add explain module
This commit is contained in:
@@ -37,3 +37,46 @@ enum SentenceBoundaryAPI {
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
struct SentenceExplanationResponse: Decodable {
|
||||
let explanation: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case explanation
|
||||
}
|
||||
}
|
||||
|
||||
extension SentenceBoundaryAPI {
|
||||
static func explain(
|
||||
_ sentence: SentenceBoundary,
|
||||
courseTitle: String
|
||||
) async throws -> String {
|
||||
var body: [String: Any] = [
|
||||
"sentence_text": sentence.text ?? "",
|
||||
"course_title": courseTitle,
|
||||
"sentence_index": sentence.index,
|
||||
]
|
||||
if let language = sentence.language {
|
||||
body["language"] = language
|
||||
}
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/sentences/explain",
|
||||
method: "POST",
|
||||
timeout: AppConfig.readTimeout,
|
||||
headers: headers,
|
||||
body: try JSONSerialization.data(withJSONObject: body)
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let detail = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, detail)
|
||||
}
|
||||
return try JSONDecoder()
|
||||
.decode(SentenceExplanationResponse.self, from: result.body)
|
||||
.explanation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ struct FullscreenPlayerView: View {
|
||||
let onExit: () -> Void
|
||||
|
||||
@State private var subtitleVisible = true
|
||||
@State private var explanationSentence: SentenceBoundary?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -38,10 +39,34 @@ struct FullscreenPlayerView: View {
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
Text(sentenceTextText)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence,
|
||||
!(sentence.text ?? "").isEmpty else {
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
explanationSentence = sentence
|
||||
} label: {
|
||||
Image(systemName: "lightbulb.circle.fill")
|
||||
.font(.system(size: 21, weight: .semibold))
|
||||
.foregroundColor(
|
||||
controller.currentSentence?.text?.isEmpty == false
|
||||
? Theme.accentLight
|
||||
: Theme.textSubtle
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
.accessibilityLabel("AI 讲解这句话")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
@@ -70,6 +95,12 @@ struct FullscreenPlayerView: View {
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.sheet(item: $explanationSentence) { sentence in
|
||||
SentenceExplanationSheet(
|
||||
courseTitle: controller.currentItem?.title ?? "口语训练课程",
|
||||
sentence: sentence
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@State private var progressDragging = false
|
||||
|
||||
@@ -10,6 +10,7 @@ struct PlayerSectionView: View {
|
||||
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
@State private var explanationSentence: SentenceBoundary?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -19,6 +20,12 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.sheet(item: $explanationSentence) { sentence in
|
||||
SentenceExplanationSheet(
|
||||
courseTitle: controller.currentItem?.title ?? "口语训练课程",
|
||||
sentence: sentence
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var playerSurface: some View {
|
||||
@@ -85,10 +92,33 @@ struct PlayerSectionView: View {
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Text(sentenceTextText)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button {
|
||||
guard let sentence = controller.currentSentence, !(sentence.text ?? "").isEmpty else {
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
explanationSentence = sentence
|
||||
} label: {
|
||||
Image(systemName: "lightbulb.circle.fill")
|
||||
.font(.system(size: 23, weight: .semibold))
|
||||
.foregroundColor(
|
||||
controller.currentSentence?.text?.isEmpty == false
|
||||
? Theme.accentLight
|
||||
: Theme.textSubtle
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
.accessibilityLabel("AI 讲解这句话")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
@@ -195,3 +225,53 @@ struct PlayerSectionView: View {
|
||||
controller.setPlaybackSpeed(next)
|
||||
}
|
||||
}
|
||||
|
||||
struct SentenceExplanationSheet: View {
|
||||
let courseTitle: String
|
||||
let sentence: SentenceBoundary
|
||||
|
||||
@State private var explanation = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(sentence.text ?? "")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.danger)
|
||||
} else if explanation.isEmpty {
|
||||
Text("AI 正在讲解这句话…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
} else {
|
||||
Text(explanation)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Theme.textDark)
|
||||
.lineSpacing(5)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.white.ignoresSafeArea())
|
||||
.navigationTitle("句子讲解")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
guard explanation.isEmpty, errorMessage == nil else { return }
|
||||
do {
|
||||
explanation = try await SentenceBoundaryAPI.explain(
|
||||
sentence,
|
||||
courseTitle: courseTitle
|
||||
)
|
||||
} catch {
|
||||
errorMessage = "讲解失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user