Files
hyungi_document_server/Sources/AppFeature/State/AppModel.swift
T
Hyungi 52aa99ec8e merge: integrate AIFabric (S2) into S3 app — unified package
- Resolve Package.swift add/add: one manifest, single AIFabric target (Sources/AI compiled once;
  no duplicate-symbol risk) + DSKit/AppFeature/DSApp + AITests + DSKitTests, AIFabric library product kept.
- import AI -> import AIFabric across AppFeature + RouterFallbackTests (S2 renamed module).
- AppModel.askMeta qualified DSKit.AskResponse (AIFabric also defines an AskResponse for RemoteDS).

swift build + swift test green (71 tests: S2 AITests + S3 DSKitTests). Frozen AIProvider interface intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:41:30 +09:00

99 lines
3.9 KiB
Swift

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)" }
}
}