Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.

Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-08 15:13:59 +08:00
parent db5465c646
commit 05e173b293
115 changed files with 12709 additions and 1212 deletions
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import base64
import hashlib
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from config import get_settings
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "tts_cache"
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
_TTS_USER_AGENT = "WordLoop-TTS/1.0"
def _open_tts_request(
request: urllib.request.Request,
*,
timeout: int,
):
# 统一绕过系统代理;公网网关会拒绝 Python-urllib 默认 UA403
return _NO_PROXY_OPENER.open(request, timeout=timeout)
class TtsServiceError(Exception):
pass
class TtsService:
def is_configured(self) -> bool:
settings = get_settings()
return settings.tkmind_tts_enabled and bool(settings.tkmind_tts_base_url.strip())
def synthesize(self, text: str) -> tuple[bytes, str]:
cleaned = text.strip()
if not cleaned:
raise TtsServiceError("朗读文本不能为空")
if len(cleaned) > 200:
raise TtsServiceError("朗读文本过长")
settings = get_settings()
if not settings.tkmind_tts_enabled:
raise TtsServiceError("单词朗读未启用")
base_url = settings.tkmind_tts_base_url.strip().rstrip("/")
if not base_url:
raise TtsServiceError("TTS 服务地址未配置")
cache_key = self._cache_key(cleaned, settings.tkmind_tts_spk_id)
cached = self._read_cache(cache_key)
if cached is not None:
return cached, "audio/wav"
audio_bytes, content_type = self._request_upstream(
base_url=base_url,
text=cleaned,
spk_id=settings.tkmind_tts_spk_id.strip(),
timeout=settings.tkmind_tts_timeout,
)
self._write_cache(cache_key, audio_bytes)
return audio_bytes, content_type
@staticmethod
def _cache_key(text: str, spk_id: str) -> str:
raw = f"{text.lower()}|{spk_id}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@staticmethod
def _read_cache(cache_key: str) -> bytes | None:
path = _CACHE_DIR / f"{cache_key}.wav"
if not path.is_file():
return None
return path.read_bytes()
@staticmethod
def _write_cache(cache_key: str, audio_bytes: bytes) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = _CACHE_DIR / f"{cache_key}.wav"
path.write_bytes(audio_bytes)
def _request_upstream(
self,
*,
base_url: str,
text: str,
spk_id: str,
timeout: int,
) -> tuple[bytes, str]:
payload: dict[str, str] = {"tts_text": text}
if spk_id:
payload["spk_id"] = spk_id
request = urllib.request.Request(
f"{base_url}/inference_sft",
data=urllib.parse.urlencode(payload).encode("utf-8"),
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": _TTS_USER_AGENT,
},
method="POST",
)
try:
with _open_tts_request(request, timeout=timeout) as response:
content_type = response.headers.get_content_type() or "application/octet-stream"
body = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise TtsServiceError(f"TTS 服务异常(HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise TtsServiceError(f"TTS 服务不可达:{exc.reason}") from exc
if content_type.startswith("audio/"):
if not body:
raise TtsServiceError("TTS 返回空音频")
return body, content_type
return self._parse_json_response(body)
@staticmethod
def _parse_json_response(body: bytes) -> tuple[bytes, str]:
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
if body[:4] == b"RIFF":
return body, "audio/wav"
raise TtsServiceError("TTS 响应格式无效") from exc
code = payload.get("code", 0)
if code not in (0, "0", None):
message = str(payload.get("message") or "TTS 合成失败")
raise TtsServiceError(message)
data = payload.get("data")
audio_b64 = TtsService._extract_audio_base64(data)
if not audio_b64:
raise TtsServiceError("TTS 响应缺少音频数据")
try:
audio_bytes = base64.b64decode(audio_b64)
except (ValueError, TypeError) as exc:
raise TtsServiceError("TTS 音频解码失败") from exc
if not audio_bytes:
raise TtsServiceError("TTS 返回空音频")
return audio_bytes, "audio/wav"
@staticmethod
def _extract_audio_base64(data: object) -> str | None:
if isinstance(data, str) and data.strip():
return data.strip()
if isinstance(data, dict):
for key in ("audio", "wav", "speech", "data"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
tts_service = TtsService()