diff --git a/Package.swift b/Package.swift index fc04238..dc5e75c 100644 --- a/Package.swift +++ b/Package.swift @@ -23,5 +23,15 @@ let package = Package( dependencies: ["DSKit", "AI"], swiftSettings: [.swiftLanguageMode(.v6)] ), + .target( + name: "AppFeature", + dependencies: ["DSKit", "AI"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "DSApp", + dependencies: ["AppFeature"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), ] ) diff --git a/Sources/AppFeature/AI/AICompletionView.swift b/Sources/AppFeature/AI/AICompletionView.swift new file mode 100644 index 0000000..bf06656 --- /dev/null +++ b/Sources/AppFeature/AI/AICompletionView.swift @@ -0,0 +1,87 @@ +import SwiftUI +import AI + +public extension AIProviderID { + var displayName: String { + switch self { + case .onDevice: return "온디바이스" + case .localMLX: return "맥미니" + case .remoteDS: return "원격 DS" + case .specialized: return "GPU 특화" + } + } +} + +/// Reusable renderer for any AICompletionResponse: answer text, numbered citations (deep-link to doc), +/// an ALWAYS-visible provider/routing badge (the no-silent-fallback visibility rule), and confidence. +public struct AICompletionView: View { + public let response: AICompletionResponse + public var onOpenDoc: (Int) -> Void + + public init(response: AICompletionResponse, onOpenDoc: @escaping (Int) -> Void) { + self.response = response + self.onOpenDoc = onOpenDoc + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Routing badges (mandatory, always shown) + HStack(spacing: 8) { + Chip(response.providerUsed.displayName, Sage.brand) + if let note = response.routingNote { + Chip(note, Sage.amber) + } + if response.finishReason != .completed { + Chip(finishLabel(response.finishReason), Sage.danger) + } + if let c = response.confidence { + Chip("신뢰도 \(confidenceLabel(c))", Sage.muted) + } + if let ms = response.latencyMs { + Text("\(Int(ms)) ms").font(.caption2).foregroundStyle(Sage.muted) + } + } + + // Answer + Text(response.text) + .font(.body) + .foregroundStyle(Sage.ink) + .textSelection(.enabled) + + // Citations + if !response.citations.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("출처").font(.caption.weight(.bold)).foregroundStyle(Sage.muted) + ForEach(response.citations) { c in + Button { onOpenDoc(c.docId) } label: { + HStack(alignment: .top, spacing: 8) { + Text("[\(c.n)]").font(.caption.weight(.bold)).foregroundStyle(Sage.brand) + VStack(alignment: .leading, spacing: 2) { + Text(c.title ?? c.sectionTitle ?? "문서 \(c.docId)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink) + Text(c.spanText).font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + } + } + } + .buttonStyle(.plain) + } + } + .padding(10) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func finishLabel(_ r: AIFinishReason) -> String { + switch r { + case .completed: return "완료" + case .refused: return "거부" + case .timeout: return "시간 초과" + case .unavailable: return "사용 불가" + case .noEvidence: return "근거 없음" + } + } + private func confidenceLabel(_ c: AIConfidence) -> String { + switch c { case .high: return "높음"; case .medium: return "중간"; case .low: return "낮음" } + } +} diff --git a/Sources/AppFeature/AI/AIService.swift b/Sources/AppFeature/AI/AIService.swift new file mode 100644 index 0000000..71e64f6 --- /dev/null +++ b/Sources/AppFeature/AI/AIService.swift @@ -0,0 +1,59 @@ +import Foundation +import AI + +/// Renderable failure (the UI never sees a raw AIRoutingError — that would break the +/// "visible error, not silent fallback" contract). +public enum AIServiceError: Error, Sendable { + case explicitUnavailable(AIProviderID) + case notConfigured(AIProviderID) + case noneAvailable(AITask) + case providerFailed(String) + case unknown(String) +} + +public enum AIResult: Sendable { + case success(AICompletionResponse) + case failure(AIServiceError) +} + +/// The single app-facing facade over the S2 fabric. Views call intent methods; AIService is the ONLY +/// place AICompletionRequest is built. An actor (not @Observable): routing is async work that should +/// serialize and not hop to MainActor. +public actor AIService { + private let router: AIRouter + + public init(router: AIRouter) { self.router = router } + + private func run(_ request: AICompletionRequest) async -> AIResult { + do { + return .success(try await router.route(request)) + } catch let e as AIRoutingError { + switch e { + case .explicitProviderUnavailable(let id): return .failure(.explicitUnavailable(id)) + case .providerNotConfigured(let id): return .failure(.notConfigured(id)) + case .noProviderAvailable(let t): return .failure(.noneAvailable(t)) + } + } catch let e as AIProviderError { + return .failure(.providerFailed("\(e)")) + } catch { + return .failure(.unknown("\(error)")) + } + } + + // Intent methods — UI gesture -> AITask. (vision is deferred: the frozen request is text-only.) + public func summarize(text: String) async -> AIResult { + await run(AICompletionRequest(task: .quickSummarize, prompt: "다음 문서를 요약", context: text)) + } + public func memoAssist(content: String) async -> AIResult { + await run(AICompletionRequest(task: .memoAssist, prompt: "제목과 태그 제안", context: content)) + } + public func askSelection(selection: String, question: String) async -> AIResult { + await run(AICompletionRequest(task: .askSelection, prompt: question, context: selection)) + } + public func corpusAsk(question: String, explicit: AIProviderID? = nil) async -> AIResult { + await run(AICompletionRequest(task: .corpusAsk, prompt: question, context: nil, explicitProvider: explicit)) + } + public func classify(documentText: String) async -> AIResult { + await run(AICompletionRequest(task: .classify, prompt: "도메인/카테고리 분류 제안", context: documentText)) + } +} diff --git a/Sources/AppFeature/AI/AppAIComposition.swift b/Sources/AppFeature/AI/AppAIComposition.swift new file mode 100644 index 0000000..53385ac --- /dev/null +++ b/Sources/AppFeature/AI/AppAIComposition.swift @@ -0,0 +1,26 @@ +import Foundation +import AI + +/// The ONE composition touch-point that names MockAIProvider. When S2 ships real providers, +/// only this file changes (mockProviders -> realProviders) — AIService, views, and intents stay put. +public enum AppAIComposition { + public static func mockProviders(unavailable: Set = []) -> [AIProviderID: any AIProvider] { + var providers: [AIProviderID: any AIProvider] = [:] + for id in AIProviderID.allCases { + providers[id] = MockAIProvider(id: id, available: !unavailable.contains(id)) + } + return providers + } + + public static func mockRouter(unavailable: Set = []) -> AIRouter { + AIRouter( + providers: mockProviders(unavailable: unavailable), + policy: .default, + log: { msg in + #if DEBUG + print("[route]", msg) + #endif + } + ) + } +} diff --git a/Sources/AppFeature/Markdown/MarkdownView.swift b/Sources/AppFeature/Markdown/MarkdownView.swift new file mode 100644 index 0000000..dcf1a56 --- /dev/null +++ b/Sources/AppFeature/Markdown/MarkdownView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +/// Lightweight block-aware Markdown renderer. SwiftUI's `AttributedString(markdown:)` is INLINE-only — +/// it silently drops block structure including GFM TABLES (the completed fixture's UCS-66 table would +/// vanish). This splits blocks by hand (headings, lists, pipe tables, fenced code, blockquote, +/// paragraphs) and renders each natively; inline emphasis/links use AttributedString per line. +/// Full CommonMark fidelity is a deferred dependency swap (swift-markdown / MarkdownUI). +public struct MarkdownView: View { + let markdown: String + public init(_ markdown: String) { self.markdown = markdown } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(MarkdownBlock.parse(markdown).enumerated()), id: \.offset) { _, block in + view(for: block) + } + } + } + + @ViewBuilder + private func view(for block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let text): + inline(text) + .font(headingFont(level)) + .foregroundStyle(Sage.ink) + .padding(.top, level <= 2 ? 6 : 2) + case .paragraph(let text): + inline(text).font(.body).foregroundStyle(Sage.ink) + case .listItem(let text): + HStack(alignment: .top, spacing: 8) { + Text("•").foregroundStyle(Sage.brand) + inline(text).font(.body).foregroundStyle(Sage.ink) + } + case .quote(let text): + inline(text) + .font(.body).foregroundStyle(Sage.muted) + .padding(.leading, 10) + .overlay(Rectangle().fill(Sage.brand).frame(width: 3), alignment: .leading) + case .code(let text): + Text(text) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Sage.ink) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + case .table(let header, let rows): + tableView(header: header, rows: rows) + } + } + + private func tableView(header: [String], rows: [[String]]) -> some View { + let columns = max(header.count, rows.map(\.count).max() ?? 0) + return VStack(spacing: 0) { + tableRow(cells: header, columns: columns, isHeader: true) + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + tableRow(cells: row, columns: columns, isHeader: false) + } + } + .background(Sage.card, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line)) + } + + private func tableRow(cells: [String], columns: Int, isHeader: Bool) -> some View { + HStack(spacing: 0) { + ForEach(0.. Text { + if let attr = try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + return Text(attr) + } + return Text(s) + } + + private func headingFont(_ level: Int) -> Font { + switch level { + case 1: return .title2.weight(.bold) + case 2: return .title3.weight(.semibold) + case 3: return .headline + default: return .subheadline.weight(.semibold) + } + } +} + +enum MarkdownBlock { + case heading(Int, String) + case paragraph(String) + case listItem(String) + case quote(String) + case code(String) + case table([String], [[String]]) + + static func parse(_ md: String) -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + let lines = md.components(separatedBy: "\n") + var i = 0 + var para: [String] = [] + + func flushPara() { + if !para.isEmpty { + blocks.append(.paragraph(para.joined(separator: " "))) + para.removeAll() + } + } + + while i < lines.count { + let raw = lines[i] + let line = raw.trimmingCharacters(in: .whitespaces) + + // fenced code + if line.hasPrefix("```") { + flushPara() + var code: [String] = [] + i += 1 + while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[i]); i += 1 + } + blocks.append(.code(code.joined(separator: "\n"))) + i += 1 + continue + } + + // GFM pipe table: a header row followed by an alignment separator row (| --- | :--: |) + if line.contains("|"), i + 1 < lines.count, isTableSeparator(lines[i + 1]) { + flushPara() + let header = splitRow(line) + var rows: [[String]] = [] + i += 2 // skip header + separator (the separator is NOT data) + while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).contains("|"), + !lines[i].trimmingCharacters(in: .whitespaces).isEmpty { + rows.append(splitRow(lines[i])); i += 1 + } + blocks.append(.table(header, rows)) + continue + } + + if line.isEmpty { flushPara(); i += 1; continue } + + if line.hasPrefix("#") { + flushPara() + let level = line.prefix(while: { $0 == "#" }).count + let text = line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces) + blocks.append(.heading(min(level, 6), text)) + i += 1; continue + } + if line.hasPrefix("> ") { + flushPara() + blocks.append(.quote(String(line.dropFirst(2)))) + i += 1; continue + } + if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { + flushPara() + blocks.append(.listItem(String(line.dropFirst(2)))) + i += 1; continue + } + + para.append(line) + i += 1 + } + flushPara() + return blocks + } + + /// A separator row is all dashes/colons/pipes/spaces, e.g. `| --- | :--: |`. + private static func isTableSeparator(_ line: String) -> Bool { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.contains("-"), t.contains("|") else { return false } + return t.allSatisfy { $0 == "-" || $0 == ":" || $0 == "|" || $0 == " " } + } + + private static func splitRow(_ line: String) -> [String] { + var t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("|") { t.removeFirst() } + if t.hasSuffix("|") { t.removeLast() } + return t.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } + } +} diff --git a/Sources/AppFeature/Pages/AskView.swift b/Sources/AppFeature/Pages/AskView.swift new file mode 100644 index 0000000..1a36eaa --- /dev/null +++ b/Sources/AppFeature/Pages/AskView.swift @@ -0,0 +1,85 @@ +import SwiftUI +import AI + +/// RAG proof page: routes corpusAsk through AIService (-> AIRouter -> MockAIProvider). Explicit backend +/// pick sets explicitProvider; an explicit-unavailable result renders a visible, non-retrying error. +struct AskView: View { + @Environment(AppModel.self) private var model + @State private var backend: BackendChoice = .auto + + var body: some View { + @Bindable var model = model + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Picker("백엔드", selection: $backend) { + ForEach(BackendChoice.allCases) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + + HStack(spacing: 8) { + TextField("코퍼스 전체에 질문", text: $model.askQuery) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await model.runAsk(backend: backend.provider) } } + Button("질문") { Task { await model.runAsk(backend: backend.provider) } } + .buttonStyle(.borderedProminent) + } + + if let result = model.askResult { + switch result { + case .success(let response): + AICompletionView(response: response) { docID in + model.section = .documents + Task { await model.openDocument(docID) } + } + if let meta = model.askMeta { + HStack(spacing: 6) { + Chip("완성도 \(meta.completeness)", Sage.muted) + if let aspects = meta.coveredAspects { + ForEach(aspects, id: \.self) { Chip($0, Sage.brand) } + } + } + } + case .failure(let err): + ErrorBanner(text: message(for: err)) + } + } else { + EmptyState(text: "질문을 입력하세요").frame(minHeight: 160) + } + } + .padding(16) + } + .background(Sage.surface) + } + + private func message(for error: AIServiceError) -> String { + switch error { + case .explicitUnavailable(let id): + return "\(id.displayName) 백엔드를 쓸 수 없습니다 — 다른 백엔드로 자동 전환하지 않았습니다. 다른 백엔드를 고르세요." + case .notConfigured(let id): return "\(id.displayName) 백엔드 미구성" + case .noneAvailable: return "응답 가능한 백엔드가 없습니다." + case .providerFailed(let s): return "응답 실패: \(s)" + case .unknown(let s): return "오류: \(s)" + } + } +} + +enum BackendChoice: String, CaseIterable, Identifiable { + case auto, onDevice, localMLX, remoteDS + var id: String { rawValue } + var label: String { + switch self { + case .auto: return "자동" + case .onDevice: return "온디바이스" + case .localMLX: return "맥미니" + case .remoteDS: return "원격 DS" + } + } + var provider: AIProviderID? { + switch self { + case .auto: return nil + case .onDevice: return .onDevice + case .localMLX: return .localMLX + case .remoteDS: return .remoteDS + } + } +} diff --git a/Sources/AppFeature/Pages/DashboardView.swift b/Sources/AppFeature/Pages/DashboardView.swift new file mode 100644 index 0000000..93901e9 --- /dev/null +++ b/Sources/AppFeature/Pages/DashboardView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a +/// domain jumps to Documents (cross-page nav proof). +struct DashboardView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let s = model.stats { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { + StatCard(title: "전체", value: s.total, color: Sage.brand) + StatCard(title: "문서", value: s.documents, color: Sage.brand) + StatCard(title: "검토 대기", value: s.reviewPending, color: Sage.amber) + StatCard(title: "파이프라인 실패", value: s.pipelineFailed, color: Sage.danger) + } + + VStack(alignment: .leading, spacing: 10) { + Text("도메인 분포").font(.headline).foregroundStyle(Sage.ink) + ForEach(s.byDomain.sorted { $0.value > $1.value }, id: \.key) { key, value in + DomainBar(name: key, count: value, max: s.byDomain.values.max() ?? 1) + .contentShape(Rectangle()) + .onTapGesture { model.section = .documents } + } + } + .padding(16) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Sage.line)) + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 200) + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/Pages/DigestView.swift b/Sources/AppFeature/Pages/DigestView.swift new file mode 100644 index 0000000..2c73bf1 --- /dev/null +++ b/Sources/AppFeature/Pages/DigestView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// News-brief reading layout (its own treatment): per-country sections of rank-ordered topic cards. +/// date-only displayed from the raw string (no Date conversion → no timezone off-by-one). +struct DigestView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let digest = model.digest { + VStack(alignment: .leading, spacing: 2) { + Text("뉴스 다이제스트").font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + Text("\(digest.digestDateDisplay) · 기사 \(digest.totalArticles ?? 0)건 · \(digest.totalTopics ?? 0)개 주제") + .font(.caption).foregroundStyle(Sage.muted) + } + + ForEach(digest.countries) { country in + VStack(alignment: .leading, spacing: 8) { + Chip(country.country, Sage.brand) + ForEach(country.topics) { topic in + VStack(alignment: .leading, spacing: 5) { + Text(topic.topicLabel).font(.headline).foregroundStyle(Sage.ink) + Text(topic.summary).font(.callout).foregroundStyle(Sage.muted) + HStack(spacing: 8) { + Text("기사 \(topic.articleCount ?? topic.articles.count)건") + .font(.caption2).foregroundStyle(Sage.muted) + if topic.llmFallbackUsed == true { + Text("fallback").font(.caption2).foregroundStyle(Sage.amber) + } + } + ForEach(topic.articles) { article in + Text("· \(article.title ?? "기사 \(article.id)")") + .font(.caption).foregroundStyle(Sage.ink) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Sage.line)) + } + } + } + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 200) + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/Pages/DocumentsView.swift b/Sources/AppFeature/Pages/DocumentsView.swift new file mode 100644 index 0000000..20eb96e --- /dev/null +++ b/Sources/AppFeature/Pages/DocumentsView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import DSKit + +struct DocumentListView: View { + @Environment(AppModel.self) private var model + + var body: some View { + let selection = Binding( + get: { model.selectedDocumentID }, + set: { if let id = $0 { Task { await model.openDocument(id) } } } + ) + List(model.documentList, selection: selection) { doc in + DocumentRow(doc: doc) + } + .listStyle(.inset) + .background(Sage.surface) + } +} + +struct DocumentRow: View { + let doc: DocumentResponse + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Chip(doc.displayFormat.uppercased(), Sage.formatColor(doc.displayFormat)) + Text(doc.title ?? doc.downloadLabel) + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if doc.pinned == true { Text("고정").font(.caption2).foregroundStyle(Sage.amber) } + } + HStack(spacing: 6) { + if let d = doc.aiDomain { Chip(d, Sage.domainColor(d)) } + if let r = doc.reviewStatus { + Text(r).font(.caption2).foregroundStyle(Sage.reviewStatusColor(r)) + } + Spacer() + Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + } + .padding(.vertical, 4) + } +} + +/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD 변환 대기' +/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.) +struct DocumentDetailView: View { + @Environment(AppModel.self) private var model + let detail: DocumentDetailResponse + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(detail.base.title ?? detail.base.downloadLabel) + .font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + + HStack(spacing: 8) { + if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) } + Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat)) + if let conf = detail.base.aiConfidence { + Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted) + } + Spacer() + if let url = model.downloadURL(for: detail.base) { + Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold)) + } + } + + if let tags = detail.base.aiTags, !tags.isEmpty { + HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } } + } + + Divider() + + if detail.mdIsRenderable, let md = detail.mdContent { + MarkdownView(md) + } else { + HStack { Chip("MD 변환 대기", Sage.amber); Spacer() } + Text(detail.extractedText ?? "본문 없음") + .font(.body).foregroundStyle(Sage.muted) + .frame(maxWidth: .infinity, alignment: .leading) + if let url = model.downloadURL(for: detail.base) { + Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url) + .font(.callout.weight(.semibold)) + } + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/Pages/MemosView.swift b/Sources/AppFeature/Pages/MemosView.swift new file mode 100644 index 0000000..0c5bc04 --- /dev/null +++ b/Sources/AppFeature/Pages/MemosView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import DSKit + +/// Capture-first treatment: quick-capture box + list distinguishing note vs audio. +struct MemoListView: View { + @Environment(AppModel.self) private var model + @State private var draft: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + TextField("빠른 메모 캡처", text: $draft) + .textFieldStyle(.roundedBorder) + Button("저장") { + let content = draft + draft = "" + Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) } + } + .buttonStyle(.bordered) + .disabled(draft.isEmpty) + } + .padding(12) + + let selection = Binding( + get: { model.selectedMemoID }, + set: { if let id = $0 { Task { await model.openMemo(id) } } } + ) + List(model.memoList, selection: selection) { memo in + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + if memo.isAudio { Chip("음성", Sage.formatColor("audio")) } + if memo.aiEventKind == "task" { Chip("할 일", Sage.amber) } + Text(memo.title ?? "메모 \(memo.id)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if memo.isPinned { Text("고정").font(.caption2).foregroundStyle(Sage.amber) } + } + Text(memo.content ?? "").font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + } + .padding(.vertical, 3) + } + .listStyle(.inset) + } + .background(Sage.surface) + } +} + +struct MemoDetailView: View { + let memo: MemoResponse + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(memo.title ?? "메모").font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + + if memo.isAudio { + HStack(spacing: 8) { + Image(systemName: "waveform") + Text("원본 재생 (스캐폴드에서는 비활성)").foregroundStyle(Sage.muted) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + } + + if let tags = memo.aiTags, !tags.isEmpty { + HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } } + } + + MarkdownView(memo.content ?? "") + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/Pages/SearchView.swift b/Sources/AppFeature/Pages/SearchView.swift new file mode 100644 index 0000000..59fc7d0 --- /dev/null +++ b/Sources/AppFeature/Pages/SearchView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import DSKit + +/// Distinct from the Documents table: relevance-forward result cards (score bar + match_reason). +struct SearchView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var model = model + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + TextField("검색어를 입력하세요", text: $model.searchQuery) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await model.runSearch() } } + Button("검색") { Task { await model.runSearch() } } + .buttonStyle(.borderedProminent) + } + .padding(12) + + if let response = model.searchResponse { + List(response.results) { result in + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + if let d = result.aiDomain { Chip(d, Sage.domainColor(d)) } + Text(result.title ?? "문서 \(result.id)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if let m = result.matchReason { + Text(m).font(.caption2).foregroundStyle(Sage.muted) + } + } + Text(result.snippet ?? result.aiSummary ?? "") + .font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + if let score = result.score { ScoreBar(score: score) } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + model.section = .documents + Task { await model.openDocument(result.id) } + } + } + .listStyle(.inset) + } else { + EmptyState(text: "검색어를 입력하세요") + } + } + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/Shell/Components.swift b/Sources/AppFeature/Shell/Components.swift new file mode 100644 index 0000000..df30ed4 --- /dev/null +++ b/Sources/AppFeature/Shell/Components.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct StatCard: View { + let title: String + let value: Int + let color: Color + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.caption).foregroundStyle(Sage.muted) + Text("\(value)").font(.title.weight(.bold)).foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line)) + } +} + +struct DomainBar: View { + let name: String + let count: Int + let max: Int + var body: some View { + HStack(spacing: 10) { + Text(name).font(.caption).foregroundStyle(Sage.ink) + .frame(width: 120, alignment: .leading).lineLimit(1) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4).fill(Sage.surface) + RoundedRectangle(cornerRadius: 4) + .fill(Sage.domainColor(name)) + .frame(width: geo.size.width * fraction) + } + } + .frame(height: 10) + Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) + .frame(width: 44, alignment: .trailing) + } + } + private var fraction: CGFloat { max > 0 ? CGFloat(count) / CGFloat(max) : 0 } +} + +struct ScoreBar: View { + let score: Double + var body: some View { + HStack(spacing: 6) { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(Sage.surface) + RoundedRectangle(cornerRadius: 3).fill(Sage.brand) + .frame(width: geo.size.width * CGFloat(min(max(score, 0), 1))) + } + } + .frame(height: 6) + Text(String(format: "%.2f", score)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + } +} + +struct ErrorBanner: View { + let text: String + var body: some View { + Text(text) + .font(.callout) + .foregroundStyle(Sage.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Sage.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Sage.danger.opacity(0.3))) + } +} diff --git a/Sources/AppFeature/Shell/RootView.swift b/Sources/AppFeature/Shell/RootView.swift new file mode 100644 index 0000000..cd8afe6 --- /dev/null +++ b/Sources/AppFeature/Shell/RootView.swift @@ -0,0 +1,116 @@ +import SwiftUI +import DSKit + +/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment +/// (no shell-level auto-inherit). macOS-only target. +public struct RootView: View { + @Environment(AppModel.self) private var model + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + public init() {} + + public var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + Sidebar() + .navigationSplitViewColumnWidth(min: 220, ideal: 250) + } content: { + ContentColumn() + .navigationSplitViewColumnWidth(min: 300, ideal: 380) + } detail: { + DetailColumn() + } + .navigationSplitViewStyle(.balanced) + .tint(Sage.brand) + .task { await model.loadInitial() } + } +} + +struct Sidebar: View { + @Environment(AppModel.self) private var model + + var body: some View { + let selection = Binding( + get: { model.section }, + set: { if let v = $0 { model.section = v } } + ) + List(selection: selection) { + Section { + ForEach(AppModel.Section.allCases) { s in + Text(s.title).tag(s) + } + } + if model.section == .documents, !model.tree.isEmpty { + Section("도메인") { + ForEach(model.tree) { node in + DomainRow(node: node) + } + } + } + } + .listStyle(.sidebar) + .background(Sage.sidebar) + } +} + +struct DomainRow: View { + @Environment(AppModel.self) private var model + let node: DomainTreeNode + + var body: some View { + HStack(spacing: 8) { + Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8) + Text(node.name).font(.callout).foregroundStyle(Sage.ink) + Spacer() + Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted) + } + .contentShape(Rectangle()) + .onTapGesture { model.section = .documents } + } +} + +struct ContentColumn: View { + @Environment(AppModel.self) private var model + + var body: some View { + Group { + switch model.section { + case .dashboard: DashboardView() + case .documents: DocumentListView() + case .search: SearchView() + case .ask: AskView() + case .memos: MemoListView() + case .digest: DigestView() + } + } + .navigationTitle(model.section.title) + } +} + +struct DetailColumn: View { + @Environment(AppModel.self) private var model + + var body: some View { + Group { + switch model.section { + case .documents: + if let d = model.documentDetail { DocumentDetailView(detail: d) } + else { EmptyState(text: "문서를 선택하세요") } + case .memos: + if let m = model.memoDetail { MemoDetailView(memo: m) } + else { EmptyState(text: "메모를 선택하세요") } + default: + EmptyState(text: model.section.title) + } + } + } +} + +struct EmptyState: View { + let text: String + var body: some View { + Text(text) + .foregroundStyle(Sage.muted) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Sage.surface) + } +} diff --git a/Sources/AppFeature/State/AppModel.swift b/Sources/AppFeature/State/AppModel.swift new file mode 100644 index 0000000..8d05b47 --- /dev/null +++ b/Sources/AppFeature/State/AppModel.swift @@ -0,0 +1,98 @@ +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)" } + } +} diff --git a/Sources/AppFeature/Theme/SageTheme.swift b/Sources/AppFeature/Theme/SageTheme.swift new file mode 100644 index 0000000..b2277fb --- /dev/null +++ b/Sources/AppFeature/Theme/SageTheme.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// Single sage token source (web F1 @theme parity). The shell/tokens are fixed; each page's magnetic +/// treatment layers on top. No emoji — status/format are color chips + short text. Status helpers are +/// per-vocabulary (md/review) so a value from one vocabulary never mis-colors another. +public enum Sage { + public static let brand = Color(hex: 0x4f8a6b) + public static let brandDark = Color(hex: 0x3d7256) + public static let surface = Color(hex: 0xf1f4ee) + public static let card = Color.white + public static let sidebar = Color(hex: 0xf4f7f1) + public static let ink = Color(hex: 0x23291f) + public static let muted = Color(hex: 0x697061) + public static let line = Color(hex: 0xdde3d6) + public static let amber = Color(hex: 0xb5840a) + public static let danger = Color(hex: 0xc0564a) + + public static func domainColor(_ d: String?) -> Color { + switch d { + case "Engineering": return Color(hex: 0x2f7d8f) + case "Industrial_Safety": return Color(hex: 0xb5840a) + case "General": return Color(hex: 0x7a8b3f) + case "Programming": return Color(hex: 0x3d7256) + case "법령": return Color(hex: 0x8a6a3f) + case "Philosophy": return Color(hex: 0x7a6a9b) + default: return muted + } + } + + public static func formatColor(_ f: String?) -> Color { + switch f?.lowercased() { + case "md": return Color(hex: 0x5a8f7a) + case "pdf": return Color(hex: 0xc0564a) + case "m4a", "mp3", "wav", "audio": return Color(hex: 0x8a6aa5) + case "html": return Color(hex: 0xc2911f) + case "docx", "xlsx", "txt": return Color(hex: 0x6f7c8a) + default: return muted + } + } + + public static func mdStatusColor(_ s: String?) -> Color { + switch s { + case "completed": return brand + case "partial": return Color(hex: 0x7a9f86) + case "processing", "pending": return amber + case "failed": return danger + default: return muted + } + } + + public static func reviewStatusColor(_ s: String?) -> Color { + switch s { + case "approved": return brand + case "pending": return amber + case "rejected": return danger + default: return muted + } + } +} + +public extension Color { + init(hex: UInt) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255.0, + green: Double((hex >> 8) & 0xff) / 255.0, + blue: Double(hex & 0xff) / 255.0, + opacity: 1.0 + ) + } +} + +/// A small color chip + short label (no emoji icons). +public struct Chip: View { + let text: String + let color: Color + public init(_ text: String, _ color: Color) { self.text = text; self.color = color } + public var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(color.opacity(0.13), in: Capsule()) + } +} diff --git a/Sources/DSApp/DSApp.swift b/Sources/DSApp/DSApp.swift new file mode 100644 index 0000000..fbba2c8 --- /dev/null +++ b/Sources/DSApp/DSApp.swift @@ -0,0 +1,24 @@ +import SwiftUI +import AppFeature + +/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider)) +/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in +/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial. +@main +struct DSApp: App { + @State private var model: AppModel + + @MainActor + init() { + _model = State(initialValue: AppModel.preview) + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(model) + .frame(minWidth: 980, minHeight: 640) + } + .windowStyle(.automatic) + } +}