560efb9554
- AppFeature: SageTheme tokens, AppModel (@MainActor @Observable store), RootView (DEVONthink NavigationSplitView), Dashboard/Documents(MD-first+pending fallback+?token= download)/Search/Ask/Memos/Digest pages
- AI seam: AIService actor + AIResult, AppAIComposition (MockAIProvider x4 tiers), AICompletionView (numbered citations + always-visible routing badge), backend picker with visible explicit-unavailable error
- MarkdownView: block-aware renderer (GFM table separator-row skip, AttributedString inline-only)
- DSApp: thin @main, injects FixtureDSClient + mock AIRouter (zero backend / zero LLM)
swift build (full app) + swift test (19) green under Swift 6 strict concurrency. Sources/AI untouched (isolation vs freeze 17f8830 = clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.6 KiB
Swift
92 lines
3.6 KiB
Swift
import SwiftUI
|
|
import DSKit
|
|
|
|
struct DocumentListView: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
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))
|
|
}
|
|
Spacer()
|
|
Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD 변환 대기'
|
|
/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.)
|
|
struct DocumentDetailView: View {
|
|
@Environment(AppModel.self) private var model
|
|
let detail: DocumentDetailResponse
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(detail.base.title ?? detail.base.downloadLabel)
|
|
.font(.title2.weight(.bold)).foregroundStyle(Sage.ink)
|
|
|
|
HStack(spacing: 8) {
|
|
if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) }
|
|
Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat))
|
|
if let conf = detail.base.aiConfidence {
|
|
Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted)
|
|
}
|
|
Spacer()
|
|
if let url = model.downloadURL(for: detail.base) {
|
|
Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold))
|
|
}
|
|
}
|
|
|
|
if let tags = detail.base.aiTags, !tags.isEmpty {
|
|
HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } }
|
|
}
|
|
|
|
Divider()
|
|
|
|
if detail.mdIsRenderable, let md = detail.mdContent {
|
|
MarkdownView(md)
|
|
} else {
|
|
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
|
|
Text(detail.extractedText ?? "본문 없음")
|
|
.font(.body).foregroundStyle(Sage.muted)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if let url = model.downloadURL(for: detail.base) {
|
|
Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url)
|
|
.font(.callout.weight(.semibold))
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.background(Sage.surface)
|
|
}
|
|
}
|