jfox-cli 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/CHANGELOG.md +10 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/PKG-INFO +1 -1
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/__init__.py +1 -1
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/cli.py +82 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/process.py +17 -6
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/server.py +10 -3
- jfox_cli-0.4.3/jfox/model_downloader.py +224 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/performance.py +1 -1
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/pyproject.toml +1 -1
- jfox_cli-0.4.3/scripts/download-model-intranet.sh +69 -0
- jfox_cli-0.4.3/tests/integration/test_model_download.py +105 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_daemon_process.py +4 -1
- jfox_cli-0.4.3/tests/unit/test_model_downloader.py +111 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/uv.lock +1 -1
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/settings.local.json +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/skills/release/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/skills/release/release_helper.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.githooks/pre-push +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.github/workflows/integration-test.yml +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.github/workflows/publish.yml +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.gitignore +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.python-version +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/AGENTS.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/CLAUDE.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/DEVELOPMENT_PLAN.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/README.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/SESSION.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/SESSION_SUMMARY.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/installation.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-bulk-import-bm25-fix.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-edit-command.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-unify-format-option.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-edit-content-file.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-jfox-health-skill-kb-param.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-index-kb-param.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-lazy-import-perf.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-skill-redesign.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-14-show-command-design.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/troubleshooting.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jessica-jones-static-cable.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/__main__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/bm25_index.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/config.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/__init__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/__main__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/client.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/embedding_backend.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/formatters.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/git_extractor.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/global_config.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/graph.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/indexer.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/kb_manager.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/models.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/note.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/search_engine.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/template.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/template_cli.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/vector_store.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/pytest.ini +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/run_full_test.ps1 +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/README.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-common/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-ingest/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-session-summary/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-common/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-ingest/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-organize/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-search/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-session-summary/SKILL.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/COVERAGE_PLAN.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/MIGRATION.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/TESTS.md +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/conftest.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/integration/__init__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/integration/test_backlinks.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/performance/__init__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/performance/test_performance.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_advanced_features.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_cli_format.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_config_set_unit.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_config_unit.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_core_workflow.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_embedding_device.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_hybrid_search.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_integration.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_kb_current.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_suggest_links.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/__init__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_bm25_batch.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_edit.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_format_unify.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_formatters.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_git_extractor.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_global_config.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_index_kb_param.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_indexer_clear_before_rebuild.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_indexer_verify.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_kb_manager.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_lazy_import.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_logging_config.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_release_helper.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_show.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_template.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_template_cli.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_vector_store_clear.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/__init__.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/assertions.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/jfox_cli.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/note_generator.py +0 -0
- {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/temp_kb.py +0 -0
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to jfox-cli will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.3] - 2026-04-28
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- 内网模型自动下载(3步降级重试链) (#173)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- **daemon**: eliminate deprecation warnings in daemon log (#171)
|
|
12
|
+
|
|
13
|
+
[0.4.3]: https://github.com/zhuxixi/jfox/compare/v0.4.2...v0.4.3
|
|
14
|
+
|
|
5
15
|
## [0.4.2] - 2026-04-22
|
|
6
16
|
|
|
7
17
|
### Features
|
|
@@ -88,6 +88,10 @@ def _main(
|
|
|
88
88
|
# 添加子命令
|
|
89
89
|
app.add_typer(template_app, name="template", help="Manage note templates")
|
|
90
90
|
|
|
91
|
+
# Model 下载子命令
|
|
92
|
+
model_app = typer.Typer(name="model", help="模型管理")
|
|
93
|
+
app.add_typer(model_app, name="model", help="模型管理")
|
|
94
|
+
|
|
91
95
|
console = Console(legacy_windows=False)
|
|
92
96
|
|
|
93
97
|
|
|
@@ -2674,6 +2678,84 @@ def daemon(
|
|
|
2674
2678
|
raise typer.Exit(1)
|
|
2675
2679
|
|
|
2676
2680
|
|
|
2681
|
+
def _download_impl(
|
|
2682
|
+
model: Optional[str],
|
|
2683
|
+
force: bool = False,
|
|
2684
|
+
) -> dict:
|
|
2685
|
+
"""下载模型实现(可复用)"""
|
|
2686
|
+
from .embedding_backend import EmbeddingBackend
|
|
2687
|
+
from .model_downloader import ModelDownloader
|
|
2688
|
+
|
|
2689
|
+
# 解析模型名
|
|
2690
|
+
if model is None or model == "auto":
|
|
2691
|
+
backend = EmbeddingBackend()
|
|
2692
|
+
device = backend._resolve_device()
|
|
2693
|
+
model = backend._resolve_model_name(device)
|
|
2694
|
+
|
|
2695
|
+
downloader = ModelDownloader(model)
|
|
2696
|
+
|
|
2697
|
+
if force and downloader._check_cached():
|
|
2698
|
+
import shutil
|
|
2699
|
+
|
|
2700
|
+
shutil.rmtree(downloader._model_cache, ignore_errors=True)
|
|
2701
|
+
|
|
2702
|
+
ok = downloader.ensure_cached()
|
|
2703
|
+
return {
|
|
2704
|
+
"model": model,
|
|
2705
|
+
"success": ok,
|
|
2706
|
+
"cache_dir": str(downloader._model_cache),
|
|
2707
|
+
"instructions": downloader.get_manual_instructions() if not ok else "",
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
@model_app.command("download")
|
|
2712
|
+
def download(
|
|
2713
|
+
model: Optional[str] = typer.Option(
|
|
2714
|
+
None, "--model", "-m", help="模型名(默认从配置读取,auto 则按设备自动选择)"
|
|
2715
|
+
),
|
|
2716
|
+
force: bool = typer.Option(False, "--force", "-f", help="强制重新下载(覆盖已有缓存)"),
|
|
2717
|
+
kb: Optional[str] = typer.Option(
|
|
2718
|
+
None, "--kb", "-k", help="目标知识库名称(模型下载不依赖知识库)"
|
|
2719
|
+
),
|
|
2720
|
+
output_format: str = typer.Option("table", "--format", help="输出格式: json, table"),
|
|
2721
|
+
json_output: bool = typer.Option(False, "--json", help="JSON 输出快捷方式"),
|
|
2722
|
+
):
|
|
2723
|
+
"""
|
|
2724
|
+
手动下载 embedding 模型
|
|
2725
|
+
|
|
2726
|
+
自动尝试 3 种下载方式(huggingface_hub → 镜像站 → curl)。
|
|
2727
|
+
通常不需要手动调用,daemon start 会自动执行。
|
|
2728
|
+
|
|
2729
|
+
示例:
|
|
2730
|
+
|
|
2731
|
+
jfox model download # 下载默认模型
|
|
2732
|
+
jfox model download --model bge-m3 # 下载指定模型
|
|
2733
|
+
jfox model download --force # 强制重新下载
|
|
2734
|
+
jfox model download --json # JSON 输出
|
|
2735
|
+
"""
|
|
2736
|
+
# kb 参数保持 CLI 一致性(模型下载不依赖知识库)
|
|
2737
|
+
_ = kb
|
|
2738
|
+
|
|
2739
|
+
if json_output:
|
|
2740
|
+
output_format = "json"
|
|
2741
|
+
|
|
2742
|
+
console.print(f"[yellow]准备下载模型: {model or 'auto'}[/yellow]")
|
|
2743
|
+
|
|
2744
|
+
result = _download_impl(model=model, force=force)
|
|
2745
|
+
|
|
2746
|
+
if output_format == "json":
|
|
2747
|
+
console.print(output_json(result))
|
|
2748
|
+
else:
|
|
2749
|
+
if result["success"]:
|
|
2750
|
+
console.print(f"[green]✓ 模型下载完成: {result['model']}[/green]")
|
|
2751
|
+
else:
|
|
2752
|
+
console.print("[red]✗ 模型下载失败[/red]")
|
|
2753
|
+
console.print(Panel(result["instructions"], title="手动下载"))
|
|
2754
|
+
|
|
2755
|
+
if not result["success"]:
|
|
2756
|
+
raise typer.Exit(1)
|
|
2757
|
+
|
|
2758
|
+
|
|
2677
2759
|
# 入口点
|
|
2678
2760
|
def main():
|
|
2679
2761
|
"""CLI 入口点"""
|
|
@@ -47,7 +47,7 @@ def _read_pid_file() -> Optional[dict]:
|
|
|
47
47
|
return None
|
|
48
48
|
try:
|
|
49
49
|
return json.loads(PID_FILE.read_text(encoding="utf-8"))
|
|
50
|
-
except
|
|
50
|
+
except (OSError, ValueError):
|
|
51
51
|
return None
|
|
52
52
|
|
|
53
53
|
|
|
@@ -84,7 +84,7 @@ def _http_health_check(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> Op
|
|
|
84
84
|
|
|
85
85
|
resp = urllib.request.urlopen(f"http://{host}:{port}/health", timeout=2)
|
|
86
86
|
return json.loads(resp.read().decode("utf-8"))
|
|
87
|
-
except
|
|
87
|
+
except (OSError, ValueError):
|
|
88
88
|
return None
|
|
89
89
|
|
|
90
90
|
|
|
@@ -126,7 +126,7 @@ def _check_model_cache() -> dict:
|
|
|
126
126
|
model_name = _GPU_DEFAULT_MODEL
|
|
127
127
|
else:
|
|
128
128
|
model_name = _CPU_DEFAULT_MODEL
|
|
129
|
-
except
|
|
129
|
+
except (ImportError, OSError):
|
|
130
130
|
model_name = _CPU_DEFAULT_MODEL
|
|
131
131
|
|
|
132
132
|
# 检查 HuggingFace 缓存
|
|
@@ -150,7 +150,7 @@ def _check_model_cache() -> dict:
|
|
|
150
150
|
"model_name": model_name,
|
|
151
151
|
"size_hint": size_hint,
|
|
152
152
|
}
|
|
153
|
-
except
|
|
153
|
+
except (ImportError, OSError, ValueError) as e:
|
|
154
154
|
logger.debug(f"Model cache check failed, assuming download needed: {e}")
|
|
155
155
|
return {"needs_download": True, "model_name": "unknown", "size_hint": ""}
|
|
156
156
|
|
|
@@ -185,6 +185,17 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
|
|
|
185
185
|
)
|
|
186
186
|
timeout = FIRST_RUN_TIMEOUT
|
|
187
187
|
|
|
188
|
+
# 自动下载模型(内网降级重试)
|
|
189
|
+
try:
|
|
190
|
+
from ..model_downloader import ModelDownloader
|
|
191
|
+
|
|
192
|
+
downloader = ModelDownloader(cache_info["model_name"])
|
|
193
|
+
if not downloader.ensure_cached():
|
|
194
|
+
logger.error("模型自动下载失败")
|
|
195
|
+
# 不阻断启动,让 daemon 自己去尝试加载(会暴露更详细的错误日志)
|
|
196
|
+
except (ImportError, OSError) as e:
|
|
197
|
+
logger.warning(f"模型下载检查异常: {e}")
|
|
198
|
+
|
|
188
199
|
# 构建启动命令(Windows 使用 pythonw.exe 避免控制台窗口)
|
|
189
200
|
cmd = [
|
|
190
201
|
_get_pythonw_executable(),
|
|
@@ -218,7 +229,7 @@ def start_daemon(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
|
|
|
218
229
|
)
|
|
219
230
|
logger.info(f"Daemon 进程已启动 (PID: {proc.pid})")
|
|
220
231
|
logger.info(f"Daemon 日志文件: {DAEMON_LOG_FILE}")
|
|
221
|
-
except
|
|
232
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
222
233
|
log_file.close()
|
|
223
234
|
logger.error(f"启动 daemon 失败: {e}")
|
|
224
235
|
return False
|
|
@@ -280,7 +291,7 @@ def stop_daemon() -> bool:
|
|
|
280
291
|
)
|
|
281
292
|
else:
|
|
282
293
|
os.kill(pid, 15) # SIGTERM
|
|
283
|
-
except
|
|
294
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
284
295
|
logger.warning(f"停止 daemon 失败: {e}")
|
|
285
296
|
|
|
286
297
|
# 等待进程退出
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import argparse
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
11
12
|
from typing import List
|
|
12
13
|
|
|
13
14
|
from fastapi import FastAPI
|
|
@@ -15,13 +16,10 @@ from pydantic import BaseModel
|
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
18
|
-
app = FastAPI(title="JFox Embedding Daemon")
|
|
19
|
-
|
|
20
19
|
# 全局 embedding 后端(模型加载后常驻内存)
|
|
21
20
|
_backend = None
|
|
22
21
|
|
|
23
22
|
|
|
24
|
-
@app.on_event("startup")
|
|
25
23
|
def _load_model():
|
|
26
24
|
"""启动时加载模型(标记为 daemon 进程,防止自引用)"""
|
|
27
25
|
global _backend
|
|
@@ -42,6 +40,15 @@ def _load_model():
|
|
|
42
40
|
os._exit(1)
|
|
43
41
|
|
|
44
42
|
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def lifespan(app):
|
|
45
|
+
_load_model()
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
app = FastAPI(title="JFox Embedding Daemon", lifespan=lifespan)
|
|
50
|
+
|
|
51
|
+
|
|
45
52
|
# =============================================================================
|
|
46
53
|
# 请求/响应模型
|
|
47
54
|
# =============================================================================
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""模型下载器 - 支持内网自动降级下载"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# 镜像站地址
|
|
15
|
+
_HF_MIRROR = "https://hf-mirror.com"
|
|
16
|
+
|
|
17
|
+
# 重试超时(秒)
|
|
18
|
+
_TIMEOUT_HF_HUB = 60
|
|
19
|
+
_TIMEOUT_CURL = 120
|
|
20
|
+
|
|
21
|
+
# 需要下载的文件列表(按重要性排序)
|
|
22
|
+
_REQUIRED_FILES = [
|
|
23
|
+
"model.safetensors",
|
|
24
|
+
"config.json",
|
|
25
|
+
"tokenizer.json",
|
|
26
|
+
"tokenizer_config.json",
|
|
27
|
+
"sentence_bert_config.json",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModelDownloader:
|
|
32
|
+
"""模型下载器,支持全自动降级重试链"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, model_name: str):
|
|
35
|
+
self.model_name = model_name
|
|
36
|
+
self._hf_hub_cache = self._get_hf_hub_cache()
|
|
37
|
+
# 同时替换正反斜杠,防止路径遍历
|
|
38
|
+
safe_name = model_name.replace("/", "--").replace("\\", "--")
|
|
39
|
+
self._model_cache = self._hf_hub_cache / f"models--{safe_name}"
|
|
40
|
+
|
|
41
|
+
def _get_hf_hub_cache(self) -> Path:
|
|
42
|
+
"""获取 HuggingFace Hub 缓存目录"""
|
|
43
|
+
try:
|
|
44
|
+
import huggingface_hub.constants
|
|
45
|
+
|
|
46
|
+
return Path(huggingface_hub.constants.HUGGINGFACE_HUB_CACHE)
|
|
47
|
+
except ImportError:
|
|
48
|
+
hf_home = os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface"))
|
|
49
|
+
return Path(hf_home) / "hub"
|
|
50
|
+
|
|
51
|
+
def ensure_cached(self) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
确保模型已缓存。按重试链逐层降级。
|
|
54
|
+
返回 True 表示成功(无论哪一步成功)。
|
|
55
|
+
"""
|
|
56
|
+
if self._check_cached():
|
|
57
|
+
logger.info(f"模型已缓存: {self.model_name}")
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
logger.info(f"缓存未命中: {self.model_name},开始下载")
|
|
61
|
+
|
|
62
|
+
# Step 1: 正常下载
|
|
63
|
+
logger.info("步骤 1: 使用 huggingface_hub 正常下载...")
|
|
64
|
+
if self._try_hf_hub_download():
|
|
65
|
+
logger.info("步骤 1 成功,模型已缓存")
|
|
66
|
+
return True
|
|
67
|
+
logger.warning("步骤 1 失败,进入步骤 2")
|
|
68
|
+
|
|
69
|
+
# Step 2: 镜像站下载
|
|
70
|
+
logger.info(f"步骤 2: 切换 HF_ENDPOINT={_HF_MIRROR} 重试...")
|
|
71
|
+
if self._try_hf_hub_download(endpoint=_HF_MIRROR):
|
|
72
|
+
logger.info("步骤 2 成功,模型已缓存")
|
|
73
|
+
return True
|
|
74
|
+
logger.warning("步骤 2 失败,进入步骤 3")
|
|
75
|
+
|
|
76
|
+
# Step 3: curl 子进程下载
|
|
77
|
+
logger.info("步骤 3: 使用 curl 子进程从镜像站下载...")
|
|
78
|
+
if self._try_curl_download():
|
|
79
|
+
logger.info("步骤 3 成功,模型已缓存")
|
|
80
|
+
return True
|
|
81
|
+
logger.error("步骤 3 失败,所有自动方式均已尝试")
|
|
82
|
+
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def _check_cached(self) -> bool:
|
|
86
|
+
"""检查模型是否已在 HuggingFace 缓存目录中存在"""
|
|
87
|
+
if not self._model_cache.exists():
|
|
88
|
+
return False
|
|
89
|
+
snapshots_dir = self._model_cache / "snapshots"
|
|
90
|
+
if not snapshots_dir.exists():
|
|
91
|
+
return False
|
|
92
|
+
# 检查至少有一个 snapshot 且包含 model.safetensors
|
|
93
|
+
try:
|
|
94
|
+
for snapshot in snapshots_dir.iterdir():
|
|
95
|
+
if snapshot.is_dir():
|
|
96
|
+
if (snapshot / "model.safetensors").exists():
|
|
97
|
+
return True
|
|
98
|
+
except OSError:
|
|
99
|
+
logger.warning(f"无法遍历缓存目录: {snapshots_dir}")
|
|
100
|
+
return False
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def _try_hf_hub_download(self, endpoint: Optional[str] = None) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
使用 huggingface_hub 下载模型。
|
|
106
|
+
endpoint=None 为正常模式;endpoint 为镜像站地址。
|
|
107
|
+
"""
|
|
108
|
+
env_backup = None
|
|
109
|
+
try:
|
|
110
|
+
from huggingface_hub import hf_hub_download
|
|
111
|
+
|
|
112
|
+
if endpoint:
|
|
113
|
+
env_backup = os.environ.get("HF_ENDPOINT")
|
|
114
|
+
os.environ["HF_ENDPOINT"] = endpoint
|
|
115
|
+
|
|
116
|
+
# 下载核心文件
|
|
117
|
+
hf_hub_download(
|
|
118
|
+
repo_id=self.model_name,
|
|
119
|
+
filename="model.safetensors",
|
|
120
|
+
cache_dir=str(self._hf_hub_cache),
|
|
121
|
+
local_files_only=False,
|
|
122
|
+
)
|
|
123
|
+
# 尝试下载其他必要文件(不失败)
|
|
124
|
+
for fname in _REQUIRED_FILES[1:]:
|
|
125
|
+
try:
|
|
126
|
+
hf_hub_download(
|
|
127
|
+
repo_id=self.model_name,
|
|
128
|
+
filename=fname,
|
|
129
|
+
cache_dir=str(self._hf_hub_cache),
|
|
130
|
+
local_files_only=False,
|
|
131
|
+
)
|
|
132
|
+
except (OSError, ValueError):
|
|
133
|
+
pass # 非核心文件,缺失不影响基本功能
|
|
134
|
+
|
|
135
|
+
return True
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning(f"huggingface_hub 下载失败: {e}")
|
|
138
|
+
return False
|
|
139
|
+
finally:
|
|
140
|
+
if env_backup is not None:
|
|
141
|
+
os.environ["HF_ENDPOINT"] = env_backup
|
|
142
|
+
elif endpoint and "HF_ENDPOINT" in os.environ:
|
|
143
|
+
del os.environ["HF_ENDPOINT"]
|
|
144
|
+
|
|
145
|
+
def _try_curl_download(self) -> bool:
|
|
146
|
+
"""
|
|
147
|
+
使用 curl 子进程下载模型文件到 HF 缓存目录。
|
|
148
|
+
按 HF 缓存目录结构放置,使 sentence-transformers 认为"模型已缓存"。
|
|
149
|
+
"""
|
|
150
|
+
if not shutil.which("curl"):
|
|
151
|
+
logger.warning("系统未安装 curl,跳过步骤 3")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
# 构建镜像站 URL(对模型名进行 URL 编码,防止特殊字符破坏 URL)
|
|
155
|
+
encoded_name = quote(self.model_name, safe="/")
|
|
156
|
+
base_url = f"{_HF_MIRROR}/{encoded_name}/resolve/main"
|
|
157
|
+
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
159
|
+
tmp_path = Path(tmpdir)
|
|
160
|
+
downloaded = []
|
|
161
|
+
|
|
162
|
+
for fname in _REQUIRED_FILES:
|
|
163
|
+
url = f"{base_url}/{fname}"
|
|
164
|
+
dest = tmp_path / fname
|
|
165
|
+
logger.info(f"下载 {fname}...")
|
|
166
|
+
try:
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
[
|
|
169
|
+
"curl",
|
|
170
|
+
"-L",
|
|
171
|
+
"-f",
|
|
172
|
+
"-s",
|
|
173
|
+
"-S",
|
|
174
|
+
"--connect-timeout",
|
|
175
|
+
"10",
|
|
176
|
+
"--max-time",
|
|
177
|
+
str(_TIMEOUT_CURL),
|
|
178
|
+
"-o",
|
|
179
|
+
str(dest),
|
|
180
|
+
url,
|
|
181
|
+
],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
timeout=_TIMEOUT_CURL + 5,
|
|
185
|
+
)
|
|
186
|
+
if result.returncode == 0 and dest.exists() and dest.stat().st_size > 0:
|
|
187
|
+
downloaded.append(fname)
|
|
188
|
+
else:
|
|
189
|
+
logger.debug(f"{fname} 下载失败或为空,跳过")
|
|
190
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
191
|
+
logger.debug(f"{fname} 下载异常: {e}")
|
|
192
|
+
|
|
193
|
+
if "model.safetensors" not in downloaded:
|
|
194
|
+
logger.error("model.safetensors 下载失败,步骤 3 未完成")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
# 按 HF 缓存目录结构放置
|
|
198
|
+
import hashlib
|
|
199
|
+
|
|
200
|
+
commit_hash = hashlib.sha256(self.model_name.encode()).hexdigest()[:12]
|
|
201
|
+
snapshot_dir = self._model_cache / "snapshots" / commit_hash
|
|
202
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
|
|
204
|
+
for fname in downloaded:
|
|
205
|
+
src = tmp_path / fname
|
|
206
|
+
dst = snapshot_dir / fname
|
|
207
|
+
shutil.copy2(str(src), str(dst))
|
|
208
|
+
|
|
209
|
+
# 创建 refs 指向 snapshot
|
|
210
|
+
refs_dir = self._model_cache / "refs"
|
|
211
|
+
refs_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
(refs_dir / "main").write_text(commit_hash, encoding="utf-8")
|
|
213
|
+
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
def get_manual_instructions(self) -> str:
|
|
217
|
+
"""获取手动下载说明"""
|
|
218
|
+
return (
|
|
219
|
+
f"自动下载失败。请手动下载模型:\n"
|
|
220
|
+
f" 1. 访问 {_HF_MIRROR}/{self.model_name}\n"
|
|
221
|
+
f" 2. 下载 model.safetensors 和 config.json\n"
|
|
222
|
+
f" 3. 放置到 {self._model_cache}/snapshots/\n"
|
|
223
|
+
f" 或运行: bash scripts/download-model-intranet.sh"
|
|
224
|
+
)
|
|
@@ -141,7 +141,7 @@ class BatchProcessor:
|
|
|
141
141
|
except Exception as e:
|
|
142
142
|
logger.warning(f"Failed to encode batch {i}: {e}")
|
|
143
143
|
# 失败时返回零向量
|
|
144
|
-
dim = backend.
|
|
144
|
+
dim = backend.dimension
|
|
145
145
|
results.extend([[0.0] * dim] * len(batch))
|
|
146
146
|
|
|
147
147
|
if progress and task is not None:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# JFox 内网模型下载脚本
|
|
6
|
+
# 当 huggingface_hub 无法工作时,使用 curl 手动下载模型
|
|
7
|
+
# =============================================================================
|
|
8
|
+
|
|
9
|
+
MODEL_NAME="${1:-sentence-transformers/all-MiniLM-L6-v2}"
|
|
10
|
+
HF_MIRROR="https://hf-mirror.com"
|
|
11
|
+
|
|
12
|
+
# 获取 HF 缓存目录
|
|
13
|
+
HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}"
|
|
14
|
+
HUB_CACHE="${HUGGINGFACE_HUB_CACHE:-$HF_HOME/hub}"
|
|
15
|
+
MODEL_CACHE="$HUB_CACHE/models--${MODEL_NAME//\//--}"
|
|
16
|
+
|
|
17
|
+
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
|
|
18
|
+
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
|
19
|
+
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
|
20
|
+
|
|
21
|
+
# 检查 curl
|
|
22
|
+
if ! command -v curl > /dev/null 2>&1; then
|
|
23
|
+
error "curl 未安装,请先安装 curl"
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
info "目标模型: $MODEL_NAME"
|
|
28
|
+
info "缓存目录: $MODEL_CACHE"
|
|
29
|
+
|
|
30
|
+
# 生成伪 commit hash
|
|
31
|
+
COMMIT_HASH=$(echo -n "$MODEL_NAME" | sha256sum | cut -c1-12)
|
|
32
|
+
SNAPSHOT_DIR="$MODEL_CACHE/snapshots/$COMMIT_HASH"
|
|
33
|
+
|
|
34
|
+
mkdir -p "$SNAPSHOT_DIR"
|
|
35
|
+
|
|
36
|
+
# 下载文件列表
|
|
37
|
+
FILES=("model.safetensors" "config.json" "tokenizer.json" "tokenizer_config.json")
|
|
38
|
+
|
|
39
|
+
for fname in "${FILES[@]}"; do
|
|
40
|
+
URL="$HF_MIRROR/$MODEL_NAME/resolve/main/$fname"
|
|
41
|
+
DEST="$SNAPSHOT_DIR/$fname"
|
|
42
|
+
|
|
43
|
+
if [ -f "$DEST" ] && [ -s "$DEST" ]; then
|
|
44
|
+
info "$fname 已存在,跳过"
|
|
45
|
+
continue
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
info "下载 $fname..."
|
|
49
|
+
if curl -L -f -s -S --connect-timeout 10 --max-time 120 \
|
|
50
|
+
-o "$DEST" "$URL"; then
|
|
51
|
+
info "$fname 下载完成"
|
|
52
|
+
else
|
|
53
|
+
warn "$fname 下载失败或不存在,跳过"
|
|
54
|
+
rm -f "$DEST"
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
|
|
58
|
+
# 检查核心文件
|
|
59
|
+
if [ ! -f "$SNAPSHOT_DIR/model.safetensors" ]; then
|
|
60
|
+
error "model.safetensors 下载失败"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# 创建 refs
|
|
65
|
+
mkdir -p "$MODEL_CACHE/refs"
|
|
66
|
+
echo "$COMMIT_HASH" > "$MODEL_CACHE/refs/main"
|
|
67
|
+
|
|
68
|
+
info "模型下载完成: $MODEL_CACHE"
|
|
69
|
+
info "现在可以运行: jfox daemon start"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""模型下载集成测试"""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from jfox.model_downloader import ModelDownloader
|
|
8
|
+
|
|
9
|
+
pytestmark = [pytest.mark.integration]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestModelDownloadRetryChain:
|
|
13
|
+
"""mock 网络层,验证完整重试链按顺序执行"""
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def downloader(self, tmp_path):
|
|
17
|
+
with patch(
|
|
18
|
+
"jfox.model_downloader.ModelDownloader._get_hf_hub_cache",
|
|
19
|
+
return_value=tmp_path / "hub",
|
|
20
|
+
):
|
|
21
|
+
return ModelDownloader("sentence-transformers/all-MiniLM-L6-v2")
|
|
22
|
+
|
|
23
|
+
def test_full_chain_step1_succeeds(self, downloader):
|
|
24
|
+
"""Step 1 成功,后续步骤不执行"""
|
|
25
|
+
with patch.object(downloader, "_try_hf_hub_download", side_effect=[True, False]) as mock_hf:
|
|
26
|
+
with patch.object(downloader, "_try_curl_download") as mock_curl:
|
|
27
|
+
result = downloader.ensure_cached()
|
|
28
|
+
assert result is True
|
|
29
|
+
assert mock_hf.call_count == 1
|
|
30
|
+
mock_curl.assert_not_called()
|
|
31
|
+
|
|
32
|
+
def test_full_chain_step1_fails_step2_succeeds(self, downloader):
|
|
33
|
+
"""Step 1 失败,Step 2 成功"""
|
|
34
|
+
with patch.object(downloader, "_try_hf_hub_download", side_effect=[False, True]) as mock_hf:
|
|
35
|
+
with patch.object(downloader, "_try_curl_download") as mock_curl:
|
|
36
|
+
result = downloader.ensure_cached()
|
|
37
|
+
assert result is True
|
|
38
|
+
calls = mock_hf.call_args_list
|
|
39
|
+
assert len(calls) == 2
|
|
40
|
+
assert calls[0][1].get("endpoint") is None
|
|
41
|
+
assert calls[1][1].get("endpoint") is not None
|
|
42
|
+
mock_curl.assert_not_called()
|
|
43
|
+
|
|
44
|
+
def test_full_chain_step1_2_fail_step3_succeeds(self, downloader):
|
|
45
|
+
"""Step 1/2 失败,Step 3 成功"""
|
|
46
|
+
with patch.object(downloader, "_try_hf_hub_download", return_value=False):
|
|
47
|
+
with patch.object(downloader, "_try_curl_download", return_value=True) as mock_curl:
|
|
48
|
+
result = downloader.ensure_cached()
|
|
49
|
+
assert result is True
|
|
50
|
+
mock_curl.assert_called_once()
|
|
51
|
+
|
|
52
|
+
def test_full_chain_all_fail(self, downloader):
|
|
53
|
+
"""全部失败,返回 False"""
|
|
54
|
+
with patch.object(downloader, "_try_hf_hub_download", return_value=False):
|
|
55
|
+
with patch.object(downloader, "_try_curl_download", return_value=False):
|
|
56
|
+
result = downloader.ensure_cached()
|
|
57
|
+
assert result is False
|
|
58
|
+
|
|
59
|
+
def test_daemon_start_calls_downloader(self):
|
|
60
|
+
"""验证 daemon start 启动前调用下载检查"""
|
|
61
|
+
from jfox.daemon.process import start_daemon
|
|
62
|
+
|
|
63
|
+
with patch("jfox.daemon.process._http_health_check", return_value=None):
|
|
64
|
+
with patch("jfox.daemon.process._check_model_cache") as mock_cache:
|
|
65
|
+
mock_cache.return_value = {
|
|
66
|
+
"needs_download": True,
|
|
67
|
+
"model_name": "test-model",
|
|
68
|
+
"size_hint": "90MB",
|
|
69
|
+
}
|
|
70
|
+
with patch(
|
|
71
|
+
"jfox.model_downloader.ModelDownloader",
|
|
72
|
+
) as mock_cls:
|
|
73
|
+
mock_downloader = MagicMock()
|
|
74
|
+
mock_downloader.ensure_cached.return_value = True
|
|
75
|
+
mock_cls.return_value = mock_downloader
|
|
76
|
+
|
|
77
|
+
with patch("jfox.daemon.process.subprocess.Popen"):
|
|
78
|
+
with patch(
|
|
79
|
+
"jfox.daemon.process._http_health_check",
|
|
80
|
+
side_effect=[None, {"pid": 123}],
|
|
81
|
+
):
|
|
82
|
+
start_daemon()
|
|
83
|
+
mock_cls.assert_called_once_with("test-model")
|
|
84
|
+
mock_downloader.ensure_cached.assert_called_once()
|
|
85
|
+
|
|
86
|
+
def test_cli_model_download_command(self):
|
|
87
|
+
"""验证 CLI model download 命令正确调用 downloader"""
|
|
88
|
+
from typer.testing import CliRunner
|
|
89
|
+
|
|
90
|
+
from jfox.cli import app
|
|
91
|
+
|
|
92
|
+
runner = CliRunner()
|
|
93
|
+
|
|
94
|
+
with patch(
|
|
95
|
+
"jfox.model_downloader.ModelDownloader",
|
|
96
|
+
) as mock_cls:
|
|
97
|
+
mock_downloader = MagicMock()
|
|
98
|
+
mock_downloader.ensure_cached.return_value = True
|
|
99
|
+
mock_downloader._check_cached.return_value = False
|
|
100
|
+
mock_cls.return_value = mock_downloader
|
|
101
|
+
|
|
102
|
+
result = runner.invoke(app, ["model", "download"])
|
|
103
|
+
assert result.exit_code == 0
|
|
104
|
+
mock_cls.assert_called_once()
|
|
105
|
+
mock_downloader.ensure_cached.assert_called_once()
|
|
@@ -139,10 +139,13 @@ class TestDaemonModelCacheCheck:
|
|
|
139
139
|
assert FIRST_RUN_TIMEOUT > STARTUP_TIMEOUT
|
|
140
140
|
assert FIRST_RUN_TIMEOUT >= 300
|
|
141
141
|
|
|
142
|
+
@patch("jfox.model_downloader.ModelDownloader.ensure_cached", return_value=True)
|
|
142
143
|
@patch("jfox.daemon.process._check_model_cache")
|
|
143
144
|
@patch("jfox.daemon.process.subprocess.Popen")
|
|
144
145
|
@patch("jfox.daemon.process._http_health_check")
|
|
145
|
-
def test_first_run_uses_extended_timeout(
|
|
146
|
+
def test_first_run_uses_extended_timeout(
|
|
147
|
+
self, mock_health, mock_popen, mock_cache, mock_ensure
|
|
148
|
+
):
|
|
146
149
|
"""首次下载模型时应使用 FIRST_RUN_TIMEOUT"""
|
|
147
150
|
from jfox.daemon.process import start_daemon
|
|
148
151
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""ModelDownloader 单元测试"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from jfox.model_downloader import _HF_MIRROR, ModelDownloader
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestModelDownloader:
|
|
12
|
+
"""ModelDownloader 单元测试"""
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def downloader(self, tmp_path):
|
|
16
|
+
"""创建带临时缓存的 downloader"""
|
|
17
|
+
with patch(
|
|
18
|
+
"jfox.model_downloader.ModelDownloader._get_hf_hub_cache",
|
|
19
|
+
return_value=tmp_path / "hub",
|
|
20
|
+
):
|
|
21
|
+
d = ModelDownloader("sentence-transformers/all-MiniLM-L6-v2")
|
|
22
|
+
return d
|
|
23
|
+
|
|
24
|
+
def test_check_cached_when_not_exists(self, downloader):
|
|
25
|
+
"""缓存不存在时返回 False"""
|
|
26
|
+
assert downloader._check_cached() is False
|
|
27
|
+
|
|
28
|
+
def test_check_cached_when_exists(self, downloader):
|
|
29
|
+
"""缓存存在时返回 True"""
|
|
30
|
+
snapshot = downloader._model_cache / "snapshots" / "abc123"
|
|
31
|
+
snapshot.mkdir(parents=True)
|
|
32
|
+
(snapshot / "model.safetensors").write_text("fake")
|
|
33
|
+
assert downloader._check_cached() is True
|
|
34
|
+
|
|
35
|
+
def test_check_cached_missing_model_file(self, downloader):
|
|
36
|
+
"""有 snapshot 但缺少 model.safetensors 时返回 False"""
|
|
37
|
+
snapshot = downloader._model_cache / "snapshots" / "abc123"
|
|
38
|
+
snapshot.mkdir(parents=True)
|
|
39
|
+
(snapshot / "config.json").write_text("fake")
|
|
40
|
+
assert downloader._check_cached() is False
|
|
41
|
+
|
|
42
|
+
def test_ensure_cached_early_return_when_cached(self, downloader):
|
|
43
|
+
"""已缓存时直接返回 True,不走重试链"""
|
|
44
|
+
snapshot = downloader._model_cache / "snapshots" / "abc123"
|
|
45
|
+
snapshot.mkdir(parents=True)
|
|
46
|
+
(snapshot / "model.safetensors").write_text("fake")
|
|
47
|
+
|
|
48
|
+
with patch.object(downloader, "_try_hf_hub_download") as mock_hf:
|
|
49
|
+
result = downloader.ensure_cached()
|
|
50
|
+
assert result is True
|
|
51
|
+
mock_hf.assert_not_called()
|
|
52
|
+
|
|
53
|
+
def test_ensure_cached_step1_succeeds(self, downloader):
|
|
54
|
+
"""Step 1 成功,后续步骤不执行"""
|
|
55
|
+
with patch.object(downloader, "_try_hf_hub_download", side_effect=[True, False]) as mock_hf:
|
|
56
|
+
with patch.object(downloader, "_try_curl_download") as mock_curl:
|
|
57
|
+
result = downloader.ensure_cached()
|
|
58
|
+
assert result is True
|
|
59
|
+
assert mock_hf.call_count == 1
|
|
60
|
+
mock_curl.assert_not_called()
|
|
61
|
+
|
|
62
|
+
def test_ensure_cached_step1_fails_step2_succeeds(self, downloader):
|
|
63
|
+
"""Step 1 失败,Step 2 成功"""
|
|
64
|
+
with patch.object(downloader, "_try_hf_hub_download", side_effect=[False, True]) as mock_hf:
|
|
65
|
+
with patch.object(downloader, "_try_curl_download") as mock_curl:
|
|
66
|
+
result = downloader.ensure_cached()
|
|
67
|
+
assert result is True
|
|
68
|
+
assert mock_hf.call_count == 2
|
|
69
|
+
mock_curl.assert_not_called()
|
|
70
|
+
|
|
71
|
+
def test_ensure_cached_step1_2_fail_step3_succeeds(self, downloader):
|
|
72
|
+
"""Step 1/2 失败,Step 3 成功"""
|
|
73
|
+
with patch.object(downloader, "_try_hf_hub_download", return_value=False) as mock_hf:
|
|
74
|
+
with patch.object(downloader, "_try_curl_download", return_value=True) as mock_curl:
|
|
75
|
+
result = downloader.ensure_cached()
|
|
76
|
+
assert result is True
|
|
77
|
+
assert mock_hf.call_count == 2
|
|
78
|
+
mock_curl.assert_called_once()
|
|
79
|
+
|
|
80
|
+
def test_ensure_cached_all_fail(self, downloader):
|
|
81
|
+
"""全部失败,返回 False"""
|
|
82
|
+
with patch.object(downloader, "_try_hf_hub_download", return_value=False):
|
|
83
|
+
with patch.object(downloader, "_try_curl_download", return_value=False):
|
|
84
|
+
result = downloader.ensure_cached()
|
|
85
|
+
assert result is False
|
|
86
|
+
|
|
87
|
+
def test_try_hf_hub_download_sets_env(self, downloader):
|
|
88
|
+
"""验证镜像模式设置了 HF_ENDPOINT 环境变量"""
|
|
89
|
+
env_before = os.environ.get("HF_ENDPOINT")
|
|
90
|
+
|
|
91
|
+
with patch("huggingface_hub.hf_hub_download") as mock_download:
|
|
92
|
+
mock_download.side_effect = Exception("network")
|
|
93
|
+
downloader._try_hf_hub_download(endpoint=_HF_MIRROR)
|
|
94
|
+
|
|
95
|
+
# 调用后环境变量应被恢复
|
|
96
|
+
assert os.environ.get("HF_ENDPOINT") == env_before
|
|
97
|
+
|
|
98
|
+
def test_try_curl_download_no_curl(self, downloader):
|
|
99
|
+
"""curl 不存在时返回 False"""
|
|
100
|
+
with patch("jfox.model_downloader.shutil.which", return_value=None):
|
|
101
|
+
result = downloader._try_curl_download()
|
|
102
|
+
assert result is False
|
|
103
|
+
|
|
104
|
+
def test_cleanup_partial(self, downloader):
|
|
105
|
+
"""验证部分下载残留被清理(通过 TemporaryDirectory 自动实现)"""
|
|
106
|
+
with patch("jfox.model_downloader.shutil.which", return_value="curl"):
|
|
107
|
+
with patch("jfox.model_downloader.subprocess.run") as mock_run:
|
|
108
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
109
|
+
result = downloader._try_curl_download()
|
|
110
|
+
# curl 未实际下载文件,返回 False;TemporaryDirectory 自动清理
|
|
111
|
+
assert result is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md
RENAMED
|
File without changes
|
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md
RENAMED
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md
RENAMED
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md
RENAMED
|
File without changes
|
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md
RENAMED
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-session-summary/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|