From f527c63232c1a0aeef43f59d58456bbd337fbb12 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 14:52:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(ds-app):=20macOS=20=EC=95=B1=20=EB=A7=88?= =?UTF-8?q?=EB=AC=B4=EB=A6=AC=20=E2=80=94=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=C2=B7=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=C2=B7=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20+=204=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바) - FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리) - 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴) - 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저 (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all - 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AppFeature/Files/FileTransfer.swift | 63 +++ .../Sources/AppFeature/Pages/AskView.swift | 85 ---- .../AppFeature/Pages/DashboardView.swift | 403 +++++++++++++++-- .../AppFeature/Pages/DocumentsView.swift | 404 +++++++++++++++--- .../Sources/AppFeature/Pages/MemosView.swift | 5 +- .../Sources/AppFeature/Pages/SearchView.swift | 50 --- .../Sources/AppFeature/Shell/Components.swift | 53 +++ .../Sources/AppFeature/Shell/RootView.swift | 272 +++++++++--- .../Sources/AppFeature/State/AppModel.swift | 160 ++++++- clients/ds-app/Sources/DSKit/DSClient.swift | 2 + .../Sources/DSKit/FixtureDSClient.swift | 3 + .../ds-app/Sources/DSKit/LiveDSClient.swift | 55 ++- .../Sources/DSKit/Models/Requests.swift | 19 + .../AppModelActionsTests.swift | 50 +++ .../AppFeatureTests/AppModelAuthTests.swift | 1 + .../ds-app/Tests/DSKitTests/UploadTests.swift | 42 ++ clients/ds-app/contract/CONTRACT.md | 2 +- 17 files changed, 1352 insertions(+), 317 deletions(-) create mode 100644 clients/ds-app/Sources/AppFeature/Files/FileTransfer.swift delete mode 100644 clients/ds-app/Sources/AppFeature/Pages/AskView.swift delete mode 100644 clients/ds-app/Sources/AppFeature/Pages/SearchView.swift create mode 100644 clients/ds-app/Tests/AppFeatureTests/AppModelActionsTests.swift create mode 100644 clients/ds-app/Tests/DSKitTests/UploadTests.swift diff --git a/clients/ds-app/Sources/AppFeature/Files/FileTransfer.swift b/clients/ds-app/Sources/AppFeature/Files/FileTransfer.swift new file mode 100644 index 0000000..2c68675 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Files/FileTransfer.swift @@ -0,0 +1,63 @@ +import AppKit +import Foundation + +/// macOS 파일 패널 + 네이티브 다운로드 헬퍼. AppKit(NSOpenPanel/NSSavePanel) 의존이라 AppFeature +/// (맥OS UI 계층)에 둔다 — DSKit 은 크로스플랫폼 유지(향후 iOS/watchOS). 모두 @MainActor. +@MainActor +enum FilePanels { + /// 업로드할 파일 1개 선택. 취소 시 nil. + static func pickFileToUpload() -> URL? { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.message = "업로드할 문서를 선택하세요" + panel.prompt = "업로드" + return panel.runModal() == .OK ? panel.url : nil + } + + /// 저장 위치 선택. 취소 시 nil. 사용자가 고른 위치 = 샌드박스 쓰기 권한 부여(files.user-selected). + static func pickSaveDestination(suggestedName: String) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = suggestedName + panel.message = "원본 파일을 저장할 위치" + panel.prompt = "저장" + return panel.runModal() == .OK ? panel.url : nil + } +} + +/// 원본 파일 네이티브 다운로드. 인증은 URL 쿼리의 ?token= 으로만 이뤄지므로(헤더 아님), 토큰이 든 +/// URL 은 절대 로깅/에러 메시지에 노출하지 않는다. 저장 위치는 사용자가 NSSavePanel 로 선택. +@MainActor +enum FileDownloader { + enum Outcome: Equatable { + case saved(URL) + case cancelled + case failed(String) + } + + /// `url` = DSDownload.fileURL 로 만든 ?token= 인증 URL. `suggestedName` = 원본 파일명. + static func download(from url: URL, suggestedName: String) async -> Outcome { + guard let dest = FilePanels.pickSaveDestination(suggestedName: suggestedName) else { + return .cancelled + } + do { + let (temp, response) = try await URLSession.shared.download(from: url) + // 다운로드된 임시 파일은 호출자 책임(async download 변형은 자동삭제 안 함) — 모든 종료 + // 경로에서 정리. 성공 시 move 가 temp 를 옮긴 뒤라 removeItem 은 무해한 no-op. + defer { try? FileManager.default.removeItem(at: temp) } + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + // 상태 코드만 노출 — URL/토큰은 절대 포함하지 않는다. + return .failed("다운로드 실패 (HTTP \(http.statusCode))") + } + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.moveItem(at: temp, to: dest) + return .saved(dest) + } catch { + // URLError/파일 오류의 localizedDescription 엔 URL 이 포함되지 않는다. + return .failed("저장 실패: \((error as NSError).localizedDescription)") + } + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/AskView.swift b/clients/ds-app/Sources/AppFeature/Pages/AskView.swift deleted file mode 100644 index b9d4acf..0000000 --- a/clients/ds-app/Sources/AppFeature/Pages/AskView.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI -import AIFabric - -/// 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/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift index e4925ca..fd759bf 100644 --- a/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift +++ b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift @@ -1,51 +1,386 @@ import SwiftUI +import DSKit -/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a -/// domain jumps to Documents (cross-page nav proof). +/// 홈 = 풀폭 데일리 코크핏 (시안 안1). detail 전폭을 받아 1000pt 캔버스로 좌측 정렬, 내부 2칼럼. +/// 인사 → 오늘 스트립(검토 큐 + 속보 + 스탯) → 좌(빠른캡처·최근활동)/우(도메인분포·고정). struct DashboardView: View { @Environment(AppModel.self) private var model var body: some View { - ScrollView { + ScrollView(.vertical) { 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.counts["document"] ?? 0, color: Sage.brand) - StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber) - } - - VStack(alignment: .leading, spacing: 10) { - Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink) - ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in - DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.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 { + GreetingHeader() + if model.stats == nil && model.tree.isEmpty { ProgressView().frame(maxWidth: .infinity, minHeight: 200) + } else { + TodayStrip() + HStack(alignment: .top, spacing: 18) { + VStack(alignment: .leading, spacing: 18) { + CaptureCard() + ActivityTimeline() + } + .frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 18) { + DomainDistribution() + PinnedItems() + } + .frame(width: 312) + } } } - .padding(20) + .frame(maxWidth: 1000, alignment: .leading) + .padding(.horizontal, 30) + .padding(.vertical, 26) } + .frame(maxWidth: .infinity, alignment: .topLeading) .background(Sage.surface) } +} - /// 서버 category enum → 표시명 (미등록 키는 raw 노출 — 신규 카테고리 추가에 안전). - static func categoryLabel(_ key: String) -> String { - switch key { - case "document": return "문서" - case "library": return "자료실" - case "news": return "뉴스" - case "law": return "법령" - case "memo": return "메모" - case "audio": return "오디오" - case "video": return "비디오" - default: return key +// MARK: - Greeting + +private struct GreetingHeader: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("안녕하세요, \(model.currentUser?.username ?? "사용자")") + .font(.system(size: 22, weight: .bold)).kerning(-0.4).foregroundStyle(Sage.ink) + Text("오늘도 지식 쌓는 날.").font(.callout).foregroundStyle(Sage.muted) + } + Text(Self.today).font(.caption).foregroundStyle(Sage.muted.opacity(0.8)) } + .padding(.bottom, 4) + } + + static var today: String { + let f = DateFormatter() + f.locale = Locale(identifier: "ko_KR") + f.dateFormat = "y년 M월 d일 EEEE" + return f.string(from: Date()) } } + +// MARK: - Today strip (hero) + +private struct TodayStrip: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(spacing: 14) { + HStack(alignment: .top, spacing: 0) { + reviewQueue + .frame(minWidth: 150, alignment: .leading) + Rectangle().fill(Sage.line).frame(width: 1).padding(.horizontal, 22) + digestTeaser + .frame(maxWidth: .infinity, alignment: .leading) + } + Divider().overlay(Sage.line) + statRow + } + .dashCard(padding: 20) + } + + private var reviewQueue: some View { + VStack(alignment: .leading, spacing: 4) { + Text(model.reviewPendingCount.map(String.init) ?? "—") + .font(.system(size: 38, weight: .bold)).kerning(-1.5).monospacedDigit() + .foregroundStyle(Sage.amber) + Text("검토 대기 문서").font(.caption).foregroundStyle(Sage.muted) + Button { model.section = .documents } label: { + Text("검토 시작 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder private var digestTeaser: some View { + if let t = topTopic { + Button { model.section = .digest } label: { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Chip("속보", Sage.danger) + Text("\(model.digest?.digestDateDisplay ?? "") 브리핑") + .font(.caption2).foregroundStyle(Sage.muted) + } + Text(t.label).font(.system(size: 15)).foregroundStyle(Sage.ink) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + Text(t.meta).font(.caption2).foregroundStyle(Sage.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } else { + Text("오늘 브리핑이 아직 없습니다").font(.callout).foregroundStyle(Sage.muted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var statRow: some View { + HStack(spacing: 0) { + StatCell(value: model.stats?.total ?? 0, label: "전체", color: Sage.brand) + StatCell(value: model.stats?.counts["document"] ?? 0, label: "문서") + StatCell(value: domainCount("Industrial_Safety"), label: "산업안전", + color: Sage.domainColor("Industrial_Safety")) + StatCell(value: domainCount("Engineering"), label: "엔지니어링", + color: Sage.domainColor("Engineering")) + StatCell(value: domainCount("General"), label: "자료실", color: Sage.domainColor("General")) + StatCell(value: model.stats?.counts["memo"] ?? model.memoList.count, label: "메모") + } + } + + private func domainCount(_ name: String) -> Int { + model.tree.first { $0.name == name }?.count ?? 0 + } + + private var topTopic: (label: String, meta: String)? { + guard let digest = model.digest else { return nil } + var best: (TopicResponse, String)? + for c in digest.countries { + for t in c.topics where best == nil || (t.importanceScore ?? 0) > (best!.0.importanceScore ?? 0) { + best = (t, c.country) + } + } + guard let (t, country) = best else { return nil } + let arts = t.articleCount ?? t.articles.count + var meta = "관련 기사 \(arts)건" + if let imp = t.importanceScore { meta += " · 중요도 \(String(format: "%.0f", imp))" } + if !country.isEmpty { meta += " · \(country)" } + return (t.topicLabel, meta) + } +} + +// MARK: - Left column + +private struct CaptureCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var m = model + VStack(alignment: .leading, spacing: 12) { + SectionLabel("빠른 캡처") + HStack(spacing: 8) { + TextField("메모 한 줄 남기기…", text: $m.captureText) + .textFieldStyle(.plain) + .padding(.horizontal, 14).frame(height: 38) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line)) + .onSubmit { Task { await model.saveMemo() } } + Button { Task { await model.saveMemo() } } label: { + Text("저장").font(.callout.weight(.semibold)).foregroundStyle(.white) + .padding(.horizontal, 18).frame(height: 38) + .background(Sage.brand, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(model.captureText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Button { + guard let url = FilePanels.pickFileToUpload() else { return } + Task { await model.uploadPicked(url) } + } label: { + Text("+ 파일 업로드").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Sage.brand.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .leading) + .dashCard() + } +} + +private struct ActivityTimeline: View { + @Environment(AppModel.self) private var model + + private var recent: [DocumentResponse] { + model.documentList + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + .prefix(5).map { $0 } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + SectionLabel("최근 활동") + Spacer() + Button { model.section = .documents } label: { + Text("전체 보기 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand) + } + .buttonStyle(.plain) + } + if recent.isEmpty { + Text("최근 활동이 없습니다").font(.caption).foregroundStyle(Sage.muted) + } else { + VStack(spacing: 0) { + ForEach(Array(recent.enumerated()), id: \.element.id) { idx, doc in + ActivityRow(doc: doc, isLast: idx == recent.count - 1) + if idx != recent.count - 1 { Divider().overlay(Sage.line) } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .dashCard() + } +} + +private struct ActivityRow: View { + @Environment(AppModel.self) private var model + let doc: DocumentResponse + let isLast: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(Self.relative(doc.updatedAt)) + .font(.caption2).foregroundStyle(Sage.muted) + .frame(width: 54, alignment: .trailing) + VStack(spacing: 0) { + Circle().fill(Sage.domainColor(doc.aiDomain)).frame(width: 8, height: 8).padding(.top, 4) + if !isLast { Rectangle().fill(Sage.line).frame(width: 1).frame(maxHeight: .infinity) } + } + .frame(width: 14) + VStack(alignment: .leading, spacing: 3) { + Text("\(localizedDomain(doc.aiDomain)) · \(doc.displayFormat.uppercased())") + .font(.caption2.weight(.bold)).foregroundStyle(Sage.domainColor(doc.aiDomain)) + Text(doc.title ?? doc.downloadLabel).font(.callout).foregroundStyle(Sage.ink).lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, isLast ? 0 : 10) + } + .contentShape(Rectangle()) + .onTapGesture { model.section = .documents; Task { await model.openDocument(doc.id) } } + } + + static func relative(_ date: Date?) -> String { + guard let date else { return "" } + let f = RelativeDateTimeFormatter() + f.locale = Locale(identifier: "ko_KR") + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Right column + +private struct DomainDistribution: View { + @Environment(AppModel.self) private var model + + private var domains: [DomainTreeNode] { model.tree.sorted { $0.count > $1.count } } + private var domainTotal: Int { domains.reduce(0) { $0 + $1.count } } + private var sum: Int { max(1, domainTotal) } // 0-나눗셈 가드 (막대 폭 분모 전용) + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionLabel("도메인 분포") + // 헤드라인 합계 = 막대/범례와 동일 분모(도메인 트리 합) — 사용자가 범례를 더해 같은 값에 도달. + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text("분류").font(.caption).foregroundStyle(Sage.muted) + Text("\(domainTotal)").font(.system(size: 18, weight: .semibold)) + .monospacedDigit().foregroundStyle(Sage.ink) + Text("건").font(.caption).foregroundStyle(Sage.muted) + } + GeometryReader { geo in + HStack(spacing: 2) { + ForEach(domains) { d in + Rectangle().fill(Sage.domainColor(d.name)) + .frame(width: max(2, geo.size.width * CGFloat(d.count) / CGFloat(sum))) + } + } + } + .frame(height: 8) + .clipShape(RoundedRectangle(cornerRadius: 4)) + VStack(spacing: 7) { + ForEach(domains) { d in + Button { + model.section = .documents + Task { await model.loadDocuments(domain: d.path) } + } label: { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2).fill(Sage.domainColor(d.name)).frame(width: 10, height: 10) + Text(localizedDomain(d.name)).font(.caption).foregroundStyle(Sage.ink) + .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + Text("\(d.count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) + } + } + .buttonStyle(.plain) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .dashCard() + } +} + +private struct PinnedItems: View { + @Environment(AppModel.self) private var model + + private var docs: [DocumentResponse] { model.documentList.filter { $0.pinned == true } } + private var memos: [MemoResponse] { model.memoList.filter { $0.isPinned } } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + SectionLabel("고정 항목") + Spacer() + Button { model.section = .documents } label: { + Text("관리 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand) + } + .buttonStyle(.plain) + } + if docs.isEmpty && memos.isEmpty { + Text("고정된 항목이 없습니다").font(.caption).foregroundStyle(Sage.muted) + } else { + VStack(spacing: 8) { + ForEach(docs) { d in + PinRow(kind: "문서", kindColor: Sage.domainColor("Engineering"), + title: d.title ?? d.downloadLabel, date: d.updatedAtRaw) { + model.section = .documents; Task { await model.openDocument(d.id) } + } + } + ForEach(memos) { m in + PinRow(kind: "메모", kindColor: Sage.brand, + title: m.title ?? (m.content ?? "메모"), date: m.updatedAtRaw ?? "") { + model.section = .memos; Task { await model.openMemo(m.id) } + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .dashCard() + } +} + +private struct PinRow: View { + let kind: String + let kindColor: Color + let title: String + let date: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 10) { + Chip(kind, kindColor) + Text(title).font(.caption).foregroundStyle(Sage.ink).lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Text(date.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + .padding(10) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } +} + +#if DEBUG +#Preview("Dashboard") { + @Previewable @State var model = AppModel.preview + DashboardView() + .environment(model) + .frame(width: 1100, height: 760) + .task { await model.bootstrap() } +} +#endif diff --git a/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift b/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift index 20eb96e..8f17788 100644 --- a/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift +++ b/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift @@ -1,91 +1,367 @@ import SwiftUI import DSKit -struct DocumentListView: View { +/// 문서 = DEVONthink식 컬럼 브라우저. 소스트리(분류)는 글로벌 사이드바에 있고, 이 페이지는 detail +/// 전폭 안에서 내부 HSplitView 3-pane = 컬럼 리스트 | MD 리더 | 인스펙터(토글). 도메인 필터는 +/// 사이드바가 model.loadDocuments(domain:) 로 서버 재조회. +struct DocumentsBrowser: View { @Environment(AppModel.self) private var model + @State private var showInspector = true + @State private var sortOrder = [KeyPathComparator(\DocumentResponse.sortUpdated, order: .reverse)] + + var body: some View { + HSplitView { + DocumentListTable(sortOrder: $sortOrder) + .frame(minWidth: 300, idealWidth: 360, maxWidth: 460) + DocumentReader(showInspector: $showInspector) + .frame(minWidth: 420, maxWidth: .infinity) + if showInspector, let d = model.documentDetail { + DocumentInspector(detail: d) + .frame(minWidth: 280, idealWidth: 320, maxWidth: 360) + } + } + .task { await model.ensureDocumentsLoaded() } // 진입 시 현재 필터 전체 문서 load-all + } +} + +// MARK: - Column list (sortable Table) + +private extension DocumentResponse { + var sortTitle: String { title ?? downloadLabel } + var sortFormat: String { (originalFormat ?? fileFormat ?? "").lowercased() } + var sortUpdated: String { updatedAtRaw } + /// "PDF→MD" / "MD" 식 종류 배지 라벨. + var formatBadge: String { + if let orig = originalFormat, orig.lowercased() != (fileFormat ?? "").lowercased() { + return "\(orig.uppercased())→MD" + } + return displayFormat.uppercased() + } +} + +struct DocumentListTable: View { + @Environment(AppModel.self) private var model + @Binding var sortOrder: [KeyPathComparator] + + private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) } 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)) + Group { + if model.documentList.isEmpty { + EmptyState(text: "문서가 없습니다") + } else { + Table(documents, selection: selection, sortOrder: $sortOrder) { + TableColumn("제목", value: \.sortTitle) { doc in + VStack(alignment: .leading, spacing: 2) { + Text(doc.title ?? doc.downloadLabel) + .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Sage.ink).lineLimit(1) + Text(localizedDomain(doc.aiDomain)) + .font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1) + } + .padding(.vertical, 2) + } + TableColumn("종류", value: \.sortFormat) { doc in + Chip(doc.formatBadge, Sage.formatColor(doc.originalFormat ?? doc.displayFormat)) + } + .width(min: 66, ideal: 74, max: 96) + TableColumn("수정", value: \.sortUpdated) { doc in + Text(doc.updatedAtRaw.prefix(10)) + .font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + .width(min: 78, ideal: 86, max: 110) } - Spacer() - Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + .tint(Sage.brand) } } - .padding(.vertical, 4) + .background(Sage.card) } } -/// 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 { +// MARK: - Reader + +struct DocumentReader: View { @Environment(AppModel.self) private var model + @Binding var showInspector: Bool + + var body: some View { + Group { + if let detail = model.documentDetail { + VStack(spacing: 0) { + ReaderHeader(detail: detail, showInspector: $showInspector) + ReaderBody(detail: detail) + } + } else { + EmptyState(text: "문서를 선택하세요") + } + } + .background(Sage.card) + } +} + +private struct ReaderHeader: View { + let detail: DocumentDetailResponse + @Binding var showInspector: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(crumb).font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1) + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(detail.base.title ?? detail.base.downloadLabel) + .font(.system(size: 18, weight: .heavy)).foregroundStyle(Sage.ink).lineLimit(2) + Spacer() + DownloadButton(doc: detail.base, compact: true) + inspectorToggle + } + metaBadges + tagRow + } + .padding(.horizontal, 26).padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.card) + .overlay(alignment: .bottom) { Rectangle().fill(Sage.line).frame(height: 1) } + } + + private var crumb: String { + let dom = localizedDomain(detail.base.aiDomain) + if let sub = detail.base.aiSubGroup, !sub.isEmpty { return "\(dom) › \(sub)" } + return dom + } + + /// 웹 상세 페이지 헤더 배지: 도메인 · 문서유형 · tier DEEP · 신뢰도 · PDF→MD success. + @ViewBuilder private var metaBadges: some View { + let b = detail.base + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + if let d = b.aiDomain { Chip(localizedDomain(d), Sage.domainColor(d)) } + if let t = b.documentType, !t.isEmpty { Chip(t, Sage.muted) } + if b.aiAnalysisTier == "deep" { Chip("tier DEEP", Sage.brand) } + if let c = b.aiConfidence { Chip("신뢰도 \(String(format: "%.2f", c))", Sage.brandDark) } + if detail.mdIsRenderable { Chip("PDF→MD success", Sage.mdStatusColor("completed")) } + } + } + } + + private var inspectorToggle: some View { + Button { withAnimation(.easeInOut(duration: 0.2)) { showInspector.toggle() } } label: { + Image(systemName: "info.circle").font(.system(size: 15)) + .foregroundStyle(showInspector ? Sage.brandDark : Sage.muted) + .frame(width: 30, height: 30) + .background(showInspector ? Sage.brand.opacity(0.14) : Sage.card, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(showInspector ? Sage.brand : Sage.line)) + } + .buttonStyle(.plain) + .help("인스펙터") + } + + @ViewBuilder private var tagRow: some View { + let tags = detail.base.aiTags ?? [] + if detail.mdStatus != nil || !tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + if let st = detail.mdStatus { Chip("MD \(st)", Sage.mdStatusColor(st)) } + ForEach(tags, id: \.self) { Chip($0, Sage.brand) } + } + } + } + } +} + +private struct ReaderBody: View { 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)) + HStack(spacing: 0) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 14) { + 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) + DownloadButton(doc: detail.base, compact: false) } } + .frame(maxWidth: 700, alignment: .leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 28).padding(.top, 22).padding(.bottom, 44) + } + .background(Sage.card) + } +} + +// MARK: - Inspector + +struct DocumentInspector: View { + let detail: DocumentDetailResponse + + private var base: DocumentResponse { detail.base } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + // 인사이트 (웹 상세 페이지 양식: TL;DR · 핵심점 · 심층 · 불일치) + if let tldr = (base.aiTldr ?? base.aiSummary), !tldr.isEmpty { + InspectorSection("TL;DR") { + Text(tldr).font(.system(size: 12)).foregroundStyle(Sage.ink).lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + if let bullets = base.aiBullets, !bullets.isEmpty { + InspectorSection("핵심점") { + VStack(alignment: .leading, spacing: 6) { + ForEach(bullets, id: \.self) { b in + HStack(alignment: .top, spacing: 6) { + Text("·").font(.system(size: 12, weight: .bold)).foregroundStyle(Sage.amber) + Text(b).font(.system(size: 12)).foregroundStyle(Sage.ink) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } + if let deep = base.aiDetailSummary, !deep.isEmpty { + InspectorSection("심층") { + VStack(alignment: .leading, spacing: 6) { + if base.aiAnalysisTier == "deep" { Chip("DEEP", Sage.brand) } + Text(deep).font(.system(size: 11.5)).foregroundStyle(Sage.ink).lineSpacing(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + if let inc = base.aiInconsistencies, !inc.isEmpty { + InspectorSection("불일치 \(inc.count)") { + VStack(alignment: .leading, spacing: 5) { + ForEach(inc, id: \.self) { x in + Text("· \(x)").font(.system(size: 11.5)).foregroundStyle(Sage.ink) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + // 정보 + InspectorSection("정보") { + VStack(spacing: 0) { + KV("종류", base.formatBadge) + KV("도메인", localizedDomain(base.aiDomain)) + KV("하위", base.aiSubGroup ?? "—") + KV("수정", String(base.updatedAtRaw.prefix(10))) + if let size = base.fileSize { + KV("원본", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) + } + if let st = detail.mdStatus { KV("md 상태", st, color: Sage.mdStatusColor(st)) } + if let tier = base.aiAnalysisTier { KV("tier", tier, color: Sage.brandDark) } + if let c = base.aiConfidence { KV("신뢰도", String(format: "%.2f", c), color: Sage.brand) } + KV("읽음", "\(base.reads)회") + } + } + if let tags = base.aiTags, !tags.isEmpty { + InspectorSection("태그") { TagWrap(tags: tags) } + } + } + .padding(.horizontal, 16).padding(.vertical, 18) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.sidebar) + .overlay(alignment: .leading) { Rectangle().fill(Sage.line).frame(width: 1) } + } +} + +private struct InspectorSection: View { + let title: String + @ViewBuilder let content: Content + init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title; self.content = content() } + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.system(size: 10, weight: .heavy)).tracking(0.8) + .textCase(.uppercase).foregroundStyle(Sage.muted.opacity(0.8)) + content + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct KV: View { + let k: String + let v: String + var color: Color = Sage.ink + init(_ k: String, _ v: String, color: Color = Sage.ink) { self.k = k; self.v = v; self.color = color } + var body: some View { + HStack { + Text(k).font(.system(size: 12)).foregroundStyle(Sage.muted) + Spacer() + Text(v).font(.system(size: 12, weight: .semibold)).foregroundStyle(color) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 3) + } +} + +/// 좁은 인스펙터용 태그 줄바꿈 (2개씩 한 줄 — 커스텀 Layout 없이 결정적). +private struct TagWrap: View { + let tags: [String] + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(stride(from: 0, to: tags.count, by: 2)), id: \.self) { i in + HStack(spacing: 6) { + Chip(tags[i], Sage.brand) + if i + 1 < tags.count { Chip(tags[i + 1], Sage.brand) } + Spacer(minLength: 0) + } + } + } + } +} + +// MARK: - Native download button (preserved) + +/// 원본 파일 네이티브 다운로드 버튼. ?token= 인증 URL 을 NSSavePanel 로 고른 위치에 저장(브라우저 +/// 핸드오프 아님). 진행 스피너 + 저장 결과/오류를 인라인 표시. note 문서는 다운로드 대상 없음 → 숨김. +struct DownloadButton: View { + @Environment(AppModel.self) private var model + let doc: DocumentResponse + /// compact = 헤더용 짧은 라벨(파일명만) / false = 본문 폴백용 긴 라벨. + var compact: Bool + + @State private var busy = false + @State private var status: String? + @State private var isError = false + + var body: some View { + if let url = model.downloadURL(for: doc) { + HStack(spacing: 8) { + Button { + Task { + busy = true; status = nil; isError = false + let outcome = await FileDownloader.download(from: url, suggestedName: doc.downloadLabel) + busy = false + switch outcome { + case .saved(let dest): status = "저장됨: \(dest.lastPathComponent)"; isError = false + case .cancelled: status = nil + case .failed(let msg): status = msg; isError = true + } + } + } label: { + Label(compact ? doc.downloadLabel : "원본 다운로드 — \(doc.downloadLabel)", + systemImage: "arrow.down.circle") + .font(.callout.weight(.semibold)) + } + .buttonStyle(.borderless) + .disabled(busy) + if busy { ProgressView().controlSize(.small) } + if let s = status { + Text(s).font(.caption) + .foregroundStyle(isError ? Sage.danger : Sage.muted) + .lineLimit(1) + } } - .padding(20) } - .background(Sage.surface) } } diff --git a/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift b/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift index 0c5bc04..93cff8a 100644 --- a/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift +++ b/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift @@ -13,11 +13,10 @@ struct MemoListView: View { .textFieldStyle(.roundedBorder) Button("저장") { let content = draft - draft = "" - Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) } + Task { if await model.saveMemo(content) { draft = "" } } } .buttonStyle(.bordered) - .disabled(draft.isEmpty) + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(12) diff --git a/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift b/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift deleted file mode 100644 index 59fc7d0..0000000 --- a/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift +++ /dev/null @@ -1,50 +0,0 @@ -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/clients/ds-app/Sources/AppFeature/Shell/Components.swift b/clients/ds-app/Sources/AppFeature/Shell/Components.swift index df30ed4..92cb4f5 100644 --- a/clients/ds-app/Sources/AppFeature/Shell/Components.swift +++ b/clients/ds-app/Sources/AppFeature/Shell/Components.swift @@ -1,5 +1,58 @@ import SwiftUI +/// 도메인 raw 값(영문/한자 enum 키) → 한글 표시 라벨. 색은 Sage.domainColor(raw) 가 raw 로 키잉하므로 +/// 색에는 raw, 표시에만 이 라벨을 쓴다. 미매핑은 원본 그대로. +func localizedDomain(_ raw: String?) -> String { + guard let raw, !raw.isEmpty else { return "미분류" } + // 경로형(Philosophy/Aesthetics)이면 leaf 만 매핑 시도, 없으면 leaf 원본 + let leaf = raw.split(separator: "/").last.map(String.init) ?? raw + let map: [String: String] = [ + "Engineering": "엔지니어링", "Industrial_Safety": "산업안전", "General": "자료실", + "Programming": "프로그래밍", "법령": "법령", "Philosophy": "철학", + ] + return map[raw] ?? map[leaf] ?? leaf +} + +/// 카드/섹션 머리말 라벨 (대문자·heavy·muted) — 대시보드/인스펙터 공용. +struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + var body: some View { + Text(text) + .font(.caption.weight(.heavy)) + .textCase(.uppercase) + .kerning(0.7) + .foregroundStyle(Sage.muted) + } +} + +/// 공용 카드 크롬 (Sage.card + corner 12 + Sage.line stroke + 패딩). +struct DashCard: ViewModifier { + var padding: CGFloat = 18 + func body(content: Content) -> some View { + content + .padding(padding) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line)) + } +} +extension View { func dashCard(padding: CGFloat = 18) -> some View { modifier(DashCard(padding: padding)) } } + +/// 보더리스 인라인 통계 셀 (대시보드 스탯 스트립). StatCard 와 달리 카드 테두리 없음. +struct StatCell: View { + let value: Int + let label: String + var color: Color = Sage.ink + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text("\(value)").font(.system(size: 20, weight: .semibold)).kerning(-0.6) + .monospacedDigit().foregroundStyle(color) + Text(label).font(.caption2).foregroundStyle(Sage.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct StatCard: View { let title: String let value: Int diff --git a/clients/ds-app/Sources/AppFeature/Shell/RootView.swift b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift index f327141..756426f 100644 --- a/clients/ds-app/Sources/AppFeature/Shell/RootView.swift +++ b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift @@ -1,9 +1,10 @@ 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. -/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸). +/// 2-column 셸 (사이드바 + 단일 detail). 각 섹션이 detail 전폭을 받아 자기 내부 레이아웃을 소유한다 +/// (개요=풀폭 캔버스 / 문서=내부 HSplitView 3-pane / 메모=리스트+상세). 이전 3-column 이 대시보드를 +/// 좁은 가운데칸에 욱여넣어 깨지던 문제를 구조적으로 제거. macOS-only. +/// 인증 게이트: checking(refresh 쿠키 복귀) → loggedOut(LoginView) → ready(셸). public struct RootView: View { @Environment(AppModel.self) private var model @State private var columnVisibility: NavigationSplitViewVisibility = .all @@ -29,38 +30,45 @@ public struct RootView: View { private var shell: some View { NavigationSplitView(columnVisibility: $columnVisibility) { Sidebar() - .navigationSplitViewColumnWidth(min: 220, ideal: 250) - } content: { - ContentColumn() - .navigationSplitViewColumnWidth(min: 300, ideal: 380) + .navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270) } detail: { - DetailColumn() + SectionDetail() } .navigationSplitViewStyle(.balanced) .tint(Sage.brand) + .toolbar { + ToolbarItem(placement: .primaryAction) { UploadToolbarButton() } + ToolbarItem(placement: .primaryAction) { AccountMenu() } + } .safeAreaInset(edge: .bottom) { - // 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지. - if let err = model.errorText { - HStack(spacing: 10) { - Text(err) - .font(.callout) - .foregroundStyle(.white) - .lineLimit(2) - Spacer() - Button("닫기") { model.errorText = nil } - .buttonStyle(.plain) - .foregroundStyle(.white.opacity(0.85)) + VStack(spacing: 0) { + UploadStatusBar() + // 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지. + if let err = model.errorText { + HStack(spacing: 10) { + Text(err) + .font(.callout) + .foregroundStyle(.white) + .lineLimit(2) + Spacer() + Button("닫기") { model.errorText = nil } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.85)) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Sage.danger) } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Sage.danger) } } } } +// MARK: - Sidebar + struct Sidebar: View { @Environment(AppModel.self) private var model + private let navSections: [AppModel.Section] = [.dashboard, .documents, .digest, .memos] var body: some View { let selection = Binding( @@ -68,73 +76,132 @@ struct Sidebar: View { set: { if let v = $0 { model.section = v } } ) List(selection: selection) { + BrandRow().selectionDisabled() Section { - ForEach(AppModel.Section.allCases) { s in - Text(s.title).tag(s) + ForEach(navSections) { s in + Label(s.title, systemImage: Self.icon(s)).tag(s) } } - if model.section == .documents, !model.tree.isEmpty { - Section("도메인") { - ForEach(model.tree) { node in - DomainRow(node: node) - } - } + // 문서 섹션일 때만 분류 소스트리 노출 (다른 섹션은 4-섹션만 보임). + if model.section == .documents { + DocumentsSourceSidebar() } } .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) + static func icon(_ s: AppModel.Section) -> String { + switch s { + case .dashboard: return "house" + case .documents: return "folder" + case .digest: return "newspaper" + case .memos: return "note.text" } - .contentShape(Rectangle()) - .onTapGesture { model.section = .documents } } } -struct ContentColumn: View { +struct BrandRow: View { + var body: some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 7).fill(Sage.brand).frame(width: 26, height: 26) + .overlay(Text("DS").font(.system(size: 10, weight: .heavy)).foregroundStyle(.white)) + Text("Document Server").font(.system(size: 13.5, weight: .heavy)).foregroundStyle(Sage.ink) + } + .padding(.vertical, 4) + } +} + +/// 문서 전용 소스트리: 분류(도메인 필터 = 실데이터) + 스마트그룹/태그(데이터 미연결 placeholder). +struct DocumentsSourceSidebar: View { + @Environment(AppModel.self) private var model + + var body: some View { + Section("분류") { + SourceRow(label: "전체 문서", color: nil, count: model.stats?.total, + selected: model.documentDomainFilter == nil) { + Task { await model.loadDocuments(domain: nil) } + } + ForEach(model.tree) { node in + SourceRow(label: localizedDomain(node.name), color: Sage.domainColor(node.name), + count: node.count, selected: model.documentDomainFilter == node.path) { + Task { await model.loadDocuments(domain: node.path) } + } + } + } + // 데이터 미연결 — IA 만 맞추고 비활성(가짜 카운트 금지). + Section("스마트 그룹") { + ForEach(["최근 7일", "검토 대기", "법령 알림"], id: \.self) { t in + Text(t).font(.callout).foregroundStyle(Sage.muted).opacity(0.5) + } + } + Section("태그") { + ForEach(["압력용기", "ASME", "받은편지함"], id: \.self) { t in + Text("#\(t)").font(.callout).foregroundStyle(Sage.muted).opacity(0.5) + } + } + } +} + +/// 소스트리 행 (분류). 선택 시 brand-soft 배경 — List 시스템 선택과 분리(수동 하이라이트). +struct SourceRow: View { + let label: String + let color: Color? + let count: Int? + let selected: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 8) { + if let color { RoundedRectangle(cornerRadius: 3).fill(color).frame(width: 8, height: 8) } + Text(label).font(.callout) + .foregroundStyle(selected ? Sage.brandDark : Sage.ink) + .fontWeight(selected ? .bold : .regular) + .lineLimit(1) + Spacer() + if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture(perform: action) + .listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear) + } +} + +// MARK: - Section router + +/// 선택 섹션을 detail 전폭으로 라우팅. 셸 차원 inspector/list 칼럼 없음 — 각 페이지가 내부에서 소유. +struct SectionDetail: 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() + case .dashboard: DashboardView() // 풀폭 캔버스 + case .documents: DocumentsBrowser() // 내부 HSplitView 3-pane + case .digest: DigestView() // 풀폭 (뉴스 — 후속 모닝브리핑 재구성) + case .memos: MemosBoard() // 리스트 + 상세 (후속 버킷 트리아지) } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Sage.surface) .navigationTitle(model.section.title) } } -struct DetailColumn: View { +/// 메모 — v1 리스트+상세 split (확정 버킷 트리아지는 후속 트랙). +struct MemosBoard: 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: + HSplitView { + MemoListView() + .frame(minWidth: 300, idealWidth: 360, maxWidth: 460) + Group { if let m = model.memoDetail { MemoDetailView(memo: m) } else { EmptyState(text: "메모를 선택하세요") } - default: - EmptyState(text: model.section.title) } + .frame(minWidth: 360, maxWidth: .infinity) } } } @@ -149,11 +216,96 @@ struct EmptyState: View { } } +// MARK: - Toolbar items + +/// 툴바 업로드 버튼 — NSOpenPanel 로 파일 선택 → 멀티파트 업로드. 진행 중 비활성. +struct UploadToolbarButton: View { + @Environment(AppModel.self) private var model + + var body: some View { + Button { + guard let fileURL = FilePanels.pickFileToUpload() else { return } + Task { await model.uploadPicked(fileURL) } + } label: { + Label("업로드", systemImage: "square.and.arrow.up") + } + .help("문서 업로드") + .disabled(isUploading) + } + + private var isUploading: Bool { + if case .uploading = model.uploadState { return true } + return false + } +} + +/// 계정 메뉴 — 사용자명 표시 + 로그아웃(확인 대화상자). +struct AccountMenu: View { + @Environment(AppModel.self) private var model + @State private var confirmLogout = false + + var body: some View { + Menu { + Button("로그아웃", role: .destructive) { confirmLogout = true } + } label: { + Label(model.currentUser?.username ?? "계정", systemImage: "person.crop.circle") + } + .help("계정") + .confirmationDialog("로그아웃하시겠습니까?", isPresented: $confirmLogout, titleVisibility: .visible) { + Button("로그아웃", role: .destructive) { Task { await model.logout() } } + Button("취소", role: .cancel) {} + } + } +} + +/// 업로드 진행/결과 상태바. uploading=스피너(닫기 없음) / done=성공(처리 대기 안내)+닫기 / failed=오류+닫기. +struct UploadStatusBar: View { + @Environment(AppModel.self) private var model + + var body: some View { + switch model.uploadState { + case .idle: + EmptyView() + case .uploading(let name): + row(bg: Sage.brand) { + ProgressView().controlSize(.small).tint(.white) + Text("업로드 중 — \(name)").font(.callout).foregroundStyle(.white).lineLimit(1) + Spacer() + } + case .done(let title): + row(bg: Sage.brand) { + Text("업로드 완료 — \(title) (처리 대기 중)").font(.callout).foregroundStyle(.white).lineLimit(1) + Spacer() + closeButton + } + case .failed(let msg): + row(bg: Sage.danger) { + Text("업로드 실패 — \(msg)").font(.callout).foregroundStyle(.white).lineLimit(2) + Spacer() + closeButton + } + } + } + + private var closeButton: some View { + Button("닫기") { model.dismissUploadStatus() } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.85)) + } + + private func row(bg: Color, @ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 10) { content() } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(bg) + } +} + #if DEBUG #Preview("DS App — full shell") { @Previewable @State var model = AppModel.preview RootView() .environment(model) - .frame(minWidth: 1000, minHeight: 660) + .frame(minWidth: 1100, minHeight: 700) } #endif diff --git a/clients/ds-app/Sources/AppFeature/State/AppModel.swift b/clients/ds-app/Sources/AppFeature/State/AppModel.swift index 0bb0f42..3e0084e 100644 --- a/clients/ds-app/Sources/AppFeature/State/AppModel.swift +++ b/clients/ds-app/Sources/AppFeature/State/AppModel.swift @@ -2,23 +2,24 @@ import SwiftUI import Observation import DSKit import AIFabric +import UniformTypeIdentifiers /// 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 { + /// 표시 순서 = 홈·문서·뉴스·메모. 질문(ask)·이드(AI chat)는 v1 macOS 표면에서 제거(2026-06-15) — + /// AIFabric(S2) 코드는 향후 iPhone/Watch 이드용으로 보존, UI 섹션만 미노출. public enum Section: String, CaseIterable, Identifiable, Hashable { - case dashboard, documents, search, ask, memos, digest + case dashboard, documents, digest, memos public var id: String { rawValue } public var title: String { switch self { - case .dashboard: return "대시보드" + case .dashboard: return "홈" case .documents: return "문서" - case .search: return "검색" - case .ask: return "질문" - case .memos: return "메모" case .digest: return "뉴스" + case .memos: return "메모" } } } @@ -27,19 +28,33 @@ public final class AppModel { /// → 성공 시 셸(ready). Fixture 클라이언트는 refresh 가 fixture 토큰을 돌려줘 곧장 ready. public enum AuthPhase: Equatable { case checking, loggedOut, ready } + /// 업로드 진행/결과 — 셸 하단 상태바 + 툴바 버튼 스피너용. done/failed 는 닫기 또는 다음 업로드로 소거. + public enum UploadState: Equatable, Sendable { + case idle + case uploading(name: String) + case done(title: String) + case failed(String) + } + public var section: Section = .dashboard public var selectedDocumentID: Int? public var selectedMemoID: Int? public var tree: [DomainTreeNode] = [] public var stats: CategoryCounts? + /// 검토 대기 문서 총수 (홈 검토 큐 히어로). loadInitial 에서 count 쿼리로 채움. nil=미로드. + public var reviewPendingCount: Int? + /// 로그인 사용자 (계정 메뉴 표시용). loadInitial 에서 me() 로 채움. + public var currentUser: UserResponse? + public private(set) var uploadState: UploadState = .idle + /// 홈 빠른 캡처 입력 (CaptureCard 바인딩, saveMemo 후 비움). + public var captureText: String = "" 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 + /// 문서 사이드바 분류 필터 (선택된 도메인 path, nil = 전체 문서). + public var documentDomainFilter: String? + /// 현재 필터의 전체 문서를 다 불러왔는지 (페이지네이션 load-all 완료). 섹션 재진입 중복로드 방지. + public private(set) var documentsFullyLoaded = false public var memoList: [MemoResponse] = [] public var memoDetail: MemoResponse? public var digest: DigestResponse? @@ -129,11 +144,16 @@ public final class AppModel { } public func loadInitial() async { + await guarded { self.currentUser = try await self.client.me() } 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) } + await guarded { + var q = DocumentListQuery(); q.reviewStatus = "pending"; q.pageSize = 1 + self.reviewPendingCount = try await self.client.documents(q).total + } } public func openDocument(_ id: Int) async { @@ -141,15 +161,60 @@ public final class AppModel { 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) } + /// 문서 섹션 진입 시 현재 필터의 전체 문서 확보 (중복로드 방지). 미로드 상태일 때만 load-all. + public func ensureDocumentsLoaded() async { + if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) } } - 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) } + /// 사이드바 분류 선택 → 도메인 필터로 **전체** 문서 load-all (서버 page_size 상한 100을 페이지네이션으로 + /// 모두 수집 — 1582건도 전부 노출). 페이지마다 append 라 목록이 점진적으로 채워진다. 재조회 후 + /// 선택 문서가 새 목록에 없으면 선택/상세를 비워 3-pane 정합 유지. + public func loadDocuments(domain: String?) async { + documentDomainFilter = domain + documentsFullyLoaded = false + documentList = [] + let pageSize = 100 + var page = 1 + do { + while page <= 80 { // 안전 상한 ~8000건 + var q = DocumentListQuery(); q.domain = domain; q.page = page; q.pageSize = pageSize + let resp = try await client.documents(q) + documentList.append(contentsOf: resp.items) + if resp.items.count < pageSize || documentList.count >= resp.total { break } + page += 1 + } + documentsFullyLoaded = true + } catch let e as DSError where e.isAuthExpired { + authPhase = .loggedOut + loginError = "세션이 만료되었습니다. 다시 로그인하세요." + } catch { + errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + await syncAccessToken() + if let sel = selectedDocumentID, !documentList.contains(where: { $0.id == sel }) { + selectedDocumentID = nil + documentDetail = nil + } + } + + /// 텍스트로 메모 생성 후 목록 맨 앞 반영. 성공 시 true. 빈/공백 입력은 무시(false). 에러는 + /// guarded 깔때기로 errorText 노출(삼키지 않음). + @discardableResult + public func saveMemo(_ text: String) async -> Bool { + let t = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !t.isEmpty else { return false } + var ok = false + await guarded { + let memo = try await self.client.createMemo(MemoCreate(content: t)) + self.memoList.insert(memo, at: 0) + ok = true + } + return ok + } + + /// 홈 빠른 캡처 — captureText 사용, 성공 시 입력 비움. + public func saveMemo() async { + if await saveMemo(captureText) { captureText = "" } } public func openMemo(_ id: Int) async { @@ -162,6 +227,67 @@ public final class AppModel { return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken) } + /// 로그아웃: 서버 쿠키/토큰 폐기(best-effort) 후 세션 상태 전체 초기화 → loggedOut. 다음 로그인이 + /// stale 데이터 없이 깨끗하게 시작하도록 로드 상태를 비운다. 실패해도 로컬은 무조건 로그아웃 처리. + public func logout() async { + try? await client.logout() + accessToken = "" + currentUser = nil + tree = [] + stats = nil + reviewPendingCount = nil + captureText = "" + documentList = [] + documentDetail = nil + documentDomainFilter = nil + documentsFullyLoaded = false + memoList = [] + memoDetail = nil + digest = nil + selectedDocumentID = nil + selectedMemoID = nil + section = .dashboard // 다음 로그인은 홈에서 시작 (리뷰 LOW: 이전 사용자 마지막 페이지 잔류 방지) + errorText = nil + uploadState = .idle + authPhase = .loggedOut + } + + /// 사용자가 고른 파일(NSOpenPanel 보안 스코프 URL)을 읽어 업로드. 파일 IO 실패는 uploadState 로 노출. + public func uploadPicked(_ fileURL: URL) async { + let accessed = fileURL.startAccessingSecurityScopedResource() + defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } } + let filename = fileURL.lastPathComponent + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + uploadState = .failed("파일을 읽을 수 없습니다: \((error as NSError).localizedDescription)") + return + } + let mime = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType + await upload(DocumentUpload(filename: filename, data: data, mimeType: mime)) + } + + /// 멀티파트 업로드 실행 + 결과 반영. 성공 시 목록 재로드(신규 문서 = 처리 대기 상태로 노출). + public func upload(_ payload: DocumentUpload) async { + uploadState = .uploading(name: payload.filename) + do { + let doc = try await client.uploadDocument(payload) + uploadState = .done(title: doc.title ?? doc.downloadLabel) + await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items } + } catch let e as DSError where e.isAuthExpired { + authPhase = .loggedOut + loginError = "세션이 만료되었습니다. 다시 로그인하세요." + uploadState = .failed("세션이 만료되었습니다.") + } catch { + uploadState = .failed((error as? LocalizedError)?.errorDescription ?? "\(error)") + } + await syncAccessToken() + } + + /// 업로드 상태바 닫기 (done/failed 소거). + public func dismissUploadStatus() { uploadState = .idle } + private func guarded(_ work: () async throws -> Void) async { do { try await work() diff --git a/clients/ds-app/Sources/DSKit/DSClient.swift b/clients/ds-app/Sources/DSKit/DSClient.swift index 6082ad8..c2ad643 100644 --- a/clients/ds-app/Sources/DSKit/DSClient.swift +++ b/clients/ds-app/Sources/DSKit/DSClient.swift @@ -23,6 +23,8 @@ public protocol DSClient: Sendable { func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse func putContent(id: Int, content: String) async throws func deleteDocument(id: Int) async throws + /// 멀티파트 업로드 (POST /documents/) → Inbox 저장 + 처리 큐 등록. 201 DocumentResponse. + func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse // Search / Ask func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse diff --git a/clients/ds-app/Sources/DSKit/FixtureDSClient.swift b/clients/ds-app/Sources/DSKit/FixtureDSClient.swift index 4cf9f23..0edd880 100644 --- a/clients/ds-app/Sources/DSKit/FixtureDSClient.swift +++ b/clients/ds-app/Sources/DSKit/FixtureDSClient.swift @@ -53,6 +53,9 @@ public struct FixtureDSClient: DSClient { } public func putContent(id: Int, content: String) async throws {} public func deleteDocument(id: Int) async throws {} + public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { + try load("document_detail", as: DocumentDetailResponse.self).base + } // Search / Ask public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { diff --git a/clients/ds-app/Sources/DSKit/LiveDSClient.swift b/clients/ds-app/Sources/DSKit/LiveDSClient.swift index 23fe87c..101ec2e 100644 --- a/clients/ds-app/Sources/DSKit/LiveDSClient.swift +++ b/clients/ds-app/Sources/DSKit/LiveDSClient.swift @@ -64,15 +64,26 @@ public final class LiveDSClient: DSClient, @unchecked Sendable { } private func perform(_ endpoint: DSEndpoint) async throws -> Data { - let request = try makeRequest(endpoint, token: await tokens.current()) + try await performWithRetry(requiresBearer: endpoint.requiresBearer) { token in + try self.makeRequest(endpoint, token: token) + } + } + + /// 401 단일-비행 refresh + 1회 재시도의 공용 경로. `build` 가 (현 토큰)→URLRequest 를 만들고, + /// 401 이면 새 토큰으로 한 번 더 빌드해 재전송한다. JSON 경로(perform)와 멀티파트 업로드가 공유. + private func performWithRetry( + requiresBearer: Bool, + _ build: (_ token: String?) throws -> URLRequest + ) async throws -> Data { + let request = try build(await tokens.current()) let (data, response) = try await dataOrTransport(request) guard let http = response as? HTTPURLResponse else { throw DSError.transport(underlying: "no HTTP response") } - if http.statusCode == 401, endpoint.requiresBearer { + if http.statusCode == 401, requiresBearer { // Single-flight refresh + one retry. let newToken = try await tokens.refreshOnce() - let retry = try makeRequest(endpoint, token: newToken) + let retry = try build(newToken) let (data2, response2) = try await dataOrTransport(retry) guard let http2 = response2 as? HTTPURLResponse else { throw DSError.transport(underlying: "no HTTP response") @@ -122,6 +133,44 @@ public final class LiveDSClient: DSClient, @unchecked Sendable { public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) } public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) } + public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { + let boundary = "DSBoundary-\(UUID().uuidString)" + let body = LiveDSClient.multipartBody(for: upload, boundary: boundary) + // 트레일링 슬래시 유지(POST /documents/) — base 문자열 결합 (appendingPathComponent 는 슬래시 strip). + let raw = base.url.absoluteString + "/documents/" + guard let url = URL(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") } + let data = try await performWithRetry(requiresBearer: true) { token in + var request = URLRequest(url: url) + request.httpMethod = "POST" + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + return request + } + do { return try decoder.decode(DocumentResponse.self, from: data) } + catch { throw DSError.decoding("documents/ upload: \(error)") } + } + + /// multipart/form-data 본문 생성. file 파트 + 선택 form 필드(doc_purpose/library_path). + /// internal(테스트 가시) — 한글 파일명은 UTF-8 바이트 그대로(Starlette 가 디코드). + static func multipartBody(for upload: DocumentUpload, boundary: String) -> Data { + var body = Data() + func appendField(_ name: String, _ value: String) { + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + body.append(Data("\(value)\r\n".utf8)) + } + if let p = upload.docPurpose { appendField("doc_purpose", p) } + if let lp = upload.libraryPath { appendField("library_path", lp) } + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(upload.filename)\"\r\n".utf8)) + body.append(Data("Content-Type: \(upload.mimeType ?? "application/octet-stream")\r\n\r\n".utf8)) + body.append(upload.data) + body.append(Data("\r\n".utf8)) + body.append(Data("--\(boundary)--\r\n".utf8)) + return body + } + public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) } public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) } diff --git a/clients/ds-app/Sources/DSKit/Models/Requests.swift b/clients/ds-app/Sources/DSKit/Models/Requests.swift index 9798499..52d3f71 100644 --- a/clients/ds-app/Sources/DSKit/Models/Requests.swift +++ b/clients/ds-app/Sources/DSKit/Models/Requests.swift @@ -24,6 +24,25 @@ public struct MemoListQuery: Sendable { public init() {} } +/// 멀티파트 업로드 페이로드 (POST /documents/). `file` 파트 + 선택 form 필드. +/// `data` 는 메모리 적재(개인 문서 규모 가정) — 대용량 디스크 스트리밍은 후속. +public struct DocumentUpload: Sendable { + public var filename: String + public var data: Data + public var mimeType: String? + /// "business" | "knowledge" | nil. business 는 서버가 @library 로 자동 태깅. + public var docPurpose: String? + public var libraryPath: String? + public init(filename: String, data: Data, mimeType: String? = nil, + docPurpose: String? = nil, libraryPath: String? = nil) { + self.filename = filename + self.data = data + self.mimeType = mimeType + self.docPurpose = docPurpose + self.libraryPath = libraryPath + } +} + public struct DocumentUpdate: Codable, Sendable { public var title: String? public var userNote: String? diff --git a/clients/ds-app/Tests/AppFeatureTests/AppModelActionsTests.swift b/clients/ds-app/Tests/AppFeatureTests/AppModelActionsTests.swift new file mode 100644 index 0000000..d87b13a --- /dev/null +++ b/clients/ds-app/Tests/AppFeatureTests/AppModelActionsTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import AppFeature +import DSKit + +/// 로그아웃 상태 초기화 + 업로드 결과 반영 — 네트워크 0 (Fixture). +final class AppModelActionsTests: XCTestCase { + + // ready 세션에서 로그아웃 → loggedOut + 토큰/사용자/로드상태 전부 초기화 + @MainActor + func testLogoutResetsStateAndLogsOut() async { + let model = AppModel.preview + await model.bootstrap() + XCTAssertEqual(model.authPhase, .ready) + XCTAssertFalse(model.documentList.isEmpty) + XCTAssertNotNil(model.currentUser, "loadInitial 이 me() 로 사용자 채움") + + await model.logout() + + XCTAssertEqual(model.authPhase, .loggedOut) + XCTAssertTrue(model.accessToken.isEmpty) + XCTAssertNil(model.currentUser) + XCTAssertTrue(model.documentList.isEmpty) + XCTAssertNil(model.documentDetail) + XCTAssertTrue(model.tree.isEmpty) + XCTAssertEqual(model.uploadState, .idle) + } + + // 업로드 성공 → uploadState=.done + 목록 재로드 + @MainActor + func testUploadSuccessSetsDoneAndReloads() async { + let model = AppModel.preview + await model.bootstrap() + await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8), mimeType: "application/pdf")) + + if case .done = model.uploadState {} else { + XCTFail("기대 .done, 실제 \(model.uploadState)") + } + XCTAssertFalse(model.documentList.isEmpty) + } + + // 업로드 진행 상태 전이 표현 (Equatable 동작 확인 — 상태바 분기 근거) + @MainActor + func testDismissUploadStatusReturnsToIdle() async { + let model = AppModel.preview + await model.bootstrap() + await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8))) + model.dismissUploadStatus() + XCTAssertEqual(model.uploadState, .idle) + } +} diff --git a/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift b/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift index 62235e1..36be4e1 100644 --- a/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift +++ b/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift @@ -168,6 +168,7 @@ final class AuthStubClient: DSClient, @unchecked Sendable { func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) } func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) } func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) } + func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { try await inner.uploadDocument(upload) } func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) } func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) } func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) } diff --git a/clients/ds-app/Tests/DSKitTests/UploadTests.swift b/clients/ds-app/Tests/DSKitTests/UploadTests.swift new file mode 100644 index 0000000..7690916 --- /dev/null +++ b/clients/ds-app/Tests/DSKitTests/UploadTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import DSKit + +/// 멀티파트 업로드 — Fixture 에코 + multipart 본문 형태(경계/디스포지션/한글 파일명/필드/파일 데이터). +final class UploadTests: XCTestCase { + + func testFixtureUploadReturnsDocument() async throws { + let doc = try await FixtureDSClient().uploadDocument( + DocumentUpload(filename: "a.pdf", data: Data("x".utf8), mimeType: "application/pdf")) + XCTAssertGreaterThan(doc.id, 0) + } + + func testMultipartBodyShape() throws { + let upload = DocumentUpload( + filename: "보고서.pdf", + data: Data("PDFDATA".utf8), + mimeType: "application/pdf", + docPurpose: "knowledge" + ) + let boundary = "TESTBOUNDARY" + let body = LiveDSClient.multipartBody(for: upload, boundary: boundary) + let s = try XCTUnwrap(String(data: body, encoding: .utf8)) + + XCTAssertTrue(s.contains("--TESTBOUNDARY\r\n"), "경계 마커") + XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="file"; filename="보고서.pdf""#), + "file 파트 + 한글 파일명") + XCTAssertTrue(s.contains("Content-Type: application/pdf"), "파일 mime") + XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="doc_purpose""#), "선택 form 필드") + XCTAssertTrue(s.contains("knowledge")) + XCTAssertTrue(s.contains("PDFDATA"), "파일 데이터") + XCTAssertTrue(s.hasSuffix("--TESTBOUNDARY--\r\n"), "종료 경계") + } + + func testMultipartOmitsAbsentOptionalFields() throws { + let upload = DocumentUpload(filename: "x.txt", data: Data("a".utf8)) + let body = LiveDSClient.multipartBody(for: upload, boundary: "B") + let s = try XCTUnwrap(String(data: body, encoding: .utf8)) + XCTAssertFalse(s.contains("doc_purpose"), "미지정 doc_purpose 는 본문에 없어야 함") + XCTAssertFalse(s.contains("library_path"), "미지정 library_path 는 본문에 없어야 함") + XCTAssertTrue(s.contains("Content-Type: application/octet-stream"), "mime 미지정 = octet-stream 폴백") + } +} diff --git a/clients/ds-app/contract/CONTRACT.md b/clients/ds-app/contract/CONTRACT.md index d1d87ab..168c3cf 100644 --- a/clients/ds-app/contract/CONTRACT.md +++ b/clients/ds-app/contract/CONTRACT.md @@ -54,7 +54,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l | GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` | | GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` | | GET | `/documents/stats/category-counts` | — | `{counts: {category: n}, library_pending_suggestions}` — **raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정**(초기 추출이 shape 합성 오류) | `documents_stats.json` | -| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` | +| POST | `/documents/` (multipart/form-data) | `file`(필수) + `doc_purpose?`(business\|knowledge) `library_path?` `facet_*?` | `DocumentResponse` (201) | `document_detail.json` | | PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — | | PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — | | POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — |