nexforge-cli 0.2.16__tar.gz → 0.2.17__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 (203) hide show
  1. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/PKG-INFO +2 -2
  2. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/README.md +1 -1
  3. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/01-/345/256/211/350/243/205/344/270/216/351/205/215/347/275/256.md +1 -1
  4. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/03-/346/250/241/345/236/213/351/205/215/347/275/256.md +4 -0
  5. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/05-/346/225/260/346/215/256/344/270/216/346/220/234/347/264/242.md +6 -0
  6. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/08-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +4 -0
  7. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/README.md +1 -1
  8. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/pyproject.toml +1 -1
  9. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/__init__.py +1 -1
  10. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/config.py +4 -0
  11. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/knowledge_base.py +5 -1
  12. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/project_mapper.py +39 -8
  13. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/provider.py +17 -0
  14. nexforge_cli-0.2.17/src/nexforge/features/map_feature.py +91 -0
  15. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/deepseek.py +15 -2
  16. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/openai.py +7 -1
  17. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/store/session_store.py +11 -3
  18. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/image_tools.py +9 -2
  19. nexforge_cli-0.2.17/tests/test_project_map_prune.py +105 -0
  20. nexforge_cli-0.2.17/tests/test_vision_model.py +130 -0
  21. nexforge_cli-0.2.16/src/nexforge/features/map_feature.py +0 -54
  22. nexforge_cli-0.2.16/tests/test_vision_model.py +0 -65
  23. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/.gitignore +0 -0
  24. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/INSTALL.md +0 -0
  25. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/STATS_API.md +0 -0
  26. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/agent.svg +0 -0
  27. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/cost.svg +0 -0
  28. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/edit.svg +0 -0
  29. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/err.svg +0 -0
  30. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/file.svg +0 -0
  31. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/model.svg +0 -0
  32. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/ok.svg +0 -0
  33. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/read.svg +0 -0
  34. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/running.svg +0 -0
  35. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/search.svg +0 -0
  36. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/shell.svg +0 -0
  37. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/thinking.svg +0 -0
  38. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/web.svg +0 -0
  39. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/assets/icons/write.svg +0 -0
  40. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/config.example.toml +0 -0
  41. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/devlog/2026-07-07.md +0 -0
  42. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/doc//344/273/243/347/240/201/344/274/230/345/214/226/350/256/241/345/210/222.md" +0 -0
  43. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/doc//347/211/271/346/200/247/346/250/241/345/235/227/346/263/250/345/206/214/345/210/266-/345/256/236/346/226/275/350/256/241/345/210/222.md" +0 -0
  44. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/doc//351/241/271/347/233/256/346/200/273/347/233/221/345/276/252/347/216/257-/345/274/200/345/217/221/350/256/241/345/210/222.md" +0 -0
  45. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/docs/submission/README-/346/212/225/347/250/277/350/257/264/346/230/216.md" +0 -0
  46. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/docs/submission/nexforge.md +0 -0
  47. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/docs/submission/nexforge.zh-CN.md +0 -0
  48. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/02-/347/225/214/351/235/242/344/270/216/345/277/253/346/215/267/351/224/256.md" +0 -0
  49. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/04-/345/267/245/345/205/267/345/217/202/350/200/203.md" +0 -0
  50. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/06-/346/235/203/351/231/220/344/270/216/346/250/241/345/274/217.md" +0 -0
  51. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/07-/344/274/232/350/257/235/344/270/216/350/256/260/345/277/206.md" +0 -0
  52. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/09-/345/270/270/350/247/201/351/227/256/351/242/230.md" +0 -0
  53. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual/10-/346/217/222/344/273/266/345/274/200/345/217/221.md" +0 -0
  54. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/manual//351/231/204/345/275/225-/345/257/271/346/257/224/345/210/206/346/236/220.md" +0 -0
  55. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/README.md +0 -0
  56. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/__init__.py +0 -0
  57. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/action.py +0 -0
  58. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/base.py +0 -0
  59. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/tools.py +0 -0
  60. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/vision.py +0 -0
  61. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/guiauto/window.py +0 -0
  62. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/install_plugin.py +0 -0
  63. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/requirements.txt +0 -0
  64. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/smoke_safe.py +0 -0
  65. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/verify_final.py +0 -0
  66. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/verify_host.py +0 -0
  67. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/plugin_gui_auto/verify_plugin.py +0 -0
  68. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/scripts/publish.sh +0 -0
  69. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/scripts/smoke_provider.py +0 -0
  70. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/scripts/smoke_tui.py +0 -0
  71. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/__main__.py +0 -0
  72. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/app.py +0 -0
  73. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/cli.py +0 -0
  74. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/__init__.py +0 -0
  75. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/agent.py +0 -0
  76. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/context.py +0 -0
  77. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/federation.py +0 -0
  78. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/goal_manager.py +0 -0
  79. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/jobs.py +0 -0
  80. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/memory_engine.py +0 -0
  81. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/model_catalog.py +0 -0
  82. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/permissions.py +0 -0
  83. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/plugin.py +0 -0
  84. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/router.py +0 -0
  85. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/session.py +0 -0
  86. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/summarizer.py +0 -0
  87. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/system_inject.py +0 -0
  88. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/tool_manifest.py +0 -0
  89. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/types.py +0 -0
  90. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/core/update_check.py +0 -0
  91. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/__init__.py +0 -0
  92. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/auto_skills.py +0 -0
  93. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/base.py +0 -0
  94. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/federation_feature.py +0 -0
  95. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/kb_feature.py +0 -0
  96. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/memory_feature.py +0 -0
  97. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/mode_features.py +0 -0
  98. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/plan_feature.py +0 -0
  99. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/registry.py +0 -0
  100. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/session_feature.py +0 -0
  101. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/slash_feature.py +0 -0
  102. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/stats_feature.py +0 -0
  103. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/features/tool_manifest_feature.py +0 -0
  104. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/i18n.py +0 -0
  105. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/icons.py +0 -0
  106. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/mcp/__init__.py +0 -0
  107. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/__init__.py +0 -0
  108. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/_retry.py +0 -0
  109. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/anthropic.py +0 -0
  110. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/balance.py +0 -0
  111. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/provider/qianwen.py +0 -0
  112. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/skills/loader.py +0 -0
  113. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/stats/__init__.py +0 -0
  114. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/stats/heartbeat.py +0 -0
  115. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/store/__init__.py +0 -0
  116. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/store/director_store.py +0 -0
  117. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/store/plan_store.py +0 -0
  118. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/theme.tcss +0 -0
  119. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/__init__.py +0 -0
  120. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/_paths.py +0 -0
  121. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/background.py +0 -0
  122. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/base.py +0 -0
  123. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/data_tools.py +0 -0
  124. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/edit_file.py +0 -0
  125. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/federation.py +0 -0
  126. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/fs_ops.py +0 -0
  127. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/git.py +0 -0
  128. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/kb_tools.py +0 -0
  129. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/list_dir.py +0 -0
  130. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/multi_edit.py +0 -0
  131. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/ops_tools.py +0 -0
  132. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/read_file.py +0 -0
  133. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/registry.py +0 -0
  134. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/screenshot_tools.py +0 -0
  135. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/search.py +0 -0
  136. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/search_content.py +0 -0
  137. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/search_files.py +0 -0
  138. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/shell.py +0 -0
  139. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/web.py +0 -0
  140. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/tools/write_file.py +0 -0
  141. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/__init__.py +0 -0
  142. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/clipboard_image.py +0 -0
  143. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/screen_api.py +0 -0
  144. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/screens/__init__.py +0 -0
  145. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/screens/chat.py +0 -0
  146. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/screens/chat_actions.py +0 -0
  147. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/screens/splash.py +0 -0
  148. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/slash_handler.py +0 -0
  149. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/__init__.py +0 -0
  150. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/apikey_modal.py +0 -0
  151. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/banner.py +0 -0
  152. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/confirm_modal.py +0 -0
  153. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/diff_modal.py +0 -0
  154. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/file_browser.py +0 -0
  155. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/file_menu.py +0 -0
  156. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/image_sidebar.py +0 -0
  157. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/messages.py +0 -0
  158. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/mode_warning.py +0 -0
  159. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/model_sidebar.py +0 -0
  160. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/multiline_modal.py +0 -0
  161. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/multiline_prompt.py +0 -0
  162. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/plan_sidebar.py +0 -0
  163. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/sidebar.py +0 -0
  164. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/ui/widgets/statusbar.py +0 -0
  165. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/utils/__init__.py +0 -0
  166. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/utils/logging.py +0 -0
  167. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/web/__init__.py +0 -0
  168. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/web/server.py +0 -0
  169. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/src/nexforge/web/static/index.html +0 -0
  170. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/conftest.py +0 -0
  171. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_app_mouse.py +0 -0
  172. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_approval.py +0 -0
  173. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_config.py +0 -0
  174. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_cross_platform.py +0 -0
  175. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_deepseek_provider.py +0 -0
  176. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_director_loop.py +0 -0
  177. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_director_store.py +0 -0
  178. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_dual_panel.py +0 -0
  179. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_federation.py +0 -0
  180. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_file_browser.py +0 -0
  181. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_goal_confirm.py +0 -0
  182. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_i18n.py +0 -0
  183. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_icons.py +0 -0
  184. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_lang_cmd.py +0 -0
  185. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_m2_tools.py +0 -0
  186. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_manual_consistency.py +0 -0
  187. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_model_catalog.py +0 -0
  188. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_p2_core.py +0 -0
  189. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_plugin.py +0 -0
  190. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_sanitize.py +0 -0
  191. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_screenshot_tools.py +0 -0
  192. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_session_repair.py +0 -0
  193. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_session_store.py +0 -0
  194. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_sidebar.py +0 -0
  195. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_sidebar_actions.py +0 -0
  196. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_splash_modal.py +0 -0
  197. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_statusbar.py +0 -0
  198. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_system_inject.py +0 -0
  199. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_tool_iters.py +0 -0
  200. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_tools.py +0 -0
  201. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_tui.py +0 -0
  202. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_update_check.py +0 -0
  203. {nexforge_cli-0.2.16 → nexforge_cli-0.2.17}/tests/test_vision_tool_retention.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nexforge-cli
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)
5
5
  Project-URL: Homepage, https://gitee.com/Lighthorn/nexforgecli
