add explain module

This commit is contained in:
2026-09-06 07:38:56 +08:00
parent 7d843e8ff1
commit 701148e00e
20 changed files with 775 additions and 8 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)"
}
}
}
}