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.
Files changed (122) hide show
  1. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/CHANGELOG.md +10 -0
  2. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/PKG-INFO +1 -1
  3. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/__init__.py +1 -1
  4. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/cli.py +82 -0
  5. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/process.py +17 -6
  6. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/server.py +10 -3
  7. jfox_cli-0.4.3/jfox/model_downloader.py +224 -0
  8. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/performance.py +1 -1
  9. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/pyproject.toml +1 -1
  10. jfox_cli-0.4.3/scripts/download-model-intranet.sh +69 -0
  11. jfox_cli-0.4.3/tests/integration/test_model_download.py +105 -0
  12. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_daemon_process.py +4 -1
  13. jfox_cli-0.4.3/tests/unit/test_model_downloader.py +111 -0
  14. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/uv.lock +1 -1
  15. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/settings.local.json +0 -0
  16. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/skills/release/SKILL.md +0 -0
  17. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.claude/skills/release/release_helper.py +0 -0
  18. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.githooks/pre-push +0 -0
  19. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.github/workflows/integration-test.yml +0 -0
  20. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.github/workflows/publish.yml +0 -0
  21. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.gitignore +0 -0
  22. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/.python-version +0 -0
  23. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/AGENTS.md +0 -0
  24. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/CLAUDE.md +0 -0
  25. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/DEVELOPMENT_PLAN.md +0 -0
  26. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/README.md +0 -0
  27. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/SESSION.md +0 -0
  28. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/SESSION_SUMMARY.md +0 -0
  29. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/installation.md +0 -0
  30. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-bulk-import-bm25-fix.md +0 -0
  31. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-edit-command.md +0 -0
  32. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-11-unify-format-option.md +0 -0
  33. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-ci-coverage-optimization.md +0 -0
  34. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-edit-content-file.md +0 -0
  35. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-rebuild-clear.md +0 -0
  36. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-fix-index-verify-id-mismatch.md +0 -0
  37. {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
  38. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-index-kb-param.md +0 -0
  39. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-lazy-import-perf.md +0 -0
  40. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-12-skill-redesign.md +0 -0
  41. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-14-sync-docs-daemon-show.md +0 -0
  42. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/plans/2026-04-15-session-summary-confirmation.md +0 -0
  43. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-03-bugfixes-design.md +0 -0
  44. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-12-skill-redesign-design.md +0 -0
  45. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-13-pr-auto-code-review-design.md +0 -0
  46. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-14-show-command-design.md +0 -0
  47. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/superpowers/specs/2026-04-15-session-summary-confirmation-design.md +0 -0
  48. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/docs/troubleshooting.md +0 -0
  49. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jessica-jones-static-cable.md +0 -0
  50. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/__main__.py +0 -0
  51. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/bm25_index.py +0 -0
  52. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/config.py +0 -0
  53. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/__init__.py +0 -0
  54. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/__main__.py +0 -0
  55. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/daemon/client.py +0 -0
  56. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/embedding_backend.py +0 -0
  57. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/formatters.py +0 -0
  58. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/git_extractor.py +0 -0
  59. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/global_config.py +0 -0
  60. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/graph.py +0 -0
  61. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/indexer.py +0 -0
  62. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/kb_manager.py +0 -0
  63. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/models.py +0 -0
  64. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/note.py +0 -0
  65. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/search_engine.py +0 -0
  66. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/template.py +0 -0
  67. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/template_cli.py +0 -0
  68. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/jfox/vector_store.py +0 -0
  69. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/pytest.ini +0 -0
  70. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/run_full_test.ps1 +0 -0
  71. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/README.md +0 -0
  72. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-common/SKILL.md +0 -0
  73. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-ingest/SKILL.md +0 -0
  74. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-organize/SKILL.md +0 -0
  75. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-search/SKILL.md +0 -0
  76. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/claude-code/jfox-session-summary/SKILL.md +0 -0
  77. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-common/SKILL.md +0 -0
  78. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-ingest/SKILL.md +0 -0
  79. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-organize/SKILL.md +0 -0
  80. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-search/SKILL.md +0 -0
  81. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/skills-recommend/kimi-cli/jfox-session-summary/SKILL.md +0 -0
  82. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/COVERAGE_PLAN.md +0 -0
  83. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/MIGRATION.md +0 -0
  84. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/TESTS.md +0 -0
  85. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/conftest.py +0 -0
  86. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/integration/__init__.py +0 -0
  87. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/integration/test_backlinks.py +0 -0
  88. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/performance/__init__.py +0 -0
  89. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/performance/test_performance.py +0 -0
  90. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_advanced_features.py +0 -0
  91. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_cli_format.py +0 -0
  92. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_config_set_unit.py +0 -0
  93. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_config_unit.py +0 -0
  94. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_core_workflow.py +0 -0
  95. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_embedding_device.py +0 -0
  96. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_hybrid_search.py +0 -0
  97. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_integration.py +0 -0
  98. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_kb_current.py +0 -0
  99. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/test_suggest_links.py +0 -0
  100. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/__init__.py +0 -0
  101. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_bm25_batch.py +0 -0
  102. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_edit.py +0 -0
  103. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_format_unify.py +0 -0
  104. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_formatters.py +0 -0
  105. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_git_extractor.py +0 -0
  106. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_global_config.py +0 -0
  107. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_index_kb_param.py +0 -0
  108. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_indexer_clear_before_rebuild.py +0 -0
  109. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_indexer_verify.py +0 -0
  110. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_kb_manager.py +0 -0
  111. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_lazy_import.py +0 -0
  112. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_logging_config.py +0 -0
  113. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_release_helper.py +0 -0
  114. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_show.py +0 -0
  115. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_template.py +0 -0
  116. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_template_cli.py +0 -0
  117. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/unit/test_vector_store_clear.py +0 -0
  118. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/__init__.py +0 -0
  119. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/assertions.py +0 -0
  120. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/jfox_cli.py +0 -0
  121. {jfox_cli-0.4.2 → jfox_cli-0.4.3}/tests/utils/note_generator.py +0 -0
  122. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jfox-cli
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: JFox - Zettelkasten 知识管理 CLI 工具
5
5
  Project-URL: Homepage, https://github.com/zhuxixi/jfox
6
6
  Project-URL: Repository, https://github.com/zhuxixi/jfox
@@ -1,5 +1,5 @@
1
1
  """JFox - Zettelkasten 知识管理工具"""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.3"
4
4
  __author__ = "User"
5
5
  __email__ = "user@example.com"
@@ -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 Exception:
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 Exception:
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 Exception:
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 Exception as e:
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 Exception as e:
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 Exception as e:
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.model.get_sentence_embedding_dimension()
144
+ dim = backend.dimension
145
145
  results.extend([[0.0] * dim] * len(batch))
146
146
 
147
147
  if progress and task is not None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jfox-cli"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "JFox - Zettelkasten 知识管理 CLI 工具"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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(self, mock_health, mock_popen, mock_cache):
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
@@ -867,7 +867,7 @@ wheels = [
867
867
 
868
868
  [[package]]
869
869
  name = "jfox-cli"
870
- version = "0.4.2"
870
+ version = "0.4.3"
871
871
  source = { editable = "." }
872
872
  dependencies = [
873
873
  { name = "chromadb" },
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