6
6
  Author: NexForge
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
51
51
 
52
52
  NexForgeCLI 是一个运行在终端里的智能体助手,既支持 DeepSeek/千问等多模型,也是**插件宿主**(plugins 目录即插即用)。既能作为全屏 TUI 交互式对话/编码,也能作为 one-shot 命令行工具嵌进脚本和管道。
53
53
 
54
- > 当前版本:**v0.2.16** · PyPI: `pip install nexforge-cli`
54
+ > 当前版本:**v0.2.17** · PyPI: `pip install nexforge-cli`
55
55
 
56
56
  ---
57
57
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  NexForgeCLI 是一个运行在终端里的智能体助手,既支持 DeepSeek/千问等多模型,也是**插件宿主**(plugins 目录即插即用)。既能作为全屏 TUI 交互式对话/编码,也能作为 one-shot 命令行工具嵌进脚本和管道。
10
10
 
11
- > 当前版本:**v0.2.16** · PyPI: `pip install nexforge-cli`
11
+ > 当前版本:**v0.2.17** · PyPI: `pip install nexforge-cli`
12
12
 
13
13
  ---
14
14
 
@@ -1,6 +1,6 @@
1
1
  # 安装与配置
2
2
 
3
- > NexForgeCLI v0.2.16
3
+ > NexForgeCLI v0.2.17
4
4
 
