Skip to content

Commit 05f6b03

Browse files
committed
Added timezone and country code picker
1 parent 5b93d2f commit 05f6b03

6 files changed

Lines changed: 1874 additions & 21 deletions

File tree

interview_coder.xcodeproj/project.pbxproj

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,29 @@
3434
7B2430AB2E96436300E3DDE4 /* Singularity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Singularity.app; sourceTree = BUILT_PRODUCTS_DIR; };
3535
7B2430BE2E96436500E3DDE4 /* interview_coderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = interview_coderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3636
7B2430C82E96436500E3DDE4 /* interview_coderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = interview_coderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
37+
7BF00BFF2F00000000AAAAAA /* interview_coder/country_codes_iso-3166.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "interview_coder/country_codes_iso-3166.json"; sourceTree = "<group>"; };
38+
7BF00C012F00000000AAAAAA /* interview_coder/timezones.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = interview_coder/timezones.json; sourceTree = "<group>"; };
3739
/* End PBXFileReference section */
3840

41+
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
42+
7BA6A9D12EAB6D300026C19E /* Exceptions for "interview_coder" folder in "interview_coder" target */ = {
43+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
44+
membershipExceptions = (
45+
"Preview Content/Preview Assets.xcassets",
46+
TODO.md,
47+
"zagent-review/review-coding-task-user-request.md",
48+
"zagent-review/review-dropdown-background.md",
49+
);
50+
target = 7B2430AA2E96436300E3DDE4 /* interview_coder */;
51+
};
52+
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
53+
3954
/* Begin PBXFileSystemSynchronizedRootGroup section */
4055
7B2430AD2E96436300E3DDE4 /* interview_coder */ = {
4156
isa = PBXFileSystemSynchronizedRootGroup;
57+
exceptions = (
58+
7BA6A9D12EAB6D300026C19E /* Exceptions for "interview_coder" folder in "interview_coder" target */,
59+
);
4260
path = interview_coder;
4361
sourceTree = "<group>";
4462
};
@@ -90,6 +108,7 @@
90108
7B2430C12E96436500E3DDE4 /* interview_coderTests */,
91109
7B2430CB2E96436500E3DDE4 /* interview_coderUITests */,
92110
7B2430AC2E96436300E3DDE4 /* Products */,
111+
7BA6A93E2EAB6A570026C19E /* Recovered References */,
93112
);
94113
sourceTree = "<group>";
95114
};
@@ -103,6 +122,15 @@
103122
name = Products;
104123
sourceTree = "<group>";
105124
};
125+
7BA6A93E2EAB6A570026C19E /* Recovered References */ = {
126+
isa = PBXGroup;
127+
children = (
128+
7BF00BFF2F00000000AAAAAA /* interview_coder/country_codes_iso-3166.json */,
129+
7BF00C012F00000000AAAAAA /* interview_coder/timezones.json */,
130+
);
131+
name = "Recovered References";
132+
sourceTree = "<group>";
133+
};
106134
/* End PBXGroup section */
107135

108136
/* Begin PBXNativeTarget section */
@@ -437,6 +465,7 @@
437465
ENABLE_RESOURCE_ACCESS_USB = NO;
438466
ENABLE_USER_SELECTED_FILES = readonly;
439467
EXCLUDED_ARCHS = Yes;
468+
EXCLUDED_SOURCE_FILE_NAMES = "*.json";
440469
GENERATE_INFOPLIST_FILE = YES;
441470
INFOPLIST_KEY_CFBundleDisplayName = Singularity;
442471
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
@@ -490,6 +519,7 @@
490519
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
491520
ENABLE_RESOURCE_ACCESS_USB = NO;
492521
ENABLE_USER_SELECTED_FILES = readonly;
522+
EXCLUDED_SOURCE_FILE_NAMES = "*.json";
493523
GENERATE_INFOPLIST_FILE = YES;
494524
INFOPLIST_KEY_CFBundleDisplayName = Singularity;
495525
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";

