560efb9554
- AppFeature: SageTheme tokens, AppModel (@MainActor @Observable store), RootView (DEVONthink NavigationSplitView), Dashboard/Documents(MD-first+pending fallback+?token= download)/Search/Ask/Memos/Digest pages
- AI seam: AIService actor + AIResult, AppAIComposition (MockAIProvider x4 tiers), AICompletionView (numbered citations + always-visible routing badge), backend picker with visible explicit-unavailable error
- MarkdownView: block-aware renderer (GFM table separator-row skip, AttributedString inline-only)
- DSApp: thin @main, injects FixtureDSClient + mock AIRouter (zero backend / zero LLM)
swift build (full app) + swift test (19) green under Swift 6 strict concurrency. Sources/AI untouched (isolation vs freeze 17f8830 = clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
3.9 KiB
Swift
99 lines
3.9 KiB
Swift
import SwiftUI
|
|
import Observation
|
|
import DSKit
|
|
import AI
|
|
|
|
/// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are
|
|
/// main-isolated; the DSClient returns Sendable models; AIService is an actor.
|
|
@MainActor
|
|
@Observable
|
|
public final class AppModel {
|
|
public enum Section: String, CaseIterable, Identifiable, Hashable {
|
|
case dashboard, documents, search, ask, memos, digest
|
|
public var id: String { rawValue }
|
|
public var title: String {
|
|
switch self {
|
|
case .dashboard: return "대시보드"
|
|
case .documents: return "문서"
|
|
case .search: return "검색"
|
|
case .ask: return "질문"
|
|
case .memos: return "메모"
|
|
case .digest: return "뉴스"
|
|
}
|
|
}
|
|
}
|
|
|
|
public var section: Section = .dashboard
|
|
public var selectedDocumentID: Int?
|
|
public var selectedMemoID: Int?
|
|
|
|
public var tree: [DomainTreeNode] = []
|
|
public var stats: CategoryCounts?
|
|
public var documentList: [DocumentResponse] = []
|
|
public var documentDetail: DocumentDetailResponse?
|
|
public var searchQuery: String = ""
|
|
public var searchResponse: SearchResponse?
|
|
public var askQuery: String = ""
|
|
public var askResult: AIResult?
|
|
public var askMeta: AskResponse?
|
|
public var memoList: [MemoResponse] = []
|
|
public var memoDetail: MemoResponse?
|
|
public var digest: DigestResponse?
|
|
public var errorText: String?
|
|
|
|
let client: any DSClient
|
|
let ai: AIService
|
|
/// Placeholder token from the auth fixture — builds a real-SHAPED download URL with no expectation it resolves offline.
|
|
public private(set) var accessToken: String = ""
|
|
|
|
public init(client: any DSClient, ai: AIService) {
|
|
self.client = client
|
|
self.ai = ai
|
|
}
|
|
|
|
@MainActor
|
|
public static var preview: AppModel {
|
|
AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter()))
|
|
}
|
|
|
|
public func loadInitial() async {
|
|
await guarded { self.accessToken = (try? await self.client.login(username: "hyungi", password: "x", totpCode: nil).accessToken) ?? "" }
|
|
await guarded { self.tree = try await self.client.documentTree() }
|
|
await guarded { self.stats = try await self.client.categoryCounts() }
|
|
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
|
|
await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items }
|
|
await guarded { self.digest = try await self.client.digest(date: nil, country: nil) }
|
|
}
|
|
|
|
public func openDocument(_ id: Int) async {
|
|
selectedDocumentID = id
|
|
await guarded { self.documentDetail = try await self.client.document(id: id) }
|
|
}
|
|
|
|
public func runSearch() async {
|
|
guard !searchQuery.isEmpty else { return }
|
|
await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) }
|
|
}
|
|
|
|
public func runAsk(backend: AIProviderID?) async {
|
|
guard !askQuery.isEmpty else { return }
|
|
askResult = await ai.corpusAsk(question: askQuery, explicit: backend)
|
|
await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) }
|
|
}
|
|
|
|
public func openMemo(_ id: Int) async {
|
|
selectedMemoID = id
|
|
await guarded { self.memoDetail = try await self.client.memo(id: id) }
|
|
}
|
|
|
|
public func downloadURL(for doc: DocumentResponse) -> URL? {
|
|
guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil }
|
|
return DSDownload.fileURL(base: .publicTLS, documentID: doc.id, accessToken: accessToken)
|
|
}
|
|
|
|
private func guarded(_ work: () async throws -> Void) async {
|
|
do { try await work() }
|
|
catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
|
}
|
|
}
|