5
5
  ## 环境要求
6
6
 
@@ -109,6 +109,10 @@ models = ["claude-sonnet-4-20250514"]
109
109
  - `api_key` 和 `api_key_env` 都未配置 → 回退到全局保存的 Key(`~/.nexforge/auth.toml`)
110
110
  - `models`:可用模型 ID 列表
111
111
  - `default_model`:可选,指定默认模型,不填取 models 第一个
112
+ - `multimodal`:可选(默认 `false`)。该 provider 挂载的模型**原生支持图片输入**时设为 `true`:
113
+ 用户贴图会直接发给模型(更快、更准,且可保留思考模式),而非降级走识图工具。
114
+ 适用于名字不含 "vision" 但实际能读图的模型(如 `deepseek-v4.1` 系列)。
115
+ 内置 vision 档位无需设置(按模型名自动识别);声明只对该 provider 列出的模型生效。
112
116
 
113
117
  软件只读不写此文件,不用担心 Key 被覆盖。
114
118
 
@@ -54,6 +54,12 @@ HTTP GET 抓取指定 URL,返回正文(截断至 20000 字符)。带浏览
54
54
  - `mode=auto`(默认):优先用当前对话 provider 的视觉模型(如 deepseek-vision,共用对话 key),失败回退阿里云千问 qwen-vl
55
55
  - `mode=cross`:**双模型交叉验证**——deepseek-vision 与 qwen-vl 并行识别同一张图,返回双答案供交叉印证,减少单一模型的视觉误差(适合 GUI 验收/关键判断)
56
56
 
57
+ **何时直接贴图、何时用本工具**(两条通道互补,不是二选一):
58
+ - **用户贴图**(剪贴板截图 / 拖入)→ 当前模型原生多模态时(vision 档位 / 千问 / `models.toml` 里声明 `multimodal = true` 的模型),图片直接发给模型理解,更快更准且可保留思考模式,此时不需要本工具;
59
+ - **AI 自己产出的图片**(`window_screenshot` / `web_screenshot` 等截图工具落盘后)→ **必须用本工具**:
60
+ AI 不能把本地文件"贴"进对话,本工具是它读取本地图片的唯一通道,GUI 验收闭环依赖这一点;
61
+ - 当前模型**不支持**图片输入时(如纯文本 flash/pro),用户贴图也会自动降级走本工具。
62
+
57
63
  ### image_gen(图片生成)
58
64
 
59
65
  提供文字描述,调用阿里云通义万相生成图片并保存到本地。
@@ -125,3 +125,7 @@ engine = "bing" # bing | duckduckgo | google | searxng
125
125
  AI 收到系统提示后会优先查阅地图来快速定位代码,而不是反复 list_dir/search_content 全量扫描。
126
126
 
127
127
  每轮对话结束自动同步地图。
128
+
129
+ **大项目友好**:地图扫描自动跳过 `node_modules`/`.git`/`.venv`/`dist`/`build` 等噪音目录(整棵子树不下钻),
130
+ 且生成与同步都在**后台线程**执行——面对超大工程(如前端 monorepo 的数十万文件)也不会卡住启动界面。
131
+ 待分析代码超过 2000 个文件时按路径排序截断,保证界面快速可用。
@@ -1,4 +1,4 @@
1
- # NexForgeCLI 用户手册 · v0.2.16
1
+ # NexForgeCLI 用户手册 · v0.2.17
2
2
 
3
3
  ## 这是什么
4
4
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexforge-cli"
7
- version = "0.2.16"
7
+ version = "0.2.17"
8
8
  description = "NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,5 +3,5 @@
3
3
  TUI(全屏交互)+ CLI(one-shot / 管道)双模式。