interview_coder/AppModel.swift

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ final class AppModel: NSObject, ObservableObject {
253253
@Published var cfgWebCity: String = ""
254254
@Published var cfgWebRegion: String = ""
255255
@Published var cfgWebTimezone: String = ""
256+
// Preset options for Web Search localization
257+
struct CountryOption: Identifiable, Hashable { let id: String; let name: String }
258+
@Published var countryOptions: [CountryOption] = []
259+
@Published var timezoneOptions: [String] = []
256260
@Published var profiles: [PromptProfile] = []
257261
@Published var selectedProfileName: String = "Default"
258262
// Image Generation settings UI state
@@ -470,6 +474,8 @@ final class AppModel: NSObject, ObservableObject {
470474
AppPluginManager.shared.refreshInstalledAsync { [weak self] list in
471475
self?.installedPlugins = list
472476
}
477+
// Load country and timezone presets for Web Search settings
478+
loadLocationPresets()
473479
loadSavedTTSPositions()
474480
// Restore manual input if pinned (unless globally suppressed for new tab creation)
475481
if manualInputPinned && !AppModel.suppressInitManualOpen {
@@ -527,6 +533,60 @@ final class AppModel: NSObject, ObservableObject {
527533
} catch { /* ignore bootstrap errors to avoid blocking launch */ }
528534
}
529535

536+
// MARK: - Location Presets
537+
private func loadLocationPresets() {
538+
// Countries: decode mapping code -> name, produce sorted options by name then code
539+
if let url = Bundle.main.url(forResource: "country_codes_iso-3166", withExtension: "json"),
540+
let data = try? Data(contentsOf: url),
541+
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
542+
let items = dict.map { CountryOption(id: $0.key.uppercased(), name: $0.value) }
543+
countryOptions = items.sorted { (a, b) in
544+
if a.name == b.name { return a.id < b.id }
545+
return a.name < b.name
546+
}
547+
// Normalize current selection to uppercase for matching
548+
if !cfgWebCountry.isEmpty { cfgWebCountry = cfgWebCountry.uppercased() }
549+
// If current selection not found, clear to avoid invalid-tag warnings
550+
if !cfgWebCountry.isEmpty && !countryOptions.contains(where: { $0.id == cfgWebCountry }) { cfgWebCountry = "" }
551+
}
552+
// Fallback to system-provided region codes if JSON not available or empty
553+
if countryOptions.isEmpty {
554+
let codes = Locale.isoRegionCodes
555+
let loc = Locale.autoupdatingCurrent
556+
let items = codes.map { code -> CountryOption in
557+
let name = loc.localizedString(forRegionCode: code) ?? code
558+
return CountryOption(id: code.uppercased(), name: name)
559+
}
560+
countryOptions = items.sorted { (a, b) in
561+
if a.name == b.name { return a.id < b.id }
562+
return a.name < b.name
563+
}
564+
}
565+
// If no selection, try system default region
566+
if cfgWebCountry.isEmpty, let sys = Locale.autoupdatingCurrent.regionCode?.uppercased(), countryOptions.contains(where: { $0.id == sys }) {
567+
cfgWebCountry = sys
568+
}
569+
// Timezones: decode array of entries, gather all UTC identifiers, unique + sorted
570+
struct TZEntry: Decodable { let utc: [String]? }
571+
if let url = Bundle.main.url(forResource: "timezones", withExtension: "json"),
572+
let data = try? Data(contentsOf: url),
573+
let entries = try? JSONDecoder().decode([TZEntry].self, from: data) {
574+
var set: Set<String> = []
575+
for e in entries { for id in (e.utc ?? []) { set.insert(id) } }
576+
timezoneOptions = Array(set).sorted()
577+
if !cfgWebTimezone.isEmpty && !timezoneOptions.contains(cfgWebTimezone) { cfgWebTimezone = "" }
578+
}
579+
// Fallback to system known identifiers if JSON missing or empty
580+
if timezoneOptions.isEmpty {
581+
timezoneOptions = TimeZone.knownTimeZoneIdentifiers.sorted()
582+
}
583+
// If no selection, default to current timezone
584+
if cfgWebTimezone.isEmpty {
585+
let current = TimeZone.autoupdatingCurrent.identifier
586+
if timezoneOptions.contains(current) { cfgWebTimezone = current }
587+
}
588+
}
589+
530590
struct SessionInfo: Identifiable, Hashable {
531591
let id: String
532592
let url: URL
@@ -2786,11 +2846,21 @@ table{border-collapse:collapse;width:100%;} th,td{border:1px solid rgba(127,127,
27862846
private func buildWebSearchConfig() -> WebSearchConfig? {
27872847
guard cfgWebSearchEnabled else { return nil }
27882848
let allowed = parseAllowedDomains()
2789-
let country = cfgWebCountry.trimmingCharacters(in: .whitespacesAndNewlines)
2849+
let rawCountry = cfgWebCountry.trimmingCharacters(in: .whitespacesAndNewlines)
2850+
let country = rawCountry.uppercased()
2851+
let validCountry = country.range(of: "^[A-Z]{2}$", options: .regularExpression) != nil ? country : ""
27902852
let city = cfgWebCity.trimmingCharacters(in: .whitespacesAndNewlines)
27912853
let region = cfgWebRegion.trimmingCharacters(in: .whitespacesAndNewlines)
27922854
let tz = cfgWebTimezone.trimmingCharacters(in: .whitespacesAndNewlines)
2793-
return WebSearchConfig(enabled: true, allowedDomains: allowed, country: country.isEmpty ? nil : country, city: city.isEmpty ? nil : city, region: region.isEmpty ? nil : region, timezone: tz.isEmpty ? nil : tz)
2855+
let validTZ = (!tz.isEmpty && TimeZone(identifier: tz) != nil) ? tz : ""
2856+
return WebSearchConfig(
2857+
enabled: true,
2858+
allowedDomains: allowed,
2859+
country: validCountry.isEmpty ? nil : validCountry,
2860+
city: city.isEmpty ? nil : city,
2861+
region: region.isEmpty ? nil : region,
2862+
timezone: validTZ.isEmpty ? nil : validTZ
2863+
)
27942864
}
27952865

27962866
private func cancelDeltaTimers() {

interview_coder/LLMService.swift

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -485,11 +485,18 @@ extension OpenAIResponsesService {
485485
"max_output_tokens": ConfigService.maxOutputTokens()
486486
]
487487
if let web, web.enabled {
488-
var tools: [[String: Any]] = [["type": "web_search"]]
489-
if !web.allowedDomains.isEmpty {
490-
tools = [["type": "web_search", "filters": ["allowed_domains": web.allowedDomains]]]
488+
var tool: [String: Any] = ["type": "web_search"]
489+
if !web.allowedDomains.isEmpty { tool["filters"] = ["allowed_domains": web.allowedDomains] }
490+
// Build validated user_location, following Responses tool schema
491+
var userLoc: [String: Any] = ["type": "approximate"]
492+
if let cc = web.country?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), cc.range(of: "^[A-Z]{2}$", options: .regularExpression) != nil {
493+
userLoc["country"] = cc
491494
}
492-
body["tools"] = tools
495+
if let city = web.city?.trimmingCharacters(in: .whitespacesAndNewlines), !city.isEmpty { userLoc["city"] = city }
496+
if let region = web.region?.trimmingCharacters(in: .whitespacesAndNewlines), !region.isEmpty { userLoc["region"] = region }
497+
if let tz = web.timezone?.trimmingCharacters(in: .whitespacesAndNewlines), !tz.isEmpty, TimeZone(identifier: tz) != nil { userLoc["timezone"] = tz }
498+
if userLoc.keys.count > 1 { tool["user_location"] = userLoc } // more than just type
499+
body["tools"] = [tool]
493500
body["tool_choice"] = "auto"
494501
var include: [String] = []
495502
if let cur = body["include"] as? [String] { include = cur }
@@ -541,11 +548,17 @@ extension OpenAIResponsesService {
541548
"stream": true
542549
]
543550
if let web, web.enabled {
544-
var tools: [[String: Any]] = [["type": "web_search"]]
545-
if !web.allowedDomains.isEmpty {
546-
tools = [["type": "web_search", "filters": ["allowed_domains": web.allowedDomains]]]
551+
var tool: [String: Any] = ["type": "web_search"]
552+
if !web.allowedDomains.isEmpty { tool["filters"] = ["allowed_domains": web.allowedDomains] }
553+
var userLoc: [String: Any] = ["type": "approximate"]
554+
if let cc = web.country?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), cc.range(of: "^[A-Z]{2}$", options: .regularExpression) != nil {
555+
userLoc["country"] = cc
547556
}
548-
body["tools"] = tools
557+
if let city = web.city?.trimmingCharacters(in: .whitespacesAndNewlines), !city.isEmpty { userLoc["city"] = city }
558+
if let region = web.region?.trimmingCharacters(in: .whitespacesAndNewlines), !region.isEmpty { userLoc["region"] = region }
559+
if let tz = web.timezone?.trimmingCharacters(in: .whitespacesAndNewlines), !tz.isEmpty, TimeZone(identifier: tz) != nil { userLoc["timezone"] = tz }
560+
if userLoc.keys.count > 1 { tool["user_location"] = userLoc }
561+
body["tools"] = [tool]
549562
body["tool_choice"] = "auto"
550563
var include: [String] = []
551564
if let cur = body["include"] as? [String] { include = cur }
@@ -686,11 +699,21 @@ extension OpenAIResponsesService {
686699
]
687700
if let web, web.enabled {
688701
var ws: [String: Any] = [:]
689-
if let c = web.country, let city = web.city, let reg = web.region {
690-
ws["user_location"] = ["type": "approximate", "approximate": ["country": c, "city": city, "region": reg]]
691-
}
702+
// Validate inputs
703+
let cc = web.country?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
704+
let validCC = cc?.range(of: "^[A-Z]{2}$", options: .regularExpression) != nil ? cc : nil
705+
let city = web.city?.trimmingCharacters(in: .whitespacesAndNewlines)
706+
let region = web.region?.trimmingCharacters(in: .whitespacesAndNewlines)
707+
let tz = web.timezone?.trimmingCharacters(in: .whitespacesAndNewlines)
708+
let validTZ = (tz != nil && !tz!.isEmpty && TimeZone(identifier: tz!) != nil) ? tz : nil
709+
710+
var userLoc: [String: Any] = ["type": "approximate"]
711+
if let validCC { userLoc["country"] = validCC }
712+
if let city, !city.isEmpty { userLoc["city"] = city }
713+
if let region, !region.isEmpty { userLoc["region"] = region }
714+
if let validTZ { userLoc["timezone"] = validTZ }
715+
if userLoc.keys.count > 1 { ws["user_location"] = userLoc }
692716
if !web.allowedDomains.isEmpty { ws["allowed_domains"] = web.allowedDomains }
693-
if let tz = web.timezone, !tz.isEmpty { ws["timezone"] = tz }
694717
if !ws.isEmpty { payload["web_search_options"] = ws }
695718
}
696719
if supportsReasoning && !chatModelA.contains("search-preview") { payload["reasoning_effort"] = reasoningEffort; payload["verbosity"] = verbosity }
@@ -740,11 +763,20 @@ extension OpenAIResponsesService {
740763
]
741764
if let web, web.enabled {
742765
var ws: [String: Any] = [:]
743-
if let c = web.country, let city = web.city, let reg = web.region {
744-
ws["user_location"] = ["type": "approximate", "approximate": ["country": c, "city": city, "region": reg]]
745-
}
766+
let cc = web.country?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
767+
let validCC = cc?.range(of: "^[A-Z]{2}$", options: .regularExpression) != nil ? cc : nil
768+
let city = web.city?.trimmingCharacters(in: .whitespacesAndNewlines)
769+
let region = web.region?.trimmingCharacters(in: .whitespacesAndNewlines)
770+
let tz = web.timezone?.trimmingCharacters(in: .whitespacesAndNewlines)
771+
let validTZ = (tz != nil && !tz!.isEmpty && TimeZone(identifier: tz!) != nil) ? tz : nil
772+
773+
var userLoc: [String: Any] = ["type": "approximate"]
774+
if let validCC { userLoc["country"] = validCC }
775+
if let city, !city.isEmpty { userLoc["city"] = city }
776+
if let region, !region.isEmpty { userLoc["region"] = region }
777+
if let validTZ { userLoc["timezone"] = validTZ }
778+
if userLoc.keys.count > 1 { ws["user_location"] = userLoc }
746779
if !web.allowedDomains.isEmpty { ws["allowed_domains"] = web.allowedDomains }
747-
if let tz = web.timezone, !tz.isEmpty { ws["timezone"] = tz }
748780
if !ws.isEmpty { payload["web_search_options"] = ws }
749781
}
750782
if supportsReasoning && !chatModelB.contains("search-preview") { payload["reasoning_effort"] = reasoningEffort; payload["verbosity"] = verbosity }

interview_coder/PanelView.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3226,8 +3226,19 @@ private var settingsPopover: some View {
32263226
}
32273227
HStack {
32283228
Text("Country").frame(width: 120, alignment: .leading)
3229-
TextField("US", text: $model.cfgWebCountry)
3230-
.frame(width: 220)
3229+
Picker("", selection: $model.cfgWebCountry) {
3230+
Text("None").tag("")
3231+
// Fallback to show current selection if not present yet
3232+
if !model.cfgWebCountry.isEmpty && !model.countryOptions.contains(where: { $0.id == model.cfgWebCountry }) {
3233+
Text(model.cfgWebCountry).tag(model.cfgWebCountry)
3234+
}
3235+
ForEach(model.countryOptions) { opt in
3236+
Text("\(opt.id)\(opt.name)").tag(opt.id)
3237+
}
3238+
}
3239+
.labelsHidden()
3240+
.pickerStyle(.menu)
3241+
.frame(width: 260)
32313242
}
32323243
HStack {
32333244
Text("City").frame(width: 120, alignment: .leading)
@@ -3241,7 +3252,18 @@ private var settingsPopover: some View {
32413252
}
32423253
HStack {
32433254
Text("Timezone").frame(width: 120, alignment: .leading)
3244-
TextField("America/Los_Angeles", text: $model.cfgWebTimezone).frame(width: 220)
3255+
Picker("", selection: $model.cfgWebTimezone) {
3256+
Text("None").tag("")
3257+
if !model.cfgWebTimezone.isEmpty && !model.timezoneOptions.contains(model.cfgWebTimezone) {
3258+
Text(model.cfgWebTimezone).tag(model.cfgWebTimezone)
3259+
}
3260+
ForEach(model.timezoneOptions, id: \.self) { tz in
3261+
Text(tz).tag(tz)
3262+
}
3263+
}
3264+
.labelsHidden()
3265+
.pickerStyle(.menu)
3266+
.frame(width: 260)
32453267
}
32463268
if model.cfgWebSearchEnabled {
32473269
Text("Some models may be unavailable due to Web Search.")

0 commit comments

Comments
 (0)