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>
88 lines
3.4 KiB
Swift
88 lines
3.4 KiB
Swift
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 "낮음" }
|
|
}
|
|
}
|