4
4
  """
5
5
 
6
- __version__ = "0.2.16"
6
+ __version__ = "0.2.17"
7
7
  __all__ = ["__version__"]
@@ -51,6 +51,10 @@ class ProviderDef(BaseModel):
51
51
  api_key: str | None = None
52
52
  models: list[str] = [] # 可用模型 ID 列表
53
53
  default_model: str = "" # 默认模型,为空则取 models[0]
54
+ # 该 provider 挂的模型是否支持图片输入(原生多模态)。
55
+ # 用于名字不含 "vision" 但实际支持读图的模型(如 deepseek-v4.1 系列),
56
+ # 显式声明后: 用户贴图直达模型(展开为 image_url), 无需走识图工具。
57
+ multimodal: bool = False
54
58
  pricing: dict[str, PricingTier] = {} # model_id → PricingTier
55
59
  timeout: float = 120.0
56
60
  max_retries: int = 2
@@ -105,8 +105,12 @@ class KnowledgeBase:
105
105
  try:
106
106
  st = f.stat()
107
107
  rel = f.relative_to(d).as_posix()
108
+ # 只读文件头(取标题用), 避免大 md 全量读入:
109
+ # read_text()[:N] 是"先全读再切片", 此处按字符读前 N 个
110
+ with f.open("r", encoding="utf-8", errors="replace") as fh:
111
+ head = fh.read(4000)
108
112
  result[rel] = {
109
- "title": self._title_of(f.read_text(encoding="utf-8")[:4000]),
113
+ "title": self._title_of(head),
110
114
  "size": st.st_size,
111
115
  "mtime": st.st_mtime,
112
116
  }
@@ -24,6 +24,7 @@ from __future__ import annotations
24
24
  import ast
25
25
  import hashlib
26
26
  import json
27
+ import os
27
28
  from collections import defaultdict
28
29
  from datetime import datetime
29
30
  from pathlib import Path
@@ -49,6 +50,33 @@ _EXCLUDED_DIRS: set[str] = {
49
50
 
50
51
  _EXCLUDED_PATTERNS: set[str] = {"test_", "_test", "conftest"}
51
52
 
53
+ # 单次分析的文件数上限: 超大项目(数千模块)AST 解析会拖慢启动,
54
+ # 按路径排序截断并在地图中注明, 保证 UI 快速可用。
55
+ _MAX_FILES = 2000
56
+
57
+
58
+ def _iter_files_pruned(base: Path, suffix: str = "") -> list[Path]:
59
+ """剪枝遍历: 排除目录直接不下钻(替代 Path.rglob)。
60
+
61
+ rglob 的目录过滤发生在遍历**之后**——它必须先走完每个子目录
62
+ (包括 node_modules/.git/.venv 这类噪音目录)才能产出结果。
63
+ 超大前端项目 node_modules 可达数十万文件, Windows 上单次遍历
64
+ 耗时数分钟(实测某项目 rglob 超 120s, 剪枝 0.17s), 而该遍历原本
65
+ 同步发生在启动的 on_mount 里, 直接冻结 UI → 表现为"卡在启动黑屏"。
66
+
67
+ 改用 os.walk(topdown=True) 原地改写 dirnames 剪枝, 排除目录的
68
+ 子树完全不进入, 成本只与真实需分析的文件数相关。
69
+ """
70
+ out: list[Path] = []
71
+ for dirpath, dirnames, filenames in os.walk(base, topdown=True):
72
+ # 原地剪枝: 排除目录不再下降(必须在遍历 filenames 前改写)
73
+ dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
74
+ for fn in filenames:
75
+ if suffix and not fn.endswith(suffix):
76
+ continue
77
+ out.append(Path(dirpath) / fn)
78
+ return out
79
+
52
80
 
53
81
  def _file_hash(path: Path) -> str | None:
54
82
  """快速文件指纹: mtime+size(仅 stat, 不读字节)。
@@ -215,15 +243,14 @@ class ProjectMapper:
215
243
  if not src.is_dir():
216
244
  src = self.root
217
245
  files: list[Path] = []
218
- for f in sorted(src.rglob("*.py")):
219
- if any(part in _EXCLUDED_DIRS for part in f.parts):
220
- continue
246
+ # 剪枝遍历(排除目录不下钻), 替代原 rglob——见 _iter_files_pruned 说明
247
+ for f in sorted(_iter_files_pruned(src, ".py")):
221
248
  if any(p in f.name for p in _EXCLUDED_PATTERNS):
222
249
  continue
223
250
  if f.name.startswith("_") and f.name not in ("__init__.py", "__main__.py"):
224
251
  continue
225
252
  files.append(f)
226
- return files
253
+ return files[:_MAX_FILES]
227
254
 
228
255
  def _analyze_all(self) -> list[_Analyzer]:
229
256
  files = self._walk()
@@ -269,7 +296,13 @@ class ProjectMapper:
269
296
  if fp in old_f and old_f[fp] != h]
270
297
  added = [fp for fp in now_f if fp not in old_f]
271
298
  removed = [fp for fp in old_f if fp not in now_f]
