Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ jobs:
uv run --no-sync ruff format --config pyproject.toml --check classfox tests main.py ../scripts/*.py
uv run --no-sync ty check
uv run --no-sync pytest -q
- run: uv run --project api-service --no-sync ty check --project . scripts
- run: uv run --project api-service --no-sync ty check --project . --extra-search-path api-service scripts
- run: uv run --project api-service --no-sync python -m unittest discover -s scripts -p 'test_*.py'
- run: pnpm --dir app-ui install --frozen-lockfile
- run: pnpm --dir app-ui build
Expand All @@ -122,7 +122,7 @@ jobs:
uv run --no-sync pytest -q tests/test_launcher.py
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ClassFox-2.0.1-${{ matrix.platform }}-unsigned
name: ClassFox-2.1.0-${{ matrix.platform }}-unsigned
path: |
app-ui/src-tauri/target/release/bundle/dmg/*.dmg
app-ui/src-tauri/target/release/bundle/nsis/*.exe
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ app-ui/playwright-report/
# Generated documentation and local documentation cache
site/
.cache/

# Verified model runtime resources generated by scripts/model_assets.py
api-service/model-assets/
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 更新记录

## 2.1.0 — 2026-10-16

- 问答模型优先从 ModelScope 固定版本下载,失败时切换原始源;模型大小和 SHA-256 保持一致,兼容已有安装。
- 模型下载支持自动重试和断点续传,错误提示包含失败文件、域名及超时或 HTTP 状态。
- 新增桌面离线模型 ZIP 包导入,通过文件校验和实际加载检查后启用。
- 三平台安装包附带 Silero VAD 与平台对应的 llama.cpp 运行包,减少安装后访问 GitHub 的步骤。
- 提供可重复生成的离线模型包及中文导入指南,百度网盘入口待实际上传和分享验证后加入官网。

## 2.0.1 — 2026-09-23

- 将应用标题栏、启动页与 macOS/Windows 安装图标中的默认 Tauri 标识替换为 ClassFox 狐狸标识。
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@

| macOS · Apple Silicon | macOS · Intel | Windows · x64 |
| :---: | :---: | :---: |
| [**下载 DMG ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.0.1/ClassFox_2.0.1_aarch64.dmg) | [**下载 DMG ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.0.1/ClassFox_2.0.1_x64.dmg) | [**下载安装程序 ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.0.1/ClassFox_2.0.1_x64-setup.exe) |
| [**下载 DMG ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.1.0/ClassFox_2.1.0_aarch64.dmg) | [**下载 DMG ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.1.0/ClassFox_2.1.0_x64.dmg) | [**下载安装程序 ↗**](https://github.com/ouyangyipeng/ClassAssistant/releases/download/v2.1.0/ClassFox_2.1.0_x64-setup.exe) |

2.1 支持魔搭问答模型下载与离线模型 ZIP 包导入;VAD 和平台运行库已随安装包附带。百度网盘入口待文件上传并验证分享后加入[官网下载区](https://class.nexa-lang.com/#download)。详见[国内下载与离线模型指南](https://class.nexa-lang.com/user-guide/offline-models/)。
| M 系列芯片 | Intel 芯片 | 64 位系统 |

[发布说明与 SHA-256 校验和](https://github.com/ouyangyipeng/ClassAssistant/releases/latest) · [首次使用指南](https://class.nexa-lang.com/user-guide/quickstart/)
Expand Down
1 change: 1 addition & 0 deletions api-service/classfox.spec
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ identity = os.environ.get("APPLE_SIGNING_IDENTITY")
if identity == "-":
identity = None
datas = copy_metadata("keyring") + collect_data_files("sherpa_onnx", includes=["*.txt", "*.json"])
datas += [(str(root / "model-assets"), "model-assets")]
binaries = collect_dynamic_libs("sherpa_onnx")
hiddenimports = collect_submodules("uvicorn") + [
"speech_recognition.recognizers.google",
Expand Down
2 changes: 1 addition & 1 deletion api-service/classfox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ClassFox application services."""

__version__ = "2.0.1"
__version__ = "2.1.0"
13 changes: 13 additions & 0 deletions api-service/classfox/download_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Alternate locations for identical, pinned model bytes."""

from classfox.downloads import Asset

QWEN_SHA256 = "00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4"
MODELSCOPE_QWEN = "https://modelscope.cn/models/unsloth/Qwen3.5-4B-GGUF/resolve/167b4afc359863325cb4164418c715421b4e9118/Qwen3.5-4B-Q4_K_M.gguf"


def download_sources(asset: Asset) -> tuple[str, ...]:
# Locations are deliberately separate from the installed-model fingerprint.
if asset.sha256 == QWEN_SHA256:
return MODELSCOPE_QWEN, asset.url
return (asset.url,)
57 changes: 47 additions & 10 deletions api-service/classfox/downloads.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import hashlib
import logging
import os
import re
import shutil
Expand All @@ -13,6 +14,8 @@
import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator

logger = logging.getLogger(__name__)


class DownloadError(Exception):
pass
Expand Down Expand Up @@ -41,7 +44,9 @@ def digest_matches(path: Path, asset: Asset) -> bool:
return hashlib.file_digest(source, "sha256").hexdigest() == asset.sha256


async def download_asset(client: httpx.AsyncClient, asset: Asset, destination: Path, progress: Callable[[int], None]) -> None:
async def download_asset(
client: httpx.AsyncClient, asset: Asset, destination: Path, progress: Callable[[int], None], *, sources: tuple[str, ...] | None = None
) -> None:
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if await asyncio.to_thread(digest_matches, destination, asset):
progress(asset.size)
Expand All @@ -57,21 +62,51 @@ async def download_asset(client: httpx.AsyncClient, asset: Asset, destination: P
offset = 0
if shutil.disk_usage(destination.parent).free < asset.size - offset + 16 * 1024**2:
raise DownloadError("磁盘空间不足,请释放空间后重试")
try:
await _receive(client, asset, partial, offset, progress)
except httpx.HTTPError as exc:
raise DownloadError("模型下载中断,请检查网络后重试;已下载部分会保留") from exc
if not await asyncio.to_thread(digest_matches, partial, asset):
partial.unlink(missing_ok=True)
raise DownloadError("模型完整性校验失败,未启用该文件;请重试下载")
os.replace(partial, destination)
reason = "下载源不可用"
for url in sources or (asset.url,):
candidate = asset.model_copy(update={"url": Asset.https_only(url)})
host = urlsplit(url).hostname
for attempt in range(2):
try:
offset = partial.stat().st_size if partial.exists() else 0
if offset < asset.size:
await _receive(client, candidate, partial, offset, progress)
if not await asyncio.to_thread(digest_matches, partial, asset):
partial.unlink(missing_ok=True)
raise DownloadError("文件完整性校验失败")
os.replace(partial, destination)
progress(asset.size)
return
except httpx.HTTPError as error:
reason = f"{asset.filename}({host}):{network_reason(error)}"
logger.warning("Download failed: file=%s host=%s category=%s", asset.filename, host, type(error).__name__)
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code not in {408, 429, 500, 502, 503, 504}:
break
if attempt == 0:
await asyncio.sleep(1)
except DownloadError as error:
reason = f"{asset.filename}({host}):{error}"
if attempt == 0 and offset > 0 and not partial.exists():
continue
break
raise DownloadError(f"{reason};可重试续传,或导入离线模型包")


def network_reason(error: httpx.HTTPError) -> str:
if isinstance(error, httpx.HTTPStatusError):
return f"服务器返回 HTTP {error.response.status_code}"
if isinstance(error, httpx.TimeoutException):
return "连接或读取超时"
if isinstance(error, httpx.ConnectError):
return "无法建立安全连接,请检查网络或代理"
return "网络连接中断"


async def _receive(client: httpx.AsyncClient, asset: Asset, partial: Path, offset: int, progress: Callable[[int], None]) -> None:
headers = {"Accept-Encoding": "identity"}
if offset:
headers["Range"] = f"bytes={offset}-"
async with client.stream("GET", asset.url, headers=headers, follow_redirects=True, timeout=httpx.Timeout(30, connect=10)) as response:
async with client.stream("GET", asset.url, headers=headers, follow_redirects=True, timeout=httpx.Timeout(60, connect=15)) as response:
response.raise_for_status()
if response.url.scheme != "https":
raise DownloadError("下载被重定向到不安全地址,已停止")
Expand All @@ -93,6 +128,8 @@ async def _receive(client: httpx.AsyncClient, asset: Asset, partial: Path, offse
progress(offset)
output.flush()
os.fsync(output.fileno())
if offset < asset.size:
raise httpx.ReadError("Incomplete model transfer", request=response.request)


def safe_member(name: str) -> PurePosixPath:
Expand Down
36 changes: 30 additions & 6 deletions api-service/classfox/model_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
import httpx
from pydantic import BaseModel

from classfox.download_sources import download_sources
from classfox.downloads import Asset, DownloadError, digest_matches, download_asset, unpack_archive
from classfox.events import Event, EventBus
from classfox.model_catalog import CATALOG, ModelDefinition, runtime_asset
from classfox.model_packages import bundled_assets, import_asset, validate_package

logger = logging.getLogger(__name__)
ModelProbe = Callable[[ModelDefinition, Path], Awaitable[None]]
Expand Down Expand Up @@ -116,13 +118,19 @@ async def verified_path(self, model_id: str) -> Path | None:
return None

def start(self, model_id: str) -> Installation:
return self._start(model_id)

def start_import(self, model_id: str, package: Path) -> Installation:
return self._start(model_id, package)

def _start(self, model_id: str, package: Path | None = None) -> Installation:
definition = self._definition(model_id)
if model_id in self._tasks:
return self.status(model_id)
assets = self._assets(definition)
state = Installation(model_id=model_id, status="verifying", total_bytes=sum(asset.size for asset in assets), message="正在检查安装状态")
self._states[model_id] = state
task = asyncio.create_task(self._install(definition, state), name=f"install-{model_id}")
task = asyncio.create_task(self._install(definition, state, package), name=f"install-{model_id}")
self._tasks[model_id] = task
task.add_done_callback(lambda _task: self._tasks.pop(model_id, None))
self._publish(state)
Expand Down Expand Up @@ -151,7 +159,7 @@ async def close(self) -> None:
def _publish(self, state: Installation) -> None:
self.events.publish("desktop", Event(type="model.installation", data=state.model_dump(mode="json")))

async def _install(self, definition: ModelDefinition, state: Installation) -> None:
async def _install(self, definition: ModelDefinition, state: Installation, package: Path | None = None) -> None:
staging: Path | None = None
try:
if (existing := self.installed_path(definition.id)) and await disk_operation(self._verify, definition, existing):
Expand All @@ -161,7 +169,9 @@ async def _install(self, definition: ModelDefinition, state: Installation) -> No
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
if shutil.disk_usage(self.root).free < state.total_bytes * 2 + 256 * 1024**2:
raise DownloadError("磁盘空间不足,需为下载和安装预留约两倍模型大小及运行库空间")
files = await self._download(definition, state)
if package is not None:
await disk_operation(validate_package, package, list(definition.files))
files = await self._download(definition, state, package)
staging = self.root / "installed" / uuid4().hex
staging.mkdir(parents=True, mode=0o700)
for artifact, source in files:
Expand Down Expand Up @@ -191,11 +201,13 @@ async def _install(self, definition: ModelDefinition, state: Installation) -> No
state.message += ";临时文件清理失败,请检查目录权限"
self._publish(state)

async def _download(self, definition: ModelDefinition, state: Installation) -> list[tuple[Asset, Path]]:
state.status, state.message = "downloading", "正在下载模型与运行库"
async def _download(self, definition: ModelDefinition, state: Installation, package: Path | None = None) -> list[tuple[Asset, Path]]:
state.status = "downloading"
state.message = "正在导入离线模型并校验" if package else "正在下载模型;网络中断将自动重试"
files: list[tuple[Asset, Path]] = []
completed = 0
last_update = 0.0
loop = asyncio.get_running_loop()

def progress(count: int) -> None:
nonlocal last_update
Expand All @@ -205,9 +217,21 @@ def progress(count: int) -> None:
self._publish(state)
last_update = now

def import_progress(count: int) -> None:
loop.call_soon_threadsafe(progress, count)

for asset in self._assets(definition):
path = self.root / "downloads" / asset.sha256 / asset.filename
await download_asset(self._client, asset, path, progress)
bundled = bundled_assets() / asset.filename
if await disk_operation(digest_matches, bundled, asset):
path = bundled
elif package is not None:
if asset not in definition.files:
raise DownloadError("应用附带的问答运行库缺失或损坏,请重新安装当前平台的应用")
await disk_operation(import_asset, package, asset, path, import_progress)
else:
await download_asset(self._client, asset, path, progress, sources=download_sources(asset))
progress(asset.size)
files.append((asset, path))
completed += asset.size
return files
Expand Down
71 changes: 71 additions & 0 deletions api-service/classfox/model_packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import os
import shutil
import stat
import sys
import zipfile
from collections.abc import Callable
from pathlib import Path

from classfox.downloads import Asset, DownloadError, digest_matches


def bundled_assets() -> Path:
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1]))
return base / "model-assets"


def validate_package(package: Path, assets: list[Asset]) -> None:
if not package.is_absolute() or package.is_symlink() or not package.is_file() or package.suffix.lower() != ".zip":
raise DownloadError("请选择本机的 ClassFox 离线模型 ZIP 包")
if package.stat().st_size > 10 * 1024**3:
raise DownloadError("离线模型包超过大小限制")
try:
with zipfile.ZipFile(package) as archive:
entries = archive.infolist()
names = [entry.filename for entry in entries]
if len(entries) > 32 or len(set(names)) != len(names):
raise DownloadError("离线模型包包含重复或过多文件")
if sum(entry.file_size for entry in entries) > sum(asset.size for asset in assets) + 4 * 1024**2:
raise DownloadError("离线模型包展开体积超出限制")
for entry in entries:
if (
not entry.filename
or entry.filename in {".", ".."}
or any(character in entry.filename for character in "/\\:")
or stat.S_ISLNK(entry.external_attr >> 16)
or entry.flag_bits & 1
):
raise DownloadError("离线模型包包含无效路径、链接或加密文件")
for asset in assets:
if asset.filename not in names or archive.getinfo(asset.filename).file_size != asset.size:
raise DownloadError(f"离线模型包不匹配,缺少或大小不符:{asset.filename}")
except (OSError, zipfile.BadZipFile) as error:
raise DownloadError("无法读取离线模型包,请重新下载完整 ZIP 文件") from error


def import_asset(package: Path, asset: Asset, destination: Path, progress: Callable[[int], None] | None = None) -> None:
if digest_matches(destination, asset):
return
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if shutil.disk_usage(destination.parent).free < asset.size + 16 * 1024**2:
raise DownloadError("磁盘空间不足,无法导入模型")
partial = destination.with_suffix(destination.suffix + ".importing")
try:
with zipfile.ZipFile(package) as archive, archive.open(asset.filename) as source, partial.open("wb") as output:
remaining = asset.size
while chunk := source.read(min(1024**2, remaining + 1)):
remaining -= len(chunk)
if remaining < 0:
raise DownloadError("离线模型文件超过预期大小")
output.write(chunk)
if progress is not None:
progress(asset.size - remaining)
output.flush()
os.fsync(output.fileno())
if not digest_matches(partial, asset):
raise DownloadError(f"离线模型文件校验失败:{asset.filename},请重新下载")
os.replace(partial, destination)
except (OSError, KeyError, zipfile.BadZipFile, RuntimeError, EOFError) as error:
raise DownloadError("离线模型包读取失败,请重新下载") from error
finally:
partial.unlink(missing_ok=True)
12 changes: 11 additions & 1 deletion api-service/classfox/model_routes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from collections.abc import Awaitable, Callable
from pathlib import Path

from fastapi import APIRouter, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field

from classfox.model_catalog import ModelDefinition
from classfox.model_install import Installation, ModelInstaller
Expand All @@ -13,6 +14,10 @@ class ModelInfo(BaseModel):
installation: Installation


class ModelImport(BaseModel):
path: str = Field(min_length=1, max_length=4096)


def model_routes() -> APIRouter:
router = APIRouter(prefix="/api/v2/models")

Expand All @@ -26,6 +31,11 @@ async def install(request: Request, model_id: str) -> Installation:
installer: ModelInstaller = request.app.state.models
return installer.start(model_id)

@router.post("/{model_id}/import", status_code=202)
async def import_package(request: Request, model_id: str, source: ModelImport) -> Installation:
installer: ModelInstaller = request.app.state.models
return installer.start_import(model_id, Path(source.path))

@router.get("/runtime")
async def runtime(request: Request) -> dict[str, object]:
process: OwnedModelProcess = request.app.state.owned_model
Expand Down
8 changes: 8 additions & 0 deletions api-service/classfox/selfcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,13 @@ def self_check() -> int:
importlib.import_module(name)
except Exception as error:
failures[name] = type(error).__name__
if getattr(sys, "frozen", False):
from classfox.downloads import digest_matches
from classfox.model_catalog import CATALOG, runtime_asset
from classfox.model_packages import bundled_assets

for asset in [CATALOG["sensevoice-small"].files[-1], runtime_asset()]:
if not digest_matches(bundled_assets() / asset.filename, asset):
failures[asset.filename] = "BundledAssetIntegrityError"
print(json.dumps({"status": "failed" if failures else "ready", "platform": sys.platform, "architecture": platform.machine(), "failures": failures}))
return 1 if failures else 0
2 changes: 1 addition & 1 deletion api-service/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "classfox-service"
version = "2.0.1"
version = "2.1.0"
description = "Local-first classroom transcription and assistance"
requires-python = ">=3.12,<3.14"
dependencies = [
Expand Down
Loading
Loading