fixed a bug

This commit is contained in:
2026-09-06 08:32:04 +08:00
parent 0baff0b447
commit aca36c78c1
8 changed files with 46 additions and 14 deletions

View File

@@ -128,7 +128,7 @@ internal class CourseCatalogController(
return return
} }
host.onStatus("句子边界加载失败:${error.message.orEmpty()}") host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
host.onSentencePlaceholder("暂无句子边界", video.title) host.onSentencePlaceholder("暂无句子边界", "")
} }
}) })
} }

View File

@@ -214,7 +214,7 @@ class MainActivity : Activity() {
bindings.statusText.text = bindings.statusText.text =
"句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}" "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
bindings.sentenceMetaText.text = "暂无句子边界" bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = item.title bindings.sentenceText.text = ""
} }
} }
}) })
@@ -372,7 +372,7 @@ class MainActivity : Activity() {
if (!bindings.hasPlayerChrome) return if (!bindings.hasPlayerChrome) return
if (sentence == null) { if (sentence == null) {
bindings.sentenceMetaText.text = "暂无句子边界" bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = bindings.lessonTitleText.text bindings.sentenceText.text = ""
bindings.explainSentenceButton?.isEnabled = false bindings.explainSentenceButton?.isEnabled = false
return return
} }

View File

@@ -171,10 +171,10 @@ struct FullscreenPlayerView: View {
case .loading(let isLocal): case .loading(let isLocal):
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中") return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
case .failed: case .failed:
return controller.currentItem?.title ?? "未选择课程" return ""
case .idle, .loaded: case .idle, .loaded:
guard let sentence = controller.currentSentence else { guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程" return ""
} }
if let text = sentence.text, !text.isEmpty { if let text = sentence.text, !text.isEmpty {
return text return text

View File

@@ -189,10 +189,10 @@ struct PlayerSectionView: View {
} }
return controller.currentItem?.title ?? "加载中" return controller.currentItem?.title ?? "加载中"
case .failed: case .failed:
return controller.currentItem?.title ?? "未选择课程" return ""
case .idle, .loaded: case .idle, .loaded:
guard let sentence = controller.currentSentence else { guard let sentence = controller.currentSentence else {
return controller.currentItem?.title ?? "未选择课程" return ""
} }
if let text = sentence.text, !text.isEmpty { if let text = sentence.text, !text.isEmpty {
return text return text

View File

@@ -17,7 +17,8 @@ MOSS_MAX_NEW_TOKENS=65536
# 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整 # 每句结束时间向后顺延的毫秒数,避免句子末尾发音被切掉;按需在 200-500 之间调整
MOSS_END_PADDING_MS=300 MOSS_END_PADDING_MS=300
# AI sentence explanation (OpenAI-compatible chat completions endpoint). # AI sentence explanation (OpenAI-compatible chat endpoint). A base URL ending
# in /v1 is also accepted and will automatically use /v1/chat/completions.
# The URL and key stay on the server; mobile clients only send the sentence text. # The URL and key stay on the server; mobile clients only send the sentence text.
AI_API_URL=https://api.example.com/v1/chat/completions AI_API_URL=https://api.example.com/v1/chat/completions
AI_API_KEY=replace-with-your-ai-api-key AI_API_KEY=replace-with-your-ai-api-key

View File

@@ -68,7 +68,9 @@ requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
## AI Sentence Explanation ## AI Sentence Explanation
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`: Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.
A base URL ending in `/v1` is also accepted and is resolved to
`/v1/chat/completions`:
```dotenv ```dotenv
AI_API_URL=https://api.example.com/v1/chat/completions AI_API_URL=https://api.example.com/v1/chat/completions

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from urllib.parse import urlsplit
import requests import requests
@@ -24,6 +25,19 @@ class AITutor:
def available(self) -> bool: def available(self) -> bool:
return bool(self.endpoint and self.model) return bool(self.endpoint and self.model)
@property
def request_url(self) -> str:
"""Resolve either a full chat URL or an OpenAI-compatible base URL."""
parsed = urlsplit(self.endpoint)
path = parsed.path.rstrip("/")
if parsed.query or path.endswith("/chat/completions"):
return self.endpoint
if path.endswith("/v1") or path.endswith("/v1/openai"):
return self.endpoint + "/chat/completions"
if not path:
return self.endpoint + "/v1/chat/completions"
return self.endpoint
def explain( def explain(
self, self,
sentence_text: str, sentence_text: str,
@@ -51,7 +65,7 @@ class AITutor:
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
try: try:
response = requests.post( response = requests.post(
self.endpoint, self.request_url,
headers=headers, headers=headers,
json={ json={
"model": self.model, "model": self.model,
@@ -67,7 +81,7 @@ class AITutor:
except requests.RequestException as exc: except requests.RequestException as exc:
logger.exception( logger.exception(
"AI explanation request failed: endpoint=%s model=%s", "AI explanation request failed: endpoint=%s model=%s",
self.endpoint, self.request_url,
self.model, self.model,
) )
raise AITutorError("AI service request failed.") from exc raise AITutorError("AI service request failed.") from exc
@@ -76,7 +90,7 @@ class AITutor:
logger.warning( logger.warning(
"AI explanation returned HTTP %s: endpoint=%s model=%s body=%s", "AI explanation returned HTTP %s: endpoint=%s model=%s body=%s",
response.status_code, response.status_code,
self.endpoint, self.request_url,
self.model, self.model,
response.text[:1000], response.text[:1000],
) )
@@ -87,14 +101,14 @@ class AITutor:
except (KeyError, IndexError, TypeError, ValueError) as exc: except (KeyError, IndexError, TypeError, ValueError) as exc:
logger.warning( logger.warning(
"AI explanation returned an unexpected body: endpoint=%s body=%s", "AI explanation returned an unexpected body: endpoint=%s body=%s",
self.endpoint, self.request_url,
response.text[:1000], response.text[:1000],
) )
raise AITutorError("AI service returned an unexpected response.") from exc raise AITutorError("AI service returned an unexpected response.") from exc
if not isinstance(explanation, str) or not explanation.strip(): if not isinstance(explanation, str) or not explanation.strip():
logger.warning( logger.warning(
"AI explanation returned empty content: endpoint=%s body=%s", "AI explanation returned empty content: endpoint=%s body=%s",
self.endpoint, self.request_url,
response.text[:1000], response.text[:1000],
) )
raise AITutorError("AI service returned an empty explanation.") raise AITutorError("AI service returned an empty explanation.")

View File

@@ -47,6 +47,21 @@ def test_ai_tutor_parses_chat_completion(monkeypatch):
assert timeout == 1.5 assert timeout == 1.5
def test_ai_tutor_resolves_openai_compatible_base_url(monkeypatch):
endpoints = []
def fake_post(endpoint, **kwargs):
endpoints.append(endpoint)
return FakeResponse(body={"choices": [{"message": {"content": "ok"}}]})
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test:8080/v1", "secret", "test-model", 1)
assert tutor.explain("Hello.") == "ok"
assert endpoints == ["https://ai.test:8080/v1/chat/completions"]
assert tutor.request_url == "https://ai.test:8080/v1/chat/completions"
def test_ai_tutor_rejects_unexpected_response(monkeypatch): def test_ai_tutor_rejects_unexpected_response(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"sentence_api.ai_tutor.requests.post", "sentence_api.ai_tutor.requests.post",