Replace manual backend config with auto JWT auth
Some checks failed
CI / build-and-deploy (push) Failing after 6s
Some checks failed
CI / build-and-deploy (push) Failing after 6s
Device auto-registers on first launch using hardcoded backend URL and anon key (MarksAuth.swift). 90-day JWT stored in UserDefaults, refreshed transparently. No settings fields needed — ClaudeService is always active and non-optional throughout the app. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.env.sample
Normal file
2
.env.sample
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
SENTRY_DSN=
|
||||||
|
ENABLE_SENTRY=false
|
||||||
30
.metadata
Normal file
30
.metadata
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
|
- platform: android
|
||||||
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
14
.runner
Normal file
14
.runner
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||||
|
"id": 3,
|
||||||
|
"uuid": "68105c19-5047-4a36-9342-2877162f169e",
|
||||||
|
"name": "marks-runner",
|
||||||
|
"token": "1e23b24d76fc51bd9b442349a780c38fceced821",
|
||||||
|
"address": "https://git.magicive.com",
|
||||||
|
"labels": [
|
||||||
|
"macos:host",
|
||||||
|
"arm64:host",
|
||||||
|
"ios:host",
|
||||||
|
"xcode:host"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -32,8 +32,7 @@ struct MainContainer: View {
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.onDisconnect = onDisconnect
|
self.onDisconnect = onDisconnect
|
||||||
let api = LinkdingAPI(config: config)
|
let api = LinkdingAPI(config: config)
|
||||||
let claude = ClaudeService.load()
|
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
|
||||||
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -11,23 +11,6 @@ struct PodcastStatus: Decodable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ClaudeService: Sendable {
|
struct ClaudeService: Sendable {
|
||||||
let backendUrl: String
|
|
||||||
let apiKey: String
|
|
||||||
|
|
||||||
static let urlKey = "marksBackendUrl"
|
|
||||||
static let apiKeyKey = "marksApiKey"
|
|
||||||
|
|
||||||
static func load() -> ClaudeService? {
|
|
||||||
guard let url = UserDefaults.standard.string(forKey: urlKey), !url.isEmpty,
|
|
||||||
let key = UserDefaults.standard.string(forKey: apiKeyKey), !key.isEmpty
|
|
||||||
else { return nil }
|
|
||||||
return ClaudeService(backendUrl: url, apiKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func save(backendUrl: String, apiKey: String) {
|
|
||||||
UserDefaults.standard.set(backendUrl, forKey: urlKey)
|
|
||||||
UserDefaults.standard.set(apiKey, forKey: apiKeyKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bookmark AI
|
// MARK: - Bookmark AI
|
||||||
|
|
||||||
@@ -94,15 +77,12 @@ struct ClaudeService: Sendable {
|
|||||||
|
|
||||||
// MARK: - HTTP primitives
|
// MARK: - HTTP primitives
|
||||||
|
|
||||||
private var base: String {
|
|
||||||
backendUrl.hasSuffix("/") ? String(backendUrl.dropLast()) : backendUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
private func post(_ path: String, body: [String: Any]) async throws -> Data {
|
private func post(_ path: String, body: [String: Any]) async throws -> Data {
|
||||||
guard let url = URL(string: base + path) else { throw APIError.invalidUrl }
|
let token = try await MarksAuth.validToken()
|
||||||
|
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
print("[AI] POST \(path)")
|
print("[AI] POST \(path)")
|
||||||
@@ -117,9 +97,10 @@ struct ClaudeService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func get(_ path: String) async throws -> Data {
|
private func get(_ path: String) async throws -> Data {
|
||||||
guard let url = URL(string: base + path) else { throw APIError.invalidUrl }
|
let token = try await MarksAuth.validToken()
|
||||||
|
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
print("[AI] GET \(path)")
|
print("[AI] GET \(path)")
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
|||||||
52
Marks/Services/MarksAuth.swift
Normal file
52
Marks/Services/MarksAuth.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// Hardcoded backend config — no user-facing settings needed
|
||||||
|
private let backendBaseURL = "https://magicive-api-production.up.railway.app"
|
||||||
|
private let marksAnonKey = "marks-anon-2025"
|
||||||
|
|
||||||
|
private let tokenKey = "marksJWT"
|
||||||
|
private let tokenExpiryKey = "marksJWTExpiry"
|
||||||
|
|
||||||
|
enum MarksAuth {
|
||||||
|
static var baseURL: String { backendBaseURL }
|
||||||
|
|
||||||
|
/// Returns a valid JWT, registering if missing or within 1 hour of expiry.
|
||||||
|
static func validToken() async throws -> String {
|
||||||
|
if let token = cached(), !token.isEmpty { return token }
|
||||||
|
return try await register()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cached() -> String? {
|
||||||
|
guard
|
||||||
|
let token = UserDefaults.standard.string(forKey: tokenKey),
|
||||||
|
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
||||||
|
expiry > Date().addingTimeInterval(3600)
|
||||||
|
else { return nil }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func register() async throws -> String {
|
||||||
|
guard let url = URL(string: "\(backendBaseURL)/api/auth/register") else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue(marksAnonKey, forHTTPHeaderField: "X-API-Key")
|
||||||
|
req.httpBody = try JSONSerialization.data(withJSONObject: ["device_id": deviceId])
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard (200...299).contains(status) else {
|
||||||
|
throw APIError.badStatus(status)
|
||||||
|
}
|
||||||
|
struct Resp: Decodable { let access_token: String; let expires_in: Int }
|
||||||
|
let resp = try JSONDecoder().decode(Resp.self, from: data)
|
||||||
|
UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
|
||||||
|
let expiry = Date().addingTimeInterval(TimeInterval(resp.expires_in))
|
||||||
|
UserDefaults.standard.set(expiry, forKey: tokenExpiryKey)
|
||||||
|
print("[Auth] Registered device \(deviceId.prefix(8))…")
|
||||||
|
return resp.access_token
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,12 +61,10 @@ struct BookmarksView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Open in Safari", systemImage: "safari")
|
Label("Open in Safari", systemImage: "safari")
|
||||||
}
|
}
|
||||||
if viewModel.claude != nil {
|
Button {
|
||||||
Button {
|
podcastBookmark = bookmark
|
||||||
podcastBookmark = bookmark
|
} label: {
|
||||||
} label: {
|
Label("Convert to Podcast", systemImage: "headphones")
|
||||||
Label("Convert to Podcast", systemImage: "headphones")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
@@ -92,22 +90,20 @@ struct BookmarksView: View {
|
|||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
if viewModel.claude != nil {
|
Menu {
|
||||||
Menu {
|
Button {
|
||||||
Button {
|
Task { await viewModel.generateSmartCollections() }
|
||||||
Task { await viewModel.generateSmartCollections() }
|
showCollections = true
|
||||||
showCollections = true
|
|
||||||
} label: {
|
|
||||||
Label("Smart Collections", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
Task { await viewModel.enrichAll() }
|
|
||||||
} label: {
|
|
||||||
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "sparkles")
|
Label("Smart Collections", systemImage: "sparkles")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.enrichAll() }
|
||||||
|
} label: {
|
||||||
|
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -163,9 +159,7 @@ struct BookmarksView: View {
|
|||||||
if new == nil { readingProgress = ReadingProgress.all() }
|
if new == nil { readingProgress = ReadingProgress.all() }
|
||||||
}
|
}
|
||||||
.sheet(item: $podcastBookmark) { bookmark in
|
.sheet(item: $podcastBookmark) { bookmark in
|
||||||
if let claude = viewModel.claude {
|
PodcastPlayerView(articleUrl: bookmark.url, claude: viewModel.claude)
|
||||||
PodcastPlayerView(articleUrl: bookmark.url, claude: claude)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
|
|||||||
@@ -16,23 +16,18 @@ final class BookmarksViewModel {
|
|||||||
var unreadFilter = false
|
var unreadFilter = false
|
||||||
|
|
||||||
private let api: LinkdingAPI
|
private let api: LinkdingAPI
|
||||||
private(set) var claude: ClaudeService?
|
let claude = ClaudeService()
|
||||||
private var enrichTask: Task<Void, Never>?
|
private var enrichTask: Task<Void, Never>?
|
||||||
private let cacheFileUrl: URL
|
private let cacheFileUrl: URL
|
||||||
|
|
||||||
init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") {
|
init(api: LinkdingAPI, cacheKey: String = "") {
|
||||||
self.api = api
|
self.api = api
|
||||||
self.claude = claude
|
|
||||||
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
|
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
|
||||||
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
|
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
|
||||||
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateClaude(_ service: ClaudeService?) {
|
|
||||||
claude = service
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(useCache: Bool = true) async {
|
func load(useCache: Bool = true) async {
|
||||||
if useCache { loadFromCache() }
|
if useCache { loadFromCache() }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -83,7 +78,7 @@ final class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func semanticSearch() async {
|
func semanticSearch() async {
|
||||||
guard let claude, !searchQuery.isEmpty else { return }
|
guard !searchQuery.isEmpty else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do {
|
||||||
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
|
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
|
||||||
@@ -102,8 +97,7 @@ final class BookmarksViewModel {
|
|||||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||||
let bookmark = try await api.createBookmark(create)
|
let bookmark = try await api.createBookmark(create)
|
||||||
bookmarks.insert(bookmark, at: 0)
|
bookmarks.insert(bookmark, at: 0)
|
||||||
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")")
|
print("[AI] addBookmark: saved id=\(bookmark.id)")
|
||||||
guard let claude else { return }
|
|
||||||
Task {
|
Task {
|
||||||
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
||||||
do {
|
do {
|
||||||
@@ -159,7 +153,7 @@ final class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateSmartCollections() async {
|
func generateSmartCollections() async {
|
||||||
guard let claude, !bookmarks.isEmpty else { return }
|
guard !bookmarks.isEmpty else { return }
|
||||||
isGeneratingCollections = true
|
isGeneratingCollections = true
|
||||||
do {
|
do {
|
||||||
let allResponse = try await api.fetchBookmarks(limit: 200)
|
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||||
@@ -171,7 +165,6 @@ final class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func enrichAll() async {
|
func enrichAll() async {
|
||||||
guard let claude else { return }
|
|
||||||
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||||
guard !toEnrich.isEmpty else { return }
|
guard !toEnrich.isEmpty else { return }
|
||||||
enrichmentProgress = 0
|
enrichmentProgress = 0
|
||||||
@@ -191,10 +184,6 @@ final class BookmarksViewModel {
|
|||||||
|
|
||||||
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||||
private func startEnrichment() {
|
private func startEnrichment() {
|
||||||
guard claude != nil else {
|
|
||||||
print("[AI] startEnrichment: skipped (no claude)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enrichTask?.cancel()
|
enrichTask?.cancel()
|
||||||
enrichTask = Task { [weak self] in
|
enrichTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -202,7 +191,7 @@ final class BookmarksViewModel {
|
|||||||
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
||||||
for i in indices {
|
for i in indices {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
guard let claude = await MainActor.run(body: { self.claude }) else { return }
|
let claude = await MainActor.run(body: { self.claude })
|
||||||
do {
|
do {
|
||||||
let bm = await MainActor.run { bookmarks[i] }
|
let bm = await MainActor.run { bookmarks[i] }
|
||||||
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
||||||
|
|||||||
@@ -185,16 +185,15 @@ private struct WebViewRepresentable: UIViewRepresentable {
|
|||||||
struct BrowserView: View {
|
struct BrowserView: View {
|
||||||
let initialURL: URL
|
let initialURL: URL
|
||||||
let initialTitle: String
|
let initialTitle: String
|
||||||
let claude: ClaudeService?
|
let claude: ClaudeService
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var state: BrowserState
|
@State private var state: BrowserState
|
||||||
@State private var readerMode = false
|
@State private var readerMode = false
|
||||||
@State private var showPodcast = false
|
@State private var showPodcast = false
|
||||||
@State private var showAISetupAlert = false
|
|
||||||
|
|
||||||
init(url: URL, title: String, claude: ClaudeService? = nil) {
|
init(url: URL, title: String, claude: ClaudeService) {
|
||||||
self.initialURL = url
|
self.initialURL = url
|
||||||
self.initialTitle = title
|
self.initialTitle = title
|
||||||
self.claude = claude
|
self.claude = claude
|
||||||
@@ -262,10 +261,7 @@ struct BrowserView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button { showPodcast = true } label: {
|
||||||
if claude != nil { showPodcast = true }
|
|
||||||
else { showAISetupAlert = true }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "headphones")
|
Image(systemName: "headphones")
|
||||||
}
|
}
|
||||||
.disabled(state.isLoading)
|
.disabled(state.isLoading)
|
||||||
@@ -284,14 +280,7 @@ struct BrowserView: View {
|
|||||||
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPodcast) {
|
.sheet(isPresented: $showPodcast) {
|
||||||
if let claude {
|
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
|
||||||
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("AI Not Configured", isPresented: $showAISetupAlert) {
|
|
||||||
Button("OK") {}
|
|
||||||
} message: {
|
|
||||||
Text("Add your Backend URL and API Key in Settings to use podcast generation.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ struct SearchView: View {
|
|||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.navigationTitle("Search")
|
.navigationTitle("Search")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if viewModel.claude != nil {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Toggle(isOn: $useSemanticSearch) {
|
||||||
Toggle(isOn: $useSemanticSearch) {
|
Label("Semantic", systemImage: "sparkles")
|
||||||
Label("Semantic", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
.toggleStyle(.button)
|
|
||||||
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
|
||||||
}
|
}
|
||||||
|
.toggleStyle(.button)
|
||||||
|
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -72,7 +70,8 @@ struct SearchView: View {
|
|||||||
private func scheduleSearch() {
|
private func scheduleSearch() {
|
||||||
semanticResults = nil
|
semanticResults = nil
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
|
guard !searchText.isEmpty, useSemanticSearch else { return }
|
||||||
|
let claude = viewModel.claude
|
||||||
searchTask = Task {
|
searchTask = Task {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|||||||
@@ -4,50 +4,15 @@ struct SettingsView: View {
|
|||||||
@Bindable var viewModel: BookmarksViewModel
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
let onDisconnect: () -> Void
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
@State private var backendUrl = UserDefaults.standard.string(forKey: ClaudeService.urlKey) ?? ""
|
|
||||||
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.apiKeyKey) ?? ""
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section("AI Features") {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
|
||||||
Text("Backend URL")
|
.font(.system(size: 13))
|
||||||
.font(.system(size: 13, weight: .medium))
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
TextField("https://api.yourserver.com", text: $backendUrl)
|
|
||||||
.textContentType(.URL)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.onChange(of: backendUrl) { _, _ in reloadClaude() }
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("API Key")
|
|
||||||
.font(.system(size: 13, weight: .medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
SecureField("Bearer token", text: $claudeKey)
|
|
||||||
.textContentType(.password)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.onChange(of: claudeKey) { _, _ in reloadClaude() }
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
if viewModel.claude != nil {
|
|
||||||
Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Enter your backend URL and API key to enable AI features.")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("AI Features")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Server") {
|
Section("Server") {
|
||||||
@@ -68,12 +33,4 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadClaude() {
|
|
||||||
ClaudeService.save(backendUrl: backendUrl, apiKey: claudeKey)
|
|
||||||
let service = backendUrl.isEmpty || claudeKey.isEmpty
|
|
||||||
? nil
|
|
||||||
: ClaudeService(backendUrl: backendUrl, apiKey: claudeKey)
|
|
||||||
viewModel.updateClaude(service)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user