feat(ds-app): macOS 앱 마무리 — 업로드·다운로드·로그아웃 + 4섹션 페이지

- 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) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-15 14:52:29 +09:00
parent b2949d26ff
commit f527c63232
17 changed files with 1352 additions and 317 deletions
@@ -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)")
}
}
}
@@ -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
}
}
}
@@ -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
@@ -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 }
/// "PDFMD" / "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<DocumentResponse>]
private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) }
var body: some View {
let selection = Binding<Int?>(
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 · · PDFMD 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<Content: View>: 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)
}
}
@@ -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)
@@ -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)
}
}
@@ -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
@@ -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<AppModel.Section?>(
@@ -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<Content: View>(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
@@ -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()
@@ -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
@@ -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 {
@@ -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) }
@@ -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?
@@ -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)
}
}
@@ -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) }
@@ -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 폴백")
}
}
+1 -1
View File
@@ -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` | — |