272
- return {"in_sync": not changed and not added and not removed,
299
+ in_sync = not changed and not added and not removed
300
+ # 边界: 地图输出被删除(.nexforge/map/ 或 map.md 没了)而哈希缓存还在时,
301
+ # 只比哈希会误判"已同步"→ 永不重建, AI 从此看不到地图。
302
+ # 故代码未变但输出缺失时, 也视为需要重建。
303
+ if in_sync and not self.index_content():
304
+ in_sync = False
305
+ return {"in_sync": in_sync,
273
306
  "changed": changed, "added": added, "removed": removed,
274
307
  "n_files": now["n_files"], "generated_at": old.get("generated_at", "(无)")}
275
308
 
@@ -474,9 +507,7 @@ class ProjectMapper:
474
507
  continue
475
508
  lines.append(f"## {d}/")
476
509
  found = False
477
- for md in sorted(base.rglob("*.md")):
478
- if any(part in _EXCLUDED_DIRS for part in md.parts):
479
- continue
510
+ for md in sorted(_iter_files_pruned(base, ".md")):
480
511
  rel = md.relative_to(self.root).as_posix()
481
512
  size_kb = max(1, md.stat().st_size // 1024)
482
513
  heads = self._extract_headings(md, max_heads=6)
@@ -36,6 +36,23 @@ class BaseProvider(ABC):
36
36
  """
37
37
  return self.supports_multimodal
38
38
 
39
+ def _pdef_declares_multimodal(self, model: str) -> bool:
40
+ """models.toml 显式声明的多模态是否适用于该模型。
41
+
42
+ 语义: ``pdef.multimodal = true`` 声明的是"**该 provider 挂载的**
43
+ 模型支持图片输入"。若传入的模型名不在该 provider 的 models/
44
+ default_model 列表里(例如同一个 provider 实例被要求跑别的模型),
45
+ 声明不适用, 交由调用方的名字启发式判断——否则会把纯文本模型
46
+ 也误判为多模态, 导致贴图发给看不懂图的模型。
47
+ """
48
+ p = getattr(self, "pdef", None)
49
+ if p is None or not getattr(p, "multimodal", False):
50
+ return False
51
+ declared = [x.lower() for x in (getattr(p, "models", None) or [])]
52
+ if getattr(p, "default_model", ""):
53
+ declared.append(p.default_model.lower())
54
+ return (not declared) or (model.lower() in declared)
55
+
39
56
  async def list_models(self) -> list[str]:
40
57
  """拉取当前 API key 下可用的模型 ID 列表。
41
58
 
@@ -0,0 +1,91 @@
1
+ """项目地图特性——每轮结束自动同步 + 注入地图提示。"""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from nexforge.core.project_mapper import ProjectMapper
8
+ from nexforge.core.system_inject import MAP_MARKER, inject_system
9
+ from nexforge.i18n import t
10
+
11
+ if TYPE_CHECKING:
12
+ from nexforge.ui.screens.chat import ChatScreen
13
+
14
+
15
+ _MAP_MARKER = MAP_MARKER # 区分旧 [项目地图] 单文件格式,避免旧会话去重误判
16
+ _MAP_PROMPT = (
17
+ f"{_MAP_MARKER} 项目结构分析存于 .nexforge/map/ 目录(分层索引体系)。\n"
18
+ "总索引见下方(已注入本会话): 各层模块清单 + 详细文件导航。\n"
19
+ "定位函数/追踪调用/查依赖/查文档时:\n"
20
+ "1. 先看总索引判断属于哪一层/哪个文件\n"
21
+ "2. 再 read_file 读对应详细文件(.nexforge/map/symbols-<层>.md 等)\n"
22
+ "3. 避免大范围 list_dir/search_content 扫描全项目。"
23
+ )
24
+
25
+
26
+ class MapFeature:
27
+ name = "map"
28
+
29
+ def register(self, screen: ChatScreen) -> None:
30
+ self.screen = screen
31
+ from nexforge.core.context import get_project_root
32
+ self._mapper = ProjectMapper(get_project_root())
33
+ self._syncing = False # 防重入: 后台同步进行中不重复触发
34
+
35
+ def on_mount(self) -> None:
36
+ """首次启动自动生成地图,并注入总索引到会话(先传索引, AI 按需读详细文件)。
37
+
38
+ 关键: sync() 必须走后台线程。它要遍历项目树 + AST 解析全部模块,
39
+ 超大项目(如前端工程 node_modules 数十万文件)同步执行会冻结事件循环,
40
+ 表现为"卡在启动黑屏"(实测某项目单次遍历 >120s)。
41
+ 故先用已有地图注入(保证 AI 立即可用), 同步完成后再刷新注入。
42
+ """
43
+ s = self.screen
44
+ self._inject_index()
45
+ s._spawn(self._sync_and_inject())
46
+
47
+ def _inject_index(self) -> None:
48
+ """注入当前地图总索引(inject_system 按 marker 去重, 重复调用即替换)。"""
49
+ s = self.screen
50
+ idx = self._mapper.index_content()
51
+ content = _MAP_PROMPT if not idx else _MAP_MARKER + "\n" + idx
52
+ inject_system(s.session.messages, content, _MAP_MARKER)
53
+
54
+ async def _sync_and_inject(self) -> None:
55
+ """后台线程同步地图, 完成后回主线程刷新注入与提示。"""
56
+ import asyncio
57
+ if self._syncing:
58
+ return # 已有同步在跑(避免并发写地图文件)
59
+ self._syncing = True
60
+ try:
61
+ await asyncio.to_thread(self._mapper.sync)
62
+ except Exception:
63
+ return # 地图同步失败不影响主流程
64
+ finally:
65
+ self._syncing = False
66
+ self._inject_index()
67
+ self.screen._note(t("note.map_generated"), scroll=True)
68
+
69
+ def on_turn_done(self, content: str) -> None:
70
+ """每轮结束快速哈希比对,有变化则自动同步(后台线程, 不阻塞回合)。"""
71
+ if self._syncing:
72
+ return # 上一轮同步未完成, 跳过本轮(变更下轮再比对)
73
+ self._syncing = True
74
+ self.screen._spawn(self._check_and_sync())
75
+
76
+ async def _check_and_sync(self) -> None:
77
+ import asyncio
78
+ try:
79
+ report = await asyncio.to_thread(self._mapper.check)
80
+ if report["in_sync"]:
81
+ return
82
+ n = len(report["changed"]) + len(report["added"]) + len(report["removed"])
83
+ await asyncio.to_thread(self._mapper.sync)
84
+ self.screen._note(t("note.map_autosynced", n=str(n)), scroll=True)
85
+ except Exception:
86
+ pass # 自动同步失败不影响主流程
87
+ finally:
88
+ self._syncing = False
89
+
90
+ def bindings(self) -> list[tuple[str, str, str]]:
91
+ return []
@@ -106,9 +106,22 @@ class DeepSeekProvider(BaseProvider):
106
106
  return self._resolve_model(self.config.models.default_tier)
107
107
 
108
108
  def supports_model_multimodal(self, model: str = "") -> bool:
109
- """视觉模型(模型名含 vision)支持图片输入。"""
109
+ """该模型是否支持图片输入(原生多模态)
110
+
111
+ 判定顺序:
112
+ 1. 模型名含 "vision" → 支持(内置 vision 档位, 如 deepseek-v4-flash-vision-exp);
113
+ 2. models.toml 里该 provider 显式声明 ``multimodal = true`` → 支持。
114
+ 用于名字不含 vision 但实际能读图的模型(如 deepseek-v4.1 系列,
115
+ 实测: 对已知内容的图能准确回答圆/三角颜色与文字, 且不编造图中没有的元素);
116
+ 3. 否则不支持——纯文本模型(如 deepseek-v4-flash)收到图片会返回空。
117
+
118
+ 判为支持后, 用户贴图走 ``[图片附件: 路径]`` 通道直发达模型;
119
+ 判为不支持则降级走识图工具(image_vision)。
120
+ """
110
121
  m = (model or self.current_model).lower()
111
- return "vision" in m
122
+ if "vision" in m:
123
+ return True
124
+ return self._pdef_declares_multimodal(m)
112
125
 
113
126
  def _expand_image_attachments(
114
127
  self, messages: list[dict], model: str | None
@@ -90,8 +90,14 @@ class OpenAIProvider(BaseProvider):
90
90
  return ""
91
91
 
92
92
  def supports_model_multimodal(self, model: str = "") -> bool:
93
- """按模型名判断图片输入能力(如 qwen3.8-max 支持, kimi-k3 不支持)。"""
93
+ """按模型名判断图片输入能力(如 qwen3.8-max 支持, kimi-k3 不支持)。
94
+
95
+ 显式声明(models.toml 的 ``multimodal = true``)优先于名字启发式——
96
+ 名字列表不可能穷举所有新模型。
97
+ """
94
98
  m = (model or self.current_model).lower()
99
+ if self._pdef_declares_multimodal(m):
100
+ return True
95
101
  if any(h in m for h in self._TEXT_HINTS):
96
102
  return False
97
103
  if any(h in m for h in self._MULTIMODAL_HINTS):
@@ -424,7 +424,10 @@ def list_sessions() -> list[SessionInfo]:
424
424
  def _read_title(path: Path) -> str | None:
425
425
  from nexforge.tools._paths import sanitize
426
426
  try:
427
- first = path.read_text(encoding="utf-8").split("\n", 1)[0]
427
+ # 只读首行即可: 原实现 read_text() 会把整个会话文件读进内存,
428
+ # 会话多/大时(历史越长文件越大)纯属浪费。
429
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
430
+ first = fh.readline()
428
431
  title = first.lstrip("# ").strip()
429
432
  return sanitize(title) or None
430
433
  except Exception:
@@ -433,8 +436,13 @@ def _read_title(path: Path) -> str | None:
433
436
 
434
437
  def _count_messages(path: Path) -> int:
435
438
  try:
436
- text = path.read_text(encoding="utf-8")
437
- return text.count("### ❯ 用户")
439
+ # 流式逐行统计(不整体载入): 大会话文件下内存友好
440
+ n = 0
441
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
442
+ for line in fh:
443
+ if "### ❯ 用户" in line:
444
+ n += 1
445
+ return n
438
446
  except Exception:
439
447
  return 0
440
448
 
@@ -51,8 +51,15 @@ def _provider_vision_channel(image_b64: str, mime: str, question: str) -> tuple[
51
51
  from nexforge.provider import create_provider
52
52
 
53
53
  cfg = load_config()
54
- # 视觉模型名(如 deepseek-v4-flash-vision-exp)
55
- model = cfg.models.vision
54
+ # 模型选择: 优先当前 provider 声明的多模态模型(如 deepseek-v4.1),
55
+ # 否则回退内置 vision 档位模型(deepseek-v4-flash-vision-exp)。
56
+ # 两者都走该 provider 的 base_url/key, 保持一致。
57
+ cur_pdef = cfg.providers.get(cfg.provider.default)
58
+ model = ""
59
+ if cur_pdef is not None and getattr(cur_pdef, "multimodal", False):
60
+ model = cur_pdef.default_model or (
61
+ cur_pdef.models[0] if cur_pdef.models else "")
62
+ model = model or cfg.models.vision
56
63
  if not model:
57
64
  return False, "", ""
58
65
  provider = create_provider(cfg)
@@ -0,0 +1,105 @@
1
+ """项目地图遍历剪枝测试。
2
+
3
+ 背景(启动黑屏根因): 原实现用 ``Path.rglob("*.py")`` 收集待分析文件,
4
+ 目录过滤发生在遍历**之后**——rglob 必须先走进每个子目录(含 node_modules/
5
+ .git/.venv 等噪音目录)才能产出结果。超大项目单次遍历实测 >300s
6
+ (同一项目剪枝遍历仅 0.17s), 而该遍历同步发生在启动的 on_mount 里,
7
+ 直接冻结事件循环 → 表现为"卡在启动黑屏"。
8
+
9
+ 修复: 改用 os.walk(topdown=True) 原地改写 dirnames 剪枝, 排除目录的
10
+ 子树完全不进入。本文件锁定该行为, 防止回归成 rglob。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from nexforge.core.project_mapper import ProjectMapper, _iter_files_pruned
17
+
18
+
19
+ def _mk(p: Path, text: str = "x = 1\n") -> None:
20
+ p.parent.mkdir(parents=True, exist_ok=True)
21
+ p.write_text(text, encoding="utf-8")
22
+
23
+
24
+ class TestPrunedIter:
25
+ def test_skips_noisy_dirs(self, tmp_path: Path) -> None:
26
+ _mk(tmp_path / "app" / "main.py")
27
+ _mk(tmp_path / "node_modules" / "left-pad" / "index.py")
28
+ _mk(tmp_path / ".git" / "hooks" / "hook.py")
29
+ _mk(tmp_path / "__pycache__" / "cached.py")
30
+ _mk(tmp_path / ".venv" / "lib" / "vendored.py")
31
+
32
+ got = {p.name for p in _iter_files_pruned(tmp_path, ".py")}
33
+ assert got == {"main.py"}
34
+
35
+ def test_prune_at_any_depth(self, tmp_path: Path) -> None:
36
+ """关键: 嵌套深处的排除目录也不下钻(而非只剪顶层)。"""
37
+ _mk(tmp_path / "packages" / "web" / "node_modules" / "x" / "y.py")
38
+ _mk(tmp_path / "packages" / "web" / "src" / "ok.py")
39
+
40
+ got = {p.name for p in _iter_files_pruned(tmp_path, ".py")}
41
+ assert got == {"ok.py"}
42
+
43
+ def test_suffix_filter(self, tmp_path: Path) -> None:
44
+ _mk(tmp_path / "a.py")
45
+ _mk(tmp_path / "b.md")
46
+ assert {p.name for p in _iter_files_pruned(tmp_path, ".md")} == {"b.md"}
47
+
48
+ def test_empty_suffix_returns_all_files(self, tmp_path: Path) -> None:
49
+ _mk(tmp_path / "a.py")
50
+ _mk(tmp_path / "b.md")
51
+ assert {p.name for p in _iter_files_pruned(tmp_path)} == {"a.py", "b.md"}
52
+
53
+ def test_missing_dir_returns_empty(self, tmp_path: Path) -> None:
54
+ assert _iter_files_pruned(tmp_path / "nope") == []
55
+
56
+
57
+ class TestWalk:
58
+ def test_uses_pruning_and_filters(self, tmp_path: Path) -> None:
59
+ _mk(tmp_path / "src" / "nexforge" / "core" / "agent.py")
60
+ _mk(tmp_path / "src" / "nexforge" / "__init__.py")
61
+ _mk(tmp_path / "src" / "nexforge" / "core" / "_private.py")
62
+ _mk(tmp_path / "src" / "nexforge" / "tests" / "test_x.py")
63
+ _mk(tmp_path / "node_modules" / "pkg" / "mod.py")
64
+
65
+ names = {p.name for p in ProjectMapper(tmp_path)._walk()}
66
+ # 私有模块(非 __init__/__main__)与 test_*.py 被过滤; 噪音目录不下钻
67
+ assert names == {"agent.py", "__init__.py"}
68
+
69
+ def test_falls_back_to_root_without_src_nexforge(self, tmp_path: Path) -> None:
70
+ """无 src/nexforge 时退化为项目根——大项目正是因此扫描整树。"""
71
+ _mk(tmp_path / "pkg" / "a.py")
72
+ _mk(tmp_path / "node_modules" / "b.py")
73
+ _mk(tmp_path / ".git" / "c.py")
74
+
75
+ names = {p.name for p in ProjectMapper(tmp_path)._walk()}
76
+ assert names == {"a.py"}
77
+
78
+ def test_respects_max_files_cap(self, tmp_path: Path, monkeypatch) -> None:
79
+ import nexforge.core.project_mapper as pm
80
+
81
+ monkeypatch.setattr(pm, "_MAX_FILES", 3)
82
+ for i in range(10):
83
+ _mk(tmp_path / "pkg" / f"m{i}.py")
84
+
85
+ assert len(ProjectMapper(tmp_path)._walk()) == 3
86
+
87
+
88
+ class TestMissingMapOutput:
89
+ """地图输出被删而哈希缓存残留时, 必须能自愈重建。"""
90
+
91
+ def test_check_requests_rebuild_when_map_deleted(self, tmp_path: Path) -> None:
92
+ import shutil
93
+
94
+ _mk(tmp_path / "pkg" / "a.py")
95
+ m = ProjectMapper(tmp_path)
96
+ m.sync()
97
+ assert m.check()["in_sync"] is True
98
+
99
+ # 只删地图输出, 保留 map_cache.json
100
+ shutil.rmtree(tmp_path / ".nexforge" / "map")
101
+ (tmp_path / ".nexforge" / "map.md").unlink()
102
+
103
+ assert m.check()["in_sync"] is False, "输出缺失时必须视为需重建"
104
+ m.sync()
105
+ assert m.index_content() != "", "重建后索引应可用"
@@ -0,0 +1,130 @@
1
+ """deepseek vision 模型接入测试: 档位/多模态判定/图片展开。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from nexforge.config import NexForgeConfig
8
+
9
+
10
+ def test_config_vision_tier():
11
+ """config: vision 档位模型名 + 价格。"""
12
+ cfg = NexForgeConfig()
13
+ assert cfg.models.vision == "deepseek-v4-flash-vision-exp"
14
+ assert cfg.model_id("vision") == "deepseek-v4-flash-vision-exp"
15
+ assert cfg.model_id("flash") != cfg.model_id("vision")
16
+ assert cfg.price("vision") is not None
17
+
18
+
19
+ def test_vision_multimodal_detection(tmp_path):
20
+ """provider: vision 模型判定为多模态, flash 不是。"""
21
+ from nexforge.provider.deepseek import DeepSeekProvider
22
+ cfg = NexForgeConfig()
23
+ # 用 _from_key 避免 resolve_api_key 失败
24
+ prov = DeepSeekProvider._from_key(cfg, "sk-test")
25
+ assert prov.supports_model_multimodal("deepseek-v4-flash-vision-exp") is True
26
+ assert prov.supports_model_multimodal("deepseek-v4-flash") is False
27
+ assert prov.supports_model_multimodal("deepseek-v4-pro") is False
28
+ assert prov.supports_model_multimodal("") is False # 默认 flash
29
+
30
+
31
+ def test_expand_image_attachments(tmp_path):
32
+ """图片附件文本展开为 base64 image_url(仅视觉模型)。"""
33
+ from nexforge.provider.deepseek import DeepSeekProvider
34
+ cfg = NexForgeConfig()
35
+ prov = DeepSeekProvider._from_key(cfg, "sk-test")
36
+ # 造一张假 png
37
+ img = tmp_path / "shot.png"
38
+ img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
39
+ msgs = [
40
+ {"role": "user", "content": f"看一下 [图片附件: {img}] 这是什么"},
41
+ ]
42
+ # vision 模型: 展开
43
+ expanded = prov._expand_image_attachments(msgs, "deepseek-v4-flash-vision-exp")
44
+ content = expanded[0]["content"]
45
+ assert isinstance(content, list), "应展开为 content 数组"
46
+ assert content[0]["type"] == "text"
47
+ assert content[1]["type"] == "image_url"
48
+ assert "data:image/png;base64," in content[1]["image_url"]["url"]
49
+ # 非 vision 模型: 保持原文
50
+ kept = prov._expand_image_attachments(msgs, "deepseek-v4-flash")
51
+ assert kept[0]["content"] == msgs[0]["content"]
52
+
53
+
54
+ def test_vision_stream_forces_thinking_off():
55
+ """vision 模型请求强制 thinking off。"""
56
+ from nexforge.provider.deepseek import DeepSeekProvider
57
+ cfg = NexForgeConfig()
58
+ prov = DeepSeekProvider._from_key(cfg, "sk-test")
59
+ # 直接验证 _build_kwargs 的 extra_body(thinking 显式传 False 时)
60
+ kwargs = prov._build_kwargs(
61
+ [{"role": "user", "content": "hi"}],
62
+ None, "vision", thinking=False, effort=None,
63
+ )
64
+ assert kwargs["model"] == "deepseek-v4-flash-vision-exp"
65
+ assert kwargs["extra_body"]["thinking"]["type"] == "disabled"
66
+
67
+
68
+ # ── 声明式多模态(models.toml 的 multimodal = true) ──────────────
69
+ # 背景: deepseek-v4.1 系列原生支持读图, 但模型名不含 "vision",
70
+ # 原判定(名字含 vision)会把它当纯文本模型 → 用户贴图被降级到识图工具通道。
71
+ # 实测: 该类模型对已知内容的图能准确回答元素颜色/文字, 且不编造不存在的元素。
72
+
73
+
74
+ def _prov_with_pdef(models: list[str], default_model: str, multimodal: bool):
75
+ from nexforge.config import ProviderDef
76
+ from nexforge.provider.deepseek import DeepSeekProvider
77
+ prov = DeepSeekProvider._from_key(NexForgeConfig(), "sk-test")
78
+ prov.pdef = ProviderDef(
79
+ type="deepseek", models=models,
80
+ default_model=default_model, multimodal=multimodal,
81
+ )
82
+ return prov
83
+
84
+
85
+ def test_providerdef_multimodal_defaults_false():
86
+ """未声明时默认非多模态(向后兼容, 不改变既有行为)。"""
87
+ from nexforge.config import ProviderDef
88
+ assert ProviderDef(type="deepseek").multimodal is False
89
+
90
+
91
+ def test_declared_multimodal_model_detected():
92
+ model = "deepseek-v4.1-flash-expires-on-0910"
93
+ prov = _prov_with_pdef([model], model, multimodal=True)
94
+ assert prov.supports_model_multimodal(model) is True
95
+ assert prov.supports_model_multimodal("") is True # 默认模型即它
96
+
97
+
98
+ def test_declared_multimodal_not_leaking_to_other_models():
99
+ """声明只覆盖该 provider 挂载的模型, 不能把纯文本模型也判成多模态。"""
100
+ model = "deepseek-v4.1-flash-expires-on-0910"
101
+ prov = _prov_with_pdef([model], model, multimodal=True)
102
+ assert prov.supports_model_multimodal("deepseek-v4-flash") is False
103
+ assert prov.supports_model_multimodal("deepseek-v4-pro") is False
104
+
105
+
106
+ def test_declared_multimodal_disabled_keeps_text_behaviour():
107
+ """multimodal=false 时即使名字像新模型也不放行。"""
108
+ model = "deepseek-v4.1-flash-expires-on-0910"
109
+ prov = _prov_with_pdef([model], model, multimodal=False)
110
+ assert prov.supports_model_multimodal(model) is False
111
+
112
+
113
+ def test_declared_multimodal_expands_image_attachments(tmp_path):
114
+ """端到端: 声明为多模态后, [图片附件:] 才真正展开为 image_url。"""
115
+ model = "deepseek-v4.1-flash-expires-on-0910"
116
+ prov = _prov_with_pdef([model], model, multimodal=True)
117
+ img = tmp_path / "shot.png"
118
+ img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
119
+ msgs = [{"role": "user", "content": f"看图 [图片附件: {img}] 有什么"}]
120
+
121
+ expanded = prov._expand_image_attachments(msgs, model)
122
+ content = expanded[0]["content"]
123
+ assert isinstance(content, list), "声明多模态后应展开为 content 数组"
124
+ assert content[1]["type"] == "image_url"
125
+ assert "data:image/png;base64," in content[1]["image_url"]["url"]
126
+
127
+ # 未声明多模态的 provider → 保持原文(走识图工具通道)
128
+ prov2 = _prov_with_pdef([model], model, multimodal=False)
129
+ assert prov2._expand_image_attachments(msgs, model)[0]["content"] == msgs[0]["content"]
130
+