Migrate AI features to backend, add podcast generation
- ClaudeService: replace direct OpenRouter calls with backend endpoints (/v1/marks/enrich, /v1/marks/search, /v1/marks/collections) - ClaudeService: add podcast methods (generate, status, downloadAudio) - SettingsView: replace OpenRouter key field with backend URL + API key - PodcastPlayerView: new sheet — generate → poll → AVPlayer playback - BrowserView: headphones toolbar button triggers podcast for current URL - BookmarksView: "Convert to Podcast" context menu item + sheet Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,147 +1,132 @@
|
||||
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 {
|
||||
let backendUrl: String
|
||||
let apiKey: String
|
||||
static let defaultsKey = "openRouterApiKey"
|
||||
// Cheap, fast model — change to any OpenRouter model slug
|
||||
private static let model = "google/gemini-2.0-flash-lite-001"
|
||||
|
||||
static let urlKey = "marksBackendUrl"
|
||||
static let apiKeyKey = "marksApiKey"
|
||||
|
||||
static func load() -> ClaudeService? {
|
||||
guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil }
|
||||
return ClaudeService(apiKey: key)
|
||||
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(apiKey: String) {
|
||||
UserDefaults.standard.set(apiKey, forKey: defaultsKey)
|
||||
static func save(backendUrl: String, apiKey: String) {
|
||||
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]) {
|
||||
let prompt = """
|
||||
Analyze this bookmark:
|
||||
URL: \(bookmark.url)
|
||||
Title: \(bookmark.displayTitle)
|
||||
Existing tags: \(bookmark.tagNames.joined(separator: ", "))
|
||||
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
{"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]}
|
||||
|
||||
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)
|
||||
struct Response: Decodable { let summary: String; let tags: [String] }
|
||||
let body: [String: Any] = [
|
||||
"url": bookmark.url,
|
||||
"title": bookmark.displayTitle,
|
||||
"tags": bookmark.tagNames,
|
||||
]
|
||||
let data = try await post("/v1/marks/enrich", body: body)
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return (decoded.summary, decoded.tags)
|
||||
}
|
||||
|
||||
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Search query: "\(query)"
|
||||
|
||||
Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7]
|
||||
Return [] if nothing matches.
|
||||
|
||||
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 }
|
||||
struct Response: Decodable { let indices: [Int] }
|
||||
let list: [[String: Any]] = bookmarks.map { b in
|
||||
var d: [String: Any] = ["id": b.id, "title": b.displayTitle, "domain": b.domain]
|
||||
if let s = b.aiSummary { d["summary"] = s }
|
||||
return d
|
||||
}
|
||||
let data = try await post("/v1/marks/search", body: ["query": query, "bookmarks": list])
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return decoded.indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||
}
|
||||
|
||||
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.prefix(150).enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Group these bookmarks into 3-6 meaningful themed collections.
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
[{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}]
|
||||
|
||||
Bookmarks:
|
||||
\(list)
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 1024)
|
||||
return parseCollections(text, bookmarks: Array(bookmarks.prefix(150)))
|
||||
struct Item: Decodable { let name: String; let description: String; let bookmarkIds: [Int] }
|
||||
struct Response: Decodable { let collections: [Item] }
|
||||
let capped = Array(bookmarks.prefix(150))
|
||||
let list: [[String: Any]] = capped.map { b in
|
||||
["id": b.id, "title": b.displayTitle, "tags": b.tagNames]
|
||||
}
|
||||
let data = try await post("/v1/marks/collections", body: ["bookmarks": list])
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return decoded.collections.map {
|
||||
SmartCollection(name: $0.name, description: $0.description, bookmarkIds: $0.bookmarkIds)
|
||||
}
|
||||
}
|
||||
|
||||
private func callModel(prompt: String, maxTokens: Int) async throws -> String {
|
||||
let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")!
|
||||
// MARK: - Podcast
|
||||
|
||||
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)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
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)
|
||||
|
||||
print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)")
|
||||
print("[AI] POST \(path)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] callModel: status=\(status) bytes=\(data.count)")
|
||||
guard status == 200 else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
print("[AI] callModel: error body=\(body.prefix(200))")
|
||||
print("[AI] POST \(path) → \(status)")
|
||||
guard (200...299).contains(status) else {
|
||||
print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
|
||||
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
|
||||
return data
|
||||
}
|
||||
|
||||
private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let summary = json["summary"] as? String,
|
||||
let tags = json["tags"] as? [String] else { return (text, []) }
|
||||
return (summary, tags)
|
||||
}
|
||||
|
||||
private func parseIntArray(_ text: String) -> [Int] {
|
||||
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)
|
||||
private func get(_ path: String) async throws -> Data {
|
||||
guard let url = URL(string: base + path) else { throw APIError.invalidUrl }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
print("[AI] GET \(path)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] GET \(path) → \(status)")
|
||||
guard (200...299).contains(status) else {
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user