52aa99ec8e
- Resolve Package.swift add/add: one manifest, single AIFabric target (Sources/AI compiled once; no duplicate-symbol risk) + DSKit/AppFeature/DSApp + AITests + DSKitTests, AIFabric library product kept. - import AI -> import AIFabric across AppFeature + RouterFallbackTests (S2 renamed module). - AppModel.askMeta qualified DSKit.AskResponse (AIFabric also defines an AskResponse for RemoteDS). swift build + swift test green (71 tests: S2 AITests + S3 DSKitTests). Frozen AIProvider interface intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
3.4 KiB
Swift
86 lines
3.4 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|