feat(s3): SwiftUI sage 3-pane shell + 6 pages + AI seam

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