import SwiftUI import Observation import DSKit import AIFabric /// 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: DSKit.AskResponse? // qualified: AIFabric also defines an 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)" } } }