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:
@@ -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 "낮음" }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)" }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user