Compare commits
2 Commits
ac2f9cf85d
...
db5092ce02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5092ce02 | ||
|
|
bc16ee986d |
@@ -12,7 +12,9 @@
|
|||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||||
|
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||||
|
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
|
||||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||||
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||||
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -61,11 +62,13 @@
|
|||||||
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
|
||||||
|
629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
|
||||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
||||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
||||||
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
|
||||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
@@ -76,7 +79,6 @@
|
|||||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
||||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||||
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
|
|
||||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -131,11 +133,12 @@
|
|||||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||||
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */,
|
|
||||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||||
|
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
|
||||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
||||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
|
||||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
||||||
|
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
||||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||||
@@ -268,14 +271,15 @@
|
|||||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||||
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */,
|
|
||||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||||
|
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||||
|
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||||
|
|||||||
@@ -1,147 +1,132 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct PodcastStatus: Decodable {
|
||||||
|
let status: String
|
||||||
|
let progress: Int
|
||||||
|
let title: String?
|
||||||
|
let error: String?
|
||||||
|
|
||||||
|
var isDone: Bool { status == "done" }
|
||||||
|
var isFailed: Bool { status == "error" }
|
||||||
|
}
|
||||||
|
|
||||||
struct ClaudeService: Sendable {
|
struct ClaudeService: Sendable {
|
||||||
|
let backendUrl: String
|
||||||
let apiKey: String
|
let apiKey: String
|
||||||
static let defaultsKey = "openRouterApiKey"
|
|
||||||
// Cheap, fast model — change to any OpenRouter model slug
|
static let urlKey = "marksBackendUrl"
|
||||||
private static let model = "google/gemini-2.0-flash-lite-001"
|
static let apiKeyKey = "marksApiKey"
|
||||||
|
|
||||||
static func load() -> ClaudeService? {
|
static func load() -> ClaudeService? {
|
||||||
guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil }
|
guard let url = UserDefaults.standard.string(forKey: urlKey), !url.isEmpty,
|
||||||
return ClaudeService(apiKey: key)
|
let key = UserDefaults.standard.string(forKey: apiKeyKey), !key.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return ClaudeService(backendUrl: url, apiKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(apiKey: String) {
|
static func save(backendUrl: String, apiKey: String) {
|
||||||
UserDefaults.standard.set(apiKey, forKey: defaultsKey)
|
UserDefaults.standard.set(backendUrl, forKey: urlKey)
|
||||||
|
UserDefaults.standard.set(apiKey, forKey: apiKeyKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bookmark AI
|
||||||
|
|
||||||
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
||||||
let prompt = """
|
struct Response: Decodable { let summary: String; let tags: [String] }
|
||||||
Analyze this bookmark:
|
let body: [String: Any] = [
|
||||||
URL: \(bookmark.url)
|
"url": bookmark.url,
|
||||||
Title: \(bookmark.displayTitle)
|
"title": bookmark.displayTitle,
|
||||||
Existing tags: \(bookmark.tagNames.joined(separator: ", "))
|
"tags": bookmark.tagNames,
|
||||||
|
]
|
||||||
Respond with ONLY valid JSON, no markdown:
|
let data = try await post("/v1/marks/enrich", body: body)
|
||||||
{"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]}
|
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||||
|
return (decoded.summary, decoded.tags)
|
||||||
Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated.
|
|
||||||
"""
|
|
||||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
|
||||||
return parseEnrichResponse(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
||||||
guard !bookmarks.isEmpty else { return [] }
|
guard !bookmarks.isEmpty else { return [] }
|
||||||
let list = bookmarks.enumerated().map { i, b in
|
struct Response: Decodable { let indices: [Int] }
|
||||||
"[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")"
|
let list: [[String: Any]] = bookmarks.map { b in
|
||||||
}.joined(separator: "\n")
|
var d: [String: Any] = ["id": b.id, "title": b.displayTitle, "domain": b.domain]
|
||||||
let prompt = """
|
if let s = b.aiSummary { d["summary"] = s }
|
||||||
Search query: "\(query)"
|
return d
|
||||||
|
}
|
||||||
Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7]
|
let data = try await post("/v1/marks/search", body: ["query": query, "bookmarks": list])
|
||||||
Return [] if nothing matches.
|
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||||
|
return decoded.indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||||
Bookmarks:
|
|
||||||
\(list)
|
|
||||||
"""
|
|
||||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
|
||||||
let indices = parseIntArray(text)
|
|
||||||
return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
||||||
guard !bookmarks.isEmpty else { return [] }
|
guard !bookmarks.isEmpty else { return [] }
|
||||||
let list = bookmarks.prefix(150).enumerated().map { i, b in
|
struct Item: Decodable { let name: String; let description: String; let bookmarkIds: [Int] }
|
||||||
"[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]"
|
struct Response: Decodable { let collections: [Item] }
|
||||||
}.joined(separator: "\n")
|
let capped = Array(bookmarks.prefix(150))
|
||||||
let prompt = """
|
let list: [[String: Any]] = capped.map { b in
|
||||||
Group these bookmarks into 3-6 meaningful themed collections.
|
["id": b.id, "title": b.displayTitle, "tags": b.tagNames]
|
||||||
Respond with ONLY valid JSON, no markdown:
|
}
|
||||||
[{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}]
|
let data = try await post("/v1/marks/collections", body: ["bookmarks": list])
|
||||||
|
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||||
Bookmarks:
|
return decoded.collections.map {
|
||||||
\(list)
|
SmartCollection(name: $0.name, description: $0.description, bookmarkIds: $0.bookmarkIds)
|
||||||
"""
|
}
|
||||||
let text = try await callModel(prompt: prompt, maxTokens: 1024)
|
|
||||||
return parseCollections(text, bookmarks: Array(bookmarks.prefix(150)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callModel(prompt: String, maxTokens: Int) async throws -> String {
|
// MARK: - Podcast
|
||||||
let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")!
|
|
||||||
|
func generatePodcast(url: String) async throws -> String {
|
||||||
|
struct Response: Decodable { let job_id: String }
|
||||||
|
let data = try await post("/v1/podcast/generate", body: ["url": url])
|
||||||
|
return try JSONDecoder().decode(Response.self, from: data).job_id
|
||||||
|
}
|
||||||
|
|
||||||
|
func podcastStatus(jobId: String) async throws -> PodcastStatus {
|
||||||
|
let data = try await get("/v1/podcast/status/\(jobId)")
|
||||||
|
return try JSONDecoder().decode(PodcastStatus.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadPodcastAudio(jobId: String) async throws -> URL {
|
||||||
|
let data = try await get("/v1/podcast/audio/\(jobId)")
|
||||||
|
let dest = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("podcast-\(jobId).mp3")
|
||||||
|
try data.write(to: dest, options: .atomic)
|
||||||
|
return dest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTP primitives
|
||||||
|
|
||||||
|
private var base: String {
|
||||||
|
backendUrl.hasSuffix("/") ? String(backendUrl.dropLast()) : backendUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post(_ path: String, body: [String: Any]) async throws -> Data {
|
||||||
|
guard let url = URL(string: base + 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 \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer")
|
|
||||||
request.setValue("Marks", forHTTPHeaderField: "X-Title")
|
|
||||||
|
|
||||||
let body: [String: Any] = [
|
|
||||||
"model": Self.model,
|
|
||||||
"max_tokens": maxTokens,
|
|
||||||
"messages": [["role": "user", "content": prompt]]
|
|
||||||
]
|
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
print("[AI] POST \(path)")
|
||||||
print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)")
|
|
||||||
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
|
||||||
print("[AI] callModel: status=\(status) bytes=\(data.count)")
|
print("[AI] POST \(path) → \(status)")
|
||||||
guard status == 200 else {
|
guard (200...299).contains(status) else {
|
||||||
let body = String(data: data, encoding: .utf8) ?? ""
|
print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")")
|
||||||
print("[AI] callModel: error body=\(body.prefix(200))")
|
|
||||||
throw APIError.badStatus(status)
|
throw APIError.badStatus(status)
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
struct Choice: Codable {
|
|
||||||
struct Message: Codable { let content: String }
|
|
||||||
let message: Message
|
|
||||||
}
|
|
||||||
struct Response: Codable { let choices: [Choice] }
|
|
||||||
let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? ""
|
|
||||||
print("[AI] callModel: response=\(content.prefix(100))")
|
|
||||||
return content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) {
|
private func get(_ path: String) async throws -> Data {
|
||||||
let cleaned = extractJSON(from: text)
|
guard let url = URL(string: base + path) else { throw APIError.invalidUrl }
|
||||||
guard let data = cleaned.data(using: .utf8),
|
var request = URLRequest(url: url)
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
let summary = json["summary"] as? String,
|
print("[AI] GET \(path)")
|
||||||
let tags = json["tags"] as? [String] else { return (text, []) }
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
return (summary, tags)
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
}
|
print("[AI] GET \(path) → \(status)")
|
||||||
|
guard (200...299).contains(status) else {
|
||||||
private func parseIntArray(_ text: String) -> [Int] {
|
throw APIError.badStatus(status)
|
||||||
let cleaned = extractJSON(from: text)
|
|
||||||
guard let data = cleaned.data(using: .utf8),
|
|
||||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] }
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
|
|
||||||
private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] {
|
|
||||||
let cleaned = extractJSON(from: text)
|
|
||||||
guard let data = cleaned.data(using: .utf8),
|
|
||||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
|
||||||
return arr.compactMap { dict in
|
|
||||||
guard let name = dict["name"] as? String,
|
|
||||||
let indices = dict["indices"] as? [Int] else { return nil }
|
|
||||||
let desc = dict["description"] as? String ?? ""
|
|
||||||
let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil }
|
|
||||||
return SmartCollection(name: name, description: desc, bookmarkIds: ids)
|
|
||||||
}
|
}
|
||||||
}
|
return data
|
||||||
|
|
||||||
private func extractJSON(from text: String) -> String {
|
|
||||||
if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") {
|
|
||||||
return String(text[start.upperBound..<end.lowerBound])
|
|
||||||
}
|
|
||||||
if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") {
|
|
||||||
return String(text[start...end])
|
|
||||||
}
|
|
||||||
if let start = text.firstIndex(of: "["), let end = text.lastIndex(of: "]") {
|
|
||||||
return String(text[start...end])
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct BookmarkRow: View {
|
struct BookmarkRow: View {
|
||||||
let bookmark: Bookmark
|
let bookmark: Bookmark
|
||||||
|
var readingProgress: Double = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -54,6 +55,22 @@ struct BookmarkRow: View {
|
|||||||
}
|
}
|
||||||
.padding(.leading, 40)
|
.padding(.leading, 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading progress indicator
|
||||||
|
if readingProgress > 0.02 {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.blue.opacity(0.55))
|
||||||
|
.frame(width: geo.size.width * min(readingProgress, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 2)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.padding(.leading, 40)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ struct BookmarksView: View {
|
|||||||
@State private var showAddBookmark = false
|
@State private var showAddBookmark = false
|
||||||
@State private var editingBookmark: Bookmark?
|
@State private var editingBookmark: Bookmark?
|
||||||
@State private var browsingBookmark: Bookmark?
|
@State private var browsingBookmark: Bookmark?
|
||||||
|
@State private var podcastBookmark: Bookmark?
|
||||||
|
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.bookmarks) { bookmark in
|
ForEach(viewModel.bookmarks) { bookmark in
|
||||||
BookmarkRow(bookmark: bookmark)
|
BookmarkRow(bookmark: bookmark, readingProgress: readingProgress[bookmark.url] ?? 0)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
browsingBookmark = bookmark
|
browsingBookmark = bookmark
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,13 @@ struct BookmarksView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Open in Safari", systemImage: "safari")
|
Label("Open in Safari", systemImage: "safari")
|
||||||
}
|
}
|
||||||
|
if viewModel.claude != nil {
|
||||||
|
Button {
|
||||||
|
podcastBookmark = bookmark
|
||||||
|
} label: {
|
||||||
|
Label("Convert to Podcast", systemImage: "headphones")
|
||||||
|
}
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
Task { await viewModel.archive(bookmark) }
|
Task { await viewModel.archive(bookmark) }
|
||||||
@@ -147,7 +156,15 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
.sheet(item: $browsingBookmark) { bookmark in
|
.sheet(item: $browsingBookmark) { bookmark in
|
||||||
if let url = URL(string: bookmark.url) {
|
if let url = URL(string: bookmark.url) {
|
||||||
BrowserView(url: url, title: bookmark.displayTitle)
|
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: browsingBookmark) { _, new in
|
||||||
|
if new == nil { readingProgress = ReadingProgress.all() }
|
||||||
|
}
|
||||||
|
.sheet(item: $podcastBookmark) { bookmark in
|
||||||
|
if let claude = viewModel.claude {
|
||||||
|
PodcastPlayerView(articleUrl: bookmark.url, claude: claude)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
|
// MARK: - Reading progress persistence
|
||||||
|
|
||||||
|
enum ReadingProgress {
|
||||||
|
private static let key = "readingProgress"
|
||||||
|
|
||||||
|
static func get(url: String) -> Double {
|
||||||
|
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(url: String, progress: Double) {
|
||||||
|
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
||||||
|
if progress >= 0.99 {
|
||||||
|
store.removeValue(forKey: url) // fully read — clean up
|
||||||
|
} else {
|
||||||
|
store[url] = progress
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(store, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func all() -> [String: Double] {
|
||||||
|
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowserState
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class BrowserState: NSObject, WKNavigationDelegate {
|
final class BrowserState: NSObject, WKNavigationDelegate {
|
||||||
let webView: WKWebView
|
let webView: WKWebView
|
||||||
@@ -9,16 +35,34 @@ final class BrowserState: NSObject, WKNavigationDelegate {
|
|||||||
var canGoForward = false
|
var canGoForward = false
|
||||||
var pageTitle = ""
|
var pageTitle = ""
|
||||||
var currentURL: URL?
|
var currentURL: URL?
|
||||||
|
var readingProgress: Double = 0
|
||||||
|
|
||||||
|
// Weak wrapper breaks WKUserContentController's strong retain of the handler
|
||||||
|
private final class ScrollHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
weak var state: BrowserState?
|
||||||
|
init(_ state: BrowserState) { self.state = state }
|
||||||
|
func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) {
|
||||||
|
if let p = msg.body as? Double { state?.readingProgress = p }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var scrollHandler: ScrollHandler?
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL) {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.allowsInlineMediaPlayback = true
|
config.allowsInlineMediaPlayback = true
|
||||||
webView = WKWebView(frame: .zero, configuration: config)
|
webView = WKWebView(frame: .zero, configuration: config)
|
||||||
super.init()
|
super.init()
|
||||||
|
let handler = ScrollHandler(self)
|
||||||
|
scrollHandler = handler
|
||||||
|
webView.configuration.userContentController.add(handler, name: "scrollProgress")
|
||||||
webView.navigationDelegate = self
|
webView.navigationDelegate = self
|
||||||
webView.load(URLRequest(url: url))
|
webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
}
|
}
|
||||||
@@ -35,24 +79,46 @@ final class BrowserState: NSObject, WKNavigationDelegate {
|
|||||||
canGoForward = webView.canGoForward
|
canGoForward = webView.canGoForward
|
||||||
pageTitle = webView.title ?? ""
|
pageTitle = webView.title ?? ""
|
||||||
currentURL = webView.url
|
currentURL = webView.url
|
||||||
|
|
||||||
|
// Restore persisted progress, then start tracking scroll
|
||||||
|
if let urlStr = webView.url?.absoluteString {
|
||||||
|
let saved = ReadingProgress.get(url: urlStr)
|
||||||
|
if saved > 0 {
|
||||||
|
let pct = saved * 100
|
||||||
|
webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) {
|
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false }
|
||||||
isLoading = false
|
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false }
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func enterReaderMode(completion: @escaping () -> Void) {
|
func enterReaderMode(completion: @escaping () -> Void) {
|
||||||
webView.evaluateJavaScript(readerModeJS) { _, _ in
|
webView.evaluateJavaScript(readerModeJS) { _, _ in
|
||||||
DispatchQueue.main.async { completion() }
|
DispatchQueue.main.async {
|
||||||
|
completion()
|
||||||
|
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitReaderMode() {
|
func exitReaderMode() { webView.reload() }
|
||||||
webView.reload()
|
|
||||||
|
private var scrollTrackingJS: String {
|
||||||
|
"""
|
||||||
|
(function(){
|
||||||
|
function report(){
|
||||||
|
var h=document.body.scrollHeight-window.innerHeight;
|
||||||
|
var p=h>0?window.scrollY/h:0;
|
||||||
|
window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p)));
|
||||||
|
}
|
||||||
|
window.removeEventListener('scroll',window._marksScrollFn);
|
||||||
|
window._marksScrollFn=report;
|
||||||
|
window.addEventListener('scroll',report,{passive:true});
|
||||||
|
report();
|
||||||
|
})();
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
private var readerModeJS: String {
|
private var readerModeJS: String {
|
||||||
@@ -106,24 +172,31 @@ final class BrowserState: NSObject, WKNavigationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - WebView representable
|
||||||
|
|
||||||
private struct WebViewRepresentable: UIViewRepresentable {
|
private struct WebViewRepresentable: UIViewRepresentable {
|
||||||
let webView: WKWebView
|
let webView: WKWebView
|
||||||
func makeUIView(context: Context) -> WKWebView { webView }
|
func makeUIView(context: Context) -> WKWebView { webView }
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowserView
|
||||||
|
|
||||||
struct BrowserView: View {
|
struct BrowserView: View {
|
||||||
let initialURL: URL
|
let initialURL: URL
|
||||||
let initialTitle: String
|
let initialTitle: String
|
||||||
|
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
|
||||||
|
|
||||||
init(url: URL, title: String) {
|
init(url: URL, title: String, claude: ClaudeService? = nil) {
|
||||||
self.initialURL = url
|
self.initialURL = url
|
||||||
self.initialTitle = title
|
self.initialTitle = title
|
||||||
|
self.claude = claude
|
||||||
self._state = State(initialValue: BrowserState(url: url))
|
self._state = State(initialValue: BrowserState(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +206,17 @@ struct BrowserView: View {
|
|||||||
WebViewRepresentable(webView: state.webView)
|
WebViewRepresentable(webView: state.webView)
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
|
||||||
|
// Reading progress bar
|
||||||
|
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.blue.opacity(0.7))
|
||||||
|
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
.animation(.linear(duration: 0.1), value: state.readingProgress)
|
||||||
|
}
|
||||||
|
|
||||||
if state.isLoading {
|
if state.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -148,25 +232,19 @@ struct BrowserView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button { openURL(state.currentURL ?? initialURL) } label: {
|
||||||
openURL(state.currentURL ?? initialURL)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
Button {
|
Button { state.webView.goBack() } label: {
|
||||||
state.webView.goBack()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
}
|
}
|
||||||
.disabled(!state.canGoBack)
|
.disabled(!state.canGoBack)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button { state.webView.goForward() } label: {
|
||||||
state.webView.goForward()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
}
|
}
|
||||||
.disabled(!state.canGoForward)
|
.disabled(!state.canGoForward)
|
||||||
@@ -174,12 +252,8 @@ struct BrowserView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if readerMode {
|
if readerMode { state.exitReaderMode(); readerMode = false }
|
||||||
state.exitReaderMode()
|
else { state.enterReaderMode { readerMode = true } }
|
||||||
readerMode = false
|
|
||||||
} else {
|
|
||||||
state.enterReaderMode { readerMode = true }
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
||||||
}
|
}
|
||||||
@@ -187,11 +261,29 @@ struct BrowserView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if claude != nil {
|
||||||
|
Button { showPodcast = true } label: {
|
||||||
|
Image(systemName: "headphones")
|
||||||
|
}
|
||||||
|
.disabled(state.isLoading)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
ShareLink(item: state.currentURL ?? initialURL) {
|
ShareLink(item: state.currentURL ?? initialURL) {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
let urlStr = (state.currentURL ?? initialURL).absoluteString
|
||||||
|
let p = state.readingProgress
|
||||||
|
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPodcast) {
|
||||||
|
if let claude {
|
||||||
|
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
Marks/Views/PodcastPlayerView.swift
Normal file
220
Marks/Views/PodcastPlayerView.swift
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class PodcastPlayerViewModel {
|
||||||
|
enum Phase {
|
||||||
|
case generating(progress: Double, label: String)
|
||||||
|
case downloading
|
||||||
|
case ready(title: String)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var phase: Phase = .generating(progress: 0, label: "Starting…")
|
||||||
|
var player: AVPlayer?
|
||||||
|
var isPlaying = false
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var duration: Double = 1
|
||||||
|
|
||||||
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
private var timeObserver: Any?
|
||||||
|
|
||||||
|
func start(articleUrl: String, claude: ClaudeService) {
|
||||||
|
pollingTask = Task {
|
||||||
|
do {
|
||||||
|
let jobId = try await claude.generatePodcast(url: articleUrl)
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let job = try await claude.podcastStatus(jobId: jobId)
|
||||||
|
phase = .generating(
|
||||||
|
progress: Double(job.progress) / 100,
|
||||||
|
label: Self.statusLabel(job.status)
|
||||||
|
)
|
||||||
|
if job.isDone {
|
||||||
|
phase = .downloading
|
||||||
|
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId)
|
||||||
|
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job.isFailed {
|
||||||
|
phase = .failed(job.error ?? "Generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .seconds(3))
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// sheet dismissed — clean exit
|
||||||
|
} catch {
|
||||||
|
phase = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayPause() {
|
||||||
|
guard let player else { return }
|
||||||
|
if isPlaying { player.pause() } else { player.play() }
|
||||||
|
isPlaying.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to time: Double) {
|
||||||
|
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
|
||||||
|
currentTime = time
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardown() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
player?.pause()
|
||||||
|
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||||
|
player = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPlayer(url: URL, title: String) {
|
||||||
|
let item = AVPlayerItem(url: url)
|
||||||
|
let p = AVPlayer(playerItem: item)
|
||||||
|
player = p
|
||||||
|
Task {
|
||||||
|
if let d = try? await item.asset.load(.duration), d.isNumeric {
|
||||||
|
duration = max(d.seconds, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||||
|
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
|
||||||
|
self?.currentTime = t.seconds
|
||||||
|
}
|
||||||
|
phase = .ready(title: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func statusLabel(_ status: String) -> String {
|
||||||
|
switch status {
|
||||||
|
case "queued": return "Queued…"
|
||||||
|
case "fetching": return "Fetching article…"
|
||||||
|
case "scripting": return "Writing dialogue…"
|
||||||
|
case "recording": return "Recording voices…"
|
||||||
|
case "mixing": return "Mixing audio…"
|
||||||
|
default: return "Generating…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View
|
||||||
|
|
||||||
|
struct PodcastPlayerView: View {
|
||||||
|
let articleUrl: String
|
||||||
|
let claude: ClaudeService
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var vm = PodcastPlayerViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
phaseContent
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.navigationTitle("Podcast")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { vm.start(articleUrl: articleUrl, claude: claude) }
|
||||||
|
.onDisappear { vm.teardown() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phaseContent: some View {
|
||||||
|
switch vm.phase {
|
||||||
|
case .generating(let progress, let label):
|
||||||
|
generatingView(progress: progress, label: label)
|
||||||
|
case .downloading:
|
||||||
|
generatingView(progress: 1.0, label: "Preparing audio…")
|
||||||
|
case .ready(let title):
|
||||||
|
playerView(title: title)
|
||||||
|
case .failed(let message):
|
||||||
|
failedView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generatingView(progress: Double, label: String) -> some View {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(.blue)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playerView(title: String) -> some View {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
.frame(width: 220, height: 220)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "waveform.circle.fill")
|
||||||
|
.font(.system(size: 80))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Slider(
|
||||||
|
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
|
||||||
|
in: 0...vm.duration
|
||||||
|
)
|
||||||
|
.tint(.primary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(formatTime(vm.currentTime))
|
||||||
|
Spacer()
|
||||||
|
Text(formatTime(vm.duration))
|
||||||
|
}
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { vm.togglePlayPause() } label: {
|
||||||
|
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
|
.font(.system(size: 72))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failedView(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Generation Failed")
|
||||||
|
.font(.headline)
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ s: Double) -> String {
|
||||||
|
let total = Int(max(s, 0))
|
||||||
|
return String(format: "%d:%02d", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,25 +4,36 @@ struct SettingsView: View {
|
|||||||
@Bindable var viewModel: BookmarksViewModel
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
let onDisconnect: () -> Void
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
|
@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("AI Features") {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("OpenRouter API Key")
|
Text("Backend URL")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
SecureField("sk-or-…", text: $claudeKey)
|
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)
|
.textContentType(.password)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.onChange(of: claudeKey) { _, new in
|
.onChange(of: claudeKey) { _, _ in reloadClaude() }
|
||||||
ClaudeService.save(apiKey: new)
|
|
||||||
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
@@ -31,10 +42,12 @@ struct SettingsView: View {
|
|||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
|
Text("Enter your backend URL and API key to enable AI features.")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("AI Features")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Server") {
|
Section("Server") {
|
||||||
@@ -55,4 +68,12 @@ 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