Files
linkding-ios/Marks/Services/ClaudeService.swift
2026-07-02 01:11:03 -05:00

131 lines
6.3 KiB
Swift

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 {
// MARK: - Bookmark AI
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
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 [] }
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 [] }
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)
}
}
// MARK: - Podcast
func generatePodcast(url: String, title: String? = nil, sourceText: String? = nil, sourceKind: String? = nil) async throws -> String {
struct Response: Decodable { let job_id: String }
var body: [String: Any] = ["url": url]
if let title, !title.isEmpty { body["title"] = title }
if let sourceText, !sourceText.isEmpty { body["text"] = sourceText }
if let sourceKind, !sourceKind.isEmpty { body["source_kind"] = sourceKind }
let data = try await post("/v1/podcast/generate", body: body)
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, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)")
try data.write(to: dest, options: .atomic)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
return dest
}
static func cachedPodcastURL(for articleUrl: String) -> URL {
// djb2 hash stable, no CryptoKit needed
let hash = articleUrl.utf8.reduce(UInt64(5381)) { ($0 &* 31) &+ UInt64($1) }
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("podcasts")
return dir.appendingPathComponent("\(hash).mp3")
}
// MARK: - HTTP primitives
private func post(_ path: String, body: [String: Any]) async throws -> Data {
let token = try await MarksAuth.validToken()
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
Log.network.debug("POST \(path, privacy: .public)")
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
Log.network.info("POST \(path, privacy: .public)\(status, privacy: .public)")
guard (200...299).contains(status) else {
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
Log.network.error("POST \(path, privacy: .public)\(status, privacy: .public) body=\(bodyText, privacy: .public)")
throw APIError.badStatus(status)
}
return data
}
private func get(_ path: String) async throws -> Data {
let token = try await MarksAuth.validToken()
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
Log.network.debug("GET \(path, privacy: .public)")
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
Log.network.info("GET \(path, privacy: .public)\(status, privacy: .public)")
guard (200...299).contains(status) else {
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
Log.network.error("GET \(path, privacy: .public)\(status, privacy: .public) body=\(bodyText, privacy: .public)")
throw APIError.badStatus(status)
}
return data
}
}