guantao пре 3 дана
родитељ
комит
d6f28ffde0
38 измењених фајлова са 2724 додато и 730 уклоњено
  1. 6 1
      .gitignore
  2. 47 0
      data/groups.json
  3. 300 261
      data/registry.json
  4. 91 13
      data/sources.json
  5. 5 2
      pyproject.toml
  6. 11 4
      src/tool_agent/router/dispatcher.py
  7. 4 1
      src/tool_agent/router/server.py
  8. 10 0
      src/tool_agent/router/status.py
  9. BIN
      tests/output/nano_banana_result.png
  10. 177 0
      tests/test_flux.py
  11. 153 0
      tests/test_jimeng.py
  12. 231 0
      tests/test_midjourney.py
  13. 144 0
      tests/test_nano_banana.py
  14. 246 35
      tests/test_router_api.py
  15. 7 0
      tools/local/flux/.env.example
  16. 3 0
      tools/local/flux/.gitignore
  17. 95 0
      tools/local/flux/bfl_client.py
  18. 87 0
      tools/local/flux/main.py
  19. 12 0
      tools/local/flux/pyproject.toml
  20. 12 0
      tools/local/ji_meng/.env.example
  21. 7 0
      tools/local/ji_meng/.gitignore
  22. 1 0
      tools/local/ji_meng/.python-version
  23. 49 0
      tools/local/ji_meng/ji_meng_client.py
  24. 71 0
      tools/local/ji_meng/main.py
  25. 12 0
      tools/local/ji_meng/pyproject.toml
  26. 2 0
      tools/local/midjourney/.env.example
  27. 3 0
      tools/local/midjourney/.gitignore
  28. 99 0
      tools/local/midjourney/main.py
  29. 46 0
      tools/local/midjourney/midjourney_client.py
  30. 12 0
      tools/local/midjourney/pyproject.toml
  31. 8 0
      tools/local/nano_banana/.env.example
  32. 4 0
      tools/local/nano_banana/.gitignore
  33. 1 0
      tools/local/nano_banana/.python-version
  34. 149 0
      tools/local/nano_banana/gemini_image_client.py
  35. 93 0
      tools/local/nano_banana/main.py
  36. 12 0
      tools/local/nano_banana/pyproject.toml
  37. 6 0
      tools/local/nano_banana/tests/server.log
  38. 508 413
      uv.lock

+ 6 - 1
.gitignore

@@ -8,4 +8,9 @@ __pycache__/
 .venv/
 .venv/
 env/
 env/
 venv/
 venv/
-.env
+.env
+
+.idea/
+.vscode/
+.cursor/
+.trae/

+ 47 - 0
data/groups.json

@@ -16,6 +16,53 @@
         "runcomfy_stop_env"
         "runcomfy_stop_env"
       ],
       ],
       "usage_example": "1. 使用 launch_comfy_env 启动云端环境,获取 server_id\n2. 使用 runcomfy_workflow_executor 在该环境上执行工作流,传入 server_id\n3. 使用 runcomfy_stop_env 停止环境释放资源,传入 server_id"
       "usage_example": "1. 使用 launch_comfy_env 启动云端环境,获取 server_id\n2. 使用 runcomfy_workflow_executor 在该环境上执行工作流,传入 server_id\n3. 使用 runcomfy_stop_env 停止环境释放资源,传入 server_id"
+    },
+    {
+      "group_id": "ji_meng_task_lifecycle",
+      "name": "即梦任务(创建与查询)",
+      "description": "先创建任务获取 task_id,再查询结果",
+      "category": "local",
+      "tool_ids": [
+        "ji_meng_add_task",
+        "ji_meng_query_task"
+      ],
+      "usage_order": [
+        "ji_meng_add_task",
+        "ji_meng_query_task"
+      ],
+      "usage_example": "1. 调用 ji_meng_add_task 传入 prompt,得到 task_id\n2. 轮询 ji_meng_query_task 传入 task_id,直到完成"
+    },
+    {
+      "group_id": "flux_bfl_lifecycle",
+      "name": "FLUX(BFL 提交与轮询)",
+      "description": "先提交生图拿到 id 与 polling_url,再轮询直至 Ready",
+      "category": "remote",
+      "tool_ids": [
+        "flux_submit",
+        "flux_query"
+      ],
+      "usage_order": [
+        "flux_submit",
+        "flux_query"
+      ],
+      "usage_example": "1. flux_submit 传入 model(如 flux-2-pro-preview)、prompt,得到 id 与 polling_url\n2. 反复调用 flux_query 传入 polling_url、request_id(即 id),直到 status 为 Ready,从 result.sample 取图(签名 URL 约 10 分钟有效)"
+    },
+    {
+      "group_id": "midjourney_lifecycle",
+      "name": "Midjourney(提交、查状态、取图)",
+      "description": "先提交任务,轮询状态完成后获取四张图链接",
+      "category": "remote",
+      "tool_ids": [
+        "midjourney_submit_job",
+        "midjourney_query_job_status",
+        "midjourney_get_image_urls"
+      ],
+      "usage_order": [
+        "midjourney_submit_job",
+        "midjourney_query_job_status",
+        "midjourney_get_image_urls"
+      ],
+      "usage_example": "1. midjourney_submit_job:cookie、prompt、user_id、mode(relaxed|fast) 得 job_id\n2. 轮询 midjourney_query_job_status:cookie、job_id 直至完成\n3. midjourney_get_image_urls:job_id 取四张图 URL"
     }
     }
   ],
   ],
   "version": "1.0"
   "version": "1.0"

+ 300 - 261
data/registry.json

@@ -382,360 +382,399 @@
       ]
       ]
     },
     },
     {
     {
-      "tool_id": "kuaishou_kling",
-      "name": "快手可灵AI生成工具",
-      "category": "ai_generation",
-      "description": "支持AI视频生成、AI图片生成、AI对口型等功能的统一接口。可以通过文本或图片生成视频,生成多张AI图片,以及为视频添加对口型效果。",
+      "tool_id": "ji_meng_add_task",
+      "name": "即梦-创建任务",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "提交异步任务到上游(本地服务转发 JI_MENG_API_BASE,POST /add_task)。需配置 tools/local/ji_meng/.env。",
       "input_schema": {
       "input_schema": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
-          "biz_type": {
+          "task_type": {
             "type": "string",
             "type": "string",
             "enum": [
             "enum": [
-              "aiImage",
-              "aiVideo",
-              "aiLipSync"
+              "image",
+              "video"
             ],
             ],
-            "description": "业务类型"
-          },
-          "action": {
-            "type": "string",
-            "description": "动作类型"
+            "description": "任务类型:图生/文生图或视频"
           },
           },
           "prompt": {
           "prompt": {
             "type": "string",
             "type": "string",
-            "description": "生成内容的提示词"
+            "description": "任务描述 / 提示词"
           },
           },
-          "negative_prompt": {
+          "image_url": {
             "type": "string",
             "type": "string",
-            "description": "不希望呈现的内容"
-          },
-          "cfg": {
+            "description": "可选,图生类任务时的参考图 URL"
+          }
+        },
+        "required": [
+          "task_type",
+          "prompt"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "task_id": {
             "type": "string",
             "type": "string",
-            "default": "50",
-            "description": "创意想象力与创意相关性比例"
+            "description": "任务 ID,用于 ji_meng_query_task"
           },
           },
-          "mode": {
+          "status": {
+            "type": "string"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "ji_meng_task_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "ji_meng_query_task",
+      "name": "即梦-查询任务",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "按 task_id 查询任务状态与结果(本地服务转发上游,POST /query_task)。",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "task_id": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "text2video",
-              "audio2video"
-            ],
-            "description": "生成模式"
+            "description": "ji_meng_add_task 返回的任务 ID"
+          }
+        },
+        "required": [
+          "task_id"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "task_id": {
+            "type": "string"
           },
           },
-          "image_url": {
-            "type": "string",
-            "description": "参考图片地址"
+          "status": {
+            "type": "string"
           },
           },
-          "aspect_ratio": {
-            "type": "string",
-            "enum": [
-              "9:16",
-              "16:9",
-              "1:1"
-            ],
-            "default": "16:9",
-            "description": "长宽比"
+          "message": {
+            "type": "string"
           },
           },
-          "task_id": {
+          "images": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "ji_meng_task_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "nano_banana",
+      "name": "Nano Banana(Gemini 图模)",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "通过 Google Gemini 原生图模 REST generateContent 文生图/图生图。需 GEMINI_API_KEY;可选 gemini-2.5-flash-image、gemini-3.1-flash-image-preview 等。详见 https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn#rest",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "prompt": {
             "type": "string",
             "type": "string",
-            "description": "查询任务状态时使用"
+            "description": "提示词(文生图或与参考图配合做编辑)"
           },
           },
-          "cookie": {
+          "model": {
             "type": "string",
             "type": "string",
-            "description": "认证Cookie"
+            "description": "模型 ID;省略则使用环境变量 GEMINI_IMAGE_MODEL,默认 gemini-2.5-flash-image。示例:gemini-3.1-flash-image-preview"
           },
           },
-          "version": {
+          "aspect_ratio": {
             "type": "string",
             "type": "string",
-            "description": "模型版本"
+            "description": "输出宽高比,如 1:1、16:9(对应 generationConfig.imageConfig.aspectRatio)"
           },
           },
-          "image_count": {
-            "type": "integer",
-            "default": 4,
-            "description": "生成图片数量(1-4)"
-          },
-          "add_audio": {
-            "type": "boolean",
-            "default": false,
-            "description": "是否自动添加音频"
-          },
-          "start_frame_image": {
+          "image_size": {
             "type": "string",
             "type": "string",
-            "description": "首帧图片URL"
+            "description": "Gemini 3.x 输出规格:512、1K、2K、4K(须大写 K,见官方文档)"
           },
           },
-          "end_frame_image": {
-            "type": "string",
-            "description": "尾帧图片URL"
+          "response_modalities": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            },
+            "description": "如 [\"TEXT\",\"IMAGE\"] 或 [\"IMAGE\"];省略则由 API 默认"
           },
           },
-          "video_id": {
-            "type": "string",
-            "description": "视频ID(对口型用)"
+          "images": {
+            "type": "array",
+            "description": "可选参考图,每项为 {mime_type, data},data 为 Base64 或 data URL",
+            "items": {
+              "type": "object",
+              "properties": {
+                "mime_type": {
+                  "type": "string"
+                },
+                "data": {
+                  "type": "string"
+                }
+              },
+              "required": [
+                "data"
+              ]
+            }
+          }
+        },
+        "required": [
+          "prompt"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "images": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            },
+            "description": "data:mime;base64,... 列表"
           },
           },
-          "video_url": {
+          "model": {
             "type": "string",
             "type": "string",
-            "description": "视频URL(对口型用)"
+            "description": "实际调用的模型 ID"
           },
           },
           "text": {
           "text": {
             "type": "string",
             "type": "string",
-            "description": "对口型文本内容"
-          },
-          "voice_id": {
+            "description": "若返回文本部分则在此汇总"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": []
+    },
+    {
+      "tool_id": "flux_submit",
+      "name": "FLUX-提交生图任务",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "向 BFL 提交异步生图任务(POST /v1/{model})。需 BFL_API_KEY;model 为端点名如 flux-2-pro-preview。返回 id、polling_url 供 flux_query 轮询。文档 https://docs.bfl.ai/quick_start/generating_images",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "model": {
             "type": "string",
             "type": "string",
-            "description": "音色ID"
+            "description": "端点路径段,如 flux-2-pro-preview、flux-2-max、flux-dev、flux-kontext-pro 等"
           },
           },
-          "voice_language": {
+          "prompt": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "zh",
-              "en"
-            ],
-            "default": "zh",
-            "description": "音色语种"
+            "description": "提示词"
           },
           },
-          "voice_speed": {
-            "type": "number",
-            "default": 1,
-            "description": "语速"
+          "width": {
+            "type": "integer",
+            "description": "输出宽度(像素),可选"
           },
           },
-          "audio_type": {
+          "height": {
+            "type": "integer",
+            "description": "输出高度(像素),可选"
+          },
+          "parameters": {
+            "type": "object",
+            "description": "合并进 BFL 请求体的额外字段(模型专有参数)"
+          }
+        },
+        "required": [
+          "model",
+          "prompt"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "properties": {
+          "id": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "file",
-              "url"
-            ],
-            "description": "音频类型"
+            "description": "请求 ID,flux_query 的 request_id"
           },
           },
-          "audio_file": {
+          "polling_url": {
             "type": "string",
             "type": "string",
-            "description": "音频文件路径"
+            "description": "轮询地址,须原样传给 flux_query(全球端点必须用返回的 URL)"
+          }
+        }
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "flux_bfl_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "flux_query",
+      "name": "FLUX-查询任务结果",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "按 flux_submit 返回的 polling_url 与 id 轮询状态;Ready 时 result.sample 为签名图 URL(约 10 分钟有效)。",
+      "input_schema": {
+        "type": "object",
+        "properties": {
+          "polling_url": {
+            "type": "string",
+            "description": "提交响应中的 polling_url"
           },
           },
-          "audio_url": {
+          "request_id": {
             "type": "string",
             "type": "string",
-            "description": "音频URL"
+            "description": "提交响应中的 id"
           }
           }
         },
         },
         "required": [
         "required": [
-          "biz_type"
+          "polling_url",
+          "request_id"
         ]
         ]
       },
       },
       "output_schema": {
       "output_schema": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
-          "task_id": {
-            "type": "string",
-            "description": "任务ID"
-          },
           "status": {
           "status": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "process",
-              "finished",
-              "failed"
-            ],
-            "description": "任务状态"
+            "description": "如 Ready、Pending、Error、Failed 等"
           },
           },
           "result": {
           "result": {
             "type": "object",
             "type": "object",
-            "description": "生成结果",
-            "properties": {
-              "images": {
-                "type": "array",
-                "items": {
-                  "type": "string"
-                },
-                "description": "图片URL列表"
-              },
-              "videos": {
-                "type": "array",
-                "items": {
-                  "type": "string"
-                },
-                "description": "视频URL列表"
-              }
-            }
-          },
-          "error": {
-            "type": "string",
-            "description": "错误信息"
+            "description": "成功时常含 sample(图片签名 URL)"
           }
           }
         }
         }
       },
       },
       "stream_support": false,
       "stream_support": false,
       "status": "active",
       "status": "active",
       "backend_runtime": "local",
       "backend_runtime": "local",
-      "group_ids": [],
-      "tool_slug_ids": []
+      "group_ids": [
+        "flux_bfl_lifecycle"
+      ]
     },
     },
     {
     {
-      "tool_id": "jimeng_ai",
-      "name": "Jimeng AI Generator",
-      "category": "ai",
-      "description": "AI generation tool supporting text-to-image (Seendance 2.0) and image-to-video (Seedream Lite 5.0)",
+      "tool_id": "midjourney_submit_job",
+      "name": "Midjourney-提交生图任务",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "提交 Midjourney 生图任务(转发至 MIDJOURNEY_API_BASE/submit_job)。需配置 tools/local/midjourney/.env。mode 为 relaxed 或 fast。",
       "input_schema": {
       "input_schema": {
         "type": "object",
         "type": "object",
-        "required": [
-          "action"
-        ],
         "properties": {
         "properties": {
-          "action": {
+          "cookie": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "text2image",
-              "image2video",
-              "query_status"
-            ],
-            "description": "Operation type: text2image, image2video, or query_status"
+            "description": "Midjourney 会话 cookie"
           },
           },
           "prompt": {
           "prompt": {
             "type": "string",
             "type": "string",
-            "description": "Positive prompt describing desired content"
-          },
-          "negative_prompt": {
-            "type": "string",
-            "description": "Negative prompt for unwanted content"
+            "description": "提示词"
           },
           },
-          "model": {
+          "user_id": {
             "type": "string",
             "type": "string",
-            "enum": [
-              "seendance_2.0",
-              "seedream_lite_5.0"
-            ],
-            "description": "Model selection"
+            "description": "用户 ID"
           },
           },
-          "aspect_ratio": {
-            "type": "string",
-            "enum": [
-              "1:1",
-              "16:9",
-              "9:16",
-              "4:3",
-              "3:4"
-            ],
-            "description": "Image aspect ratio"
-          },
-          "image_count": {
-            "type": "integer",
-            "minimum": 1,
-            "maximum": 4,
-            "description": "Number of images to generate"
-          },
-          "cfg_scale": {
-            "type": "number",
-            "minimum": 1,
-            "maximum": 20,
-            "description": "Creativity strength"
-          },
-          "steps": {
-            "type": "integer",
-            "minimum": 10,
-            "maximum": 50,
-            "description": "Generation steps"
-          },
-          "seed": {
-            "type": "integer",
-            "description": "Random seed for reproducibility"
-          },
-          "image_url": {
-            "type": "string",
-            "description": "Reference image URL (for image2video)"
-          },
-          "image_base64": {
+          "mode": {
             "type": "string",
             "type": "string",
-            "description": "Reference image Base64 (alternative to image_url)"
-          },
-          "video_duration": {
-            "type": "integer",
             "enum": [
             "enum": [
-              3,
-              5,
-              10
+              "relaxed",
+              "fast"
             ],
             ],
-            "description": "Video duration in seconds"
-          },
-          "motion_strength": {
-            "type": "number",
-            "minimum": 0,
-            "maximum": 1,
-            "description": "Motion strength (0=static, 1=maximum)"
-          },
-          "task_id": {
-            "type": "string",
-            "description": "Task ID for status query"
-          },
+            "description": "队列模式:relaxed 或 fast"
+          }
+        },
+        "required": [
+          "cookie",
+          "prompt",
+          "user_id",
+          "mode"
+        ]
+      },
+      "output_schema": {
+        "type": "object",
+        "description": "上游返回 JSON,通常含 job_id 或 id",
+        "properties": {}
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "midjourney_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "midjourney_query_job_status",
+      "name": "Midjourney-查询任务状态",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "查询指定任务状态(转发 MIDJOURNEY_API_BASE/query_job_status)。",
+      "input_schema": {
+        "type": "object",
+        "properties": {
           "cookie": {
           "cookie": {
             "type": "string",
             "type": "string",
-            "description": "Authentication cookie"
+            "description": "Midjourney 会话 cookie"
           },
           },
-          "api_key": {
+          "job_id": {
             "type": "string",
             "type": "string",
-            "description": "API key"
+            "description": "submit_job 返回的任务 ID"
           }
           }
-        }
+        },
+        "required": [
+          "cookie",
+          "job_id"
+        ]
       },
       },
       "output_schema": {
       "output_schema": {
+        "type": "object",
+        "description": "上游状态 JSON,如 status / job_status 等",
+        "properties": {}
+      },
+      "stream_support": false,
+      "status": "active",
+      "backend_runtime": "local",
+      "group_ids": [
+        "midjourney_lifecycle"
+      ]
+    },
+    {
+      "tool_id": "midjourney_get_image_urls",
+      "name": "Midjourney-获取结果图链接",
+      "tool_slug_ids": [],
+      "category": "cv",
+      "description": "根据 job_id 获取 4 张图 URL(转发 MIDJOURNEY_API_BASE/get_image_urls)。",
+      "input_schema": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
-          "task_id": {
+          "job_id": {
             "type": "string",
             "type": "string",
-            "description": "Unique task identifier"
-          },
-          "status": {
-            "type": "string",
-            "enum": [
-              "pending",
-              "processing",
-              "completed",
-              "failed"
-            ],
-            "description": "Task status"
-          },
-          "progress": {
-            "type": "number",
-            "description": "Progress percentage (0-100)"
-          },
-          "result": {
-            "type": "object",
-            "description": "Generation results",
-            "properties": {
-              "images": {
-                "type": "array",
-                "items": {
-                  "type": "string"
-                },
-                "description": "Generated image URLs"
-              },
-              "videos": {
-                "type": "array",
-                "items": {
-                  "type": "string"
-                },
-                "description": "Generated video URLs"
-              },
-              "metadata": {
-                "type": "object",
-                "description": "Generation metadata"
-              }
-            }
-          },
-          "error": {
-            "type": "string",
-            "description": "Error message if failed"
-          },
-          "estimated_time": {
-            "type": "integer",
-            "description": "Estimated completion time in seconds"
+            "description": "任务 ID"
           }
           }
         },
         },
         "required": [
         "required": [
-          "task_id",
-          "status"
+          "job_id"
         ]
         ]
       },
       },
+      "output_schema": {
+        "type": "object",
+        "description": "上游返回 JSON 或 URL 数组,常见字段 image_urls / urls",
+        "properties": {
+          "image_urls": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            },
+            "description": "四张图片链接(字段名以实际服务为准)"
+          }
+        }
+      },
       "stream_support": false,
       "stream_support": false,
       "status": "active",
       "status": "active",
       "backend_runtime": "local",
       "backend_runtime": "local",
-      "group_ids": [],
-      "tool_slug_ids": []
+      "group_ids": [
+        "midjourney_lifecycle"
+      ]
     }
     }
   ],
   ],
   "version": "2.0"
   "version": "2.0"

+ 91 - 13
data/sources.json

@@ -70,38 +70,116 @@
         "internal_port": 8000
         "internal_port": 8000
       }
       }
     ],
     ],
-    "kuaishou_kling": [
+    "ji_meng_add_task": [
       {
       {
         "type": "local",
         "type": "local",
-        "host_dir": "C:\\Users\\11304\\gitlab\\cybertogether\\tool_agent\\tools\\local\\kuaishou_kling",
+        "host_dir": "tools/local/ji_meng",
         "container_id": "",
         "container_id": "",
         "image": "",
         "image": "",
-        "remote_url": "",
-        "remote_path": "",
-        "remote_api_key": "",
         "hub_url": "",
         "hub_url": "",
         "hub_tool_path": "",
         "hub_tool_path": "",
         "hub_api_key": "",
         "hub_api_key": "",
-        "endpoint_path": "/generate",
+        "endpoint_path": "/add_task",
         "http_method": "POST",
         "http_method": "POST",
-        "internal_port": 8000
+        "internal_port": 0
+      }
+    ],
+    "ji_meng_query_task": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/ji_meng",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/query_task",
+        "http_method": "POST",
+        "internal_port": 0
       }
       }
     ],
     ],
-    "jimeng_ai": [
+    "nano_banana": [
       {
       {
         "type": "local",
         "type": "local",
-        "host_dir": "C:\\Users\\11304\\gitlab\\cybertogether\\tool_agent\\tools\\local\\jimeng_ai",
+        "host_dir": "tools/local/nano_banana",
         "container_id": "",
         "container_id": "",
         "image": "",
         "image": "",
-        "remote_url": "",
-        "remote_path": "",
-        "remote_api_key": "",
         "hub_url": "",
         "hub_url": "",
         "hub_tool_path": "",
         "hub_tool_path": "",
         "hub_api_key": "",
         "hub_api_key": "",
         "endpoint_path": "/generate",
         "endpoint_path": "/generate",
         "http_method": "POST",
         "http_method": "POST",
-        "internal_port": 8000
+        "internal_port": 0
+      }
+    ],
+    "flux_submit": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/flux",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/submit",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "flux_query": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/flux",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/query",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "midjourney_submit_job": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/midjourney",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/submit_job",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "midjourney_query_job_status": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/midjourney",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/query_job_status",
+        "http_method": "POST",
+        "internal_port": 0
+      }
+    ],
+    "midjourney_get_image_urls": [
+      {
+        "type": "local",
+        "host_dir": "tools/local/midjourney",
+        "container_id": "",
+        "image": "",
+        "hub_url": "",
+        "hub_tool_path": "",
+        "hub_api_key": "",
+        "endpoint_path": "/get_image_urls",
+        "http_method": "POST",
+        "internal_port": 0
       }
       }
     ]
     ]
   }
   }

+ 5 - 2
pyproject.toml

@@ -13,6 +13,7 @@ dependencies = [
     "psutil>=6.0.0",
     "psutil>=6.0.0",
     "claude-agent-sdk",
     "claude-agent-sdk",
     "python-dotenv>=1.0.0",
     "python-dotenv>=1.0.0",
+    "filelock>=3.25.2",
 ]
 ]
 
 
 [project.optional-dependencies]
 [project.optional-dependencies]
@@ -40,6 +41,8 @@ members = [
     "tools/local/run_comfy_workflow",
     "tools/local/run_comfy_workflow",
     "tools/local/task_0cd69d84",
     "tools/local/task_0cd69d84",
     "tools/local/runcomfy_stop_env",
     "tools/local/runcomfy_stop_env",
-    "tools/local/kuaishou_kling",
-    "tools/local/jimeng_ai",
+    "tools/local/ji_meng",
+    "tools/local/nano_banana",
+    "tools/local/flux",
+    "tools/local/midjourney",
 ]
 ]

+ 11 - 4
src/tool_agent/router/dispatcher.py

@@ -2,6 +2,7 @@
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import logging
 import logging
 from typing import Any, TYPE_CHECKING
 from typing import Any, TYPE_CHECKING
 
 
@@ -20,11 +21,17 @@ class Dispatcher:
         self._status_manager = status_manager
         self._status_manager = status_manager
 
 
     async def dispatch(self, tool_id: str, params: dict[str, Any], stream: bool = False) -> dict[str, Any]:
     async def dispatch(self, tool_id: str, params: dict[str, Any], stream: bool = False) -> dict[str, Any]:
-        """分发调用请求到工具的活跃端点"""
-        # 1. 获取端点信息
-        endpoint = self._status_manager.get_active_endpoint(tool_id)
+        """分发调用请求到工具的活跃端点;无可用端点时先尝试启动(本地 uv 进程等)。"""
+        sm = self._status_manager
+        endpoint = sm.get_active_endpoint(tool_id)
         if not endpoint:
         if not endpoint:
-            return {"status": "error", "error": f"Tool '{tool_id}' is not running or has no active endpoint"}
+            await asyncio.to_thread(sm.start_tool, tool_id)
+            endpoint = sm.get_active_endpoint(tool_id)
+        if not endpoint:
+            route = sm.get_status(tool_id)
+            err = (route.last_error or "").strip() if route else ""
+            msg = err or f"Tool '{tool_id}' is not running or has no active endpoint"
+            return {"status": "error", "error": msg}
 
 
         # 2. 根据端点类型调用
         # 2. 根据端点类型调用
         try:
         try:

+ 4 - 1
src/tool_agent/router/server.py

@@ -102,6 +102,9 @@ def create_app(router: Router, session_manager: SessionManager = None) -> FastAP
                 "output_schema": tool.output_schema,
                 "output_schema": tool.output_schema,
                 "state": route.state.value if route else "stopped",
                 "state": route.state.value if route else "stopped",
                 "port": route.port if route else None,
                 "port": route.port if route else None,
+                "host_dir": source.host_dir if source else None,
+                "endpoint_path": source.endpoint_path if source else None,
+                "http_method": source.http_method if source else None,
             })
             })
 
 
         return {
         return {
@@ -115,7 +118,7 @@ def create_app(router: Router, session_manager: SessionManager = None) -> FastAP
 
 
     @app.post("/run_tool")
     @app.post("/run_tool")
     async def run_tool(request: RunToolRequest):
     async def run_tool(request: RunToolRequest):
-        """调用已注册的工具"""
+        """调用已注册的工具(Dispatcher 会在需要时自动 start_tool)。"""
         try:
         try:
             result = await router.dispatcher.dispatch(request.tool_id, request.params)
             result = await router.dispatcher.dispatch(request.tool_id, request.params)
             return RunToolResponse(status="success", result=result)
             return RunToolResponse(status="success", result=result)

+ 10 - 0
src/tool_agent/router/status.py

@@ -292,6 +292,16 @@ class ToolStatusManager:
                 del self._popen_refs[tool_id]
                 del self._popen_refs[tool_id]
         return self._routes.get(tool_id)
         return self._routes.get(tool_id)
 
 
+    def get_primary_source(self, tool_id: str) -> ToolSource | None:
+        """当前工具选用的来源(与 route.active_source 下标对应)。"""
+        route = self._routes.get(tool_id)
+        if not route or not route.sources:
+            return None
+        idx = route.active_source
+        if idx < 0 or idx >= len(route.sources):
+            return route.sources[0]
+        return route.sources[idx]
+
     def get_active_endpoint(self, tool_id: str) -> dict | None:
     def get_active_endpoint(self, tool_id: str) -> dict | None:
         """获取工具当前活跃的调用端点"""
         """获取工具当前活跃的调用端点"""
         route = self._routes.get(tool_id)
         route = self._routes.get(tool_id)

BIN
tests/output/nano_banana_result.png


+ 177 - 0
tests/test_flux.py

@@ -0,0 +1,177 @@
+"""测试 BFL FLUX 异步生图 — 通过 Router POST /run_tool
+
+官方流程:先 POST 提交任务拿到 id + polling_url,再轮询 polling_url 直至 Ready。
+文档: https://docs.bfl.ai/quick_start/generating_images
+
+用法:
+    1. 配置 tools/local/flux/.env:BFL_API_KEY
+    2. uv run python -m tool_agent
+    3. uv run python tests/test_flux.py
+
+模型切换:
+    FLUX_TEST_MODEL=flux-2-max uv run python tests/test_flux.py
+    (model 为路径段,如 flux-2-pro-preview、flux-2-pro、flux-dev 等,见官方 Available Endpoints)
+
+环境变量:
+    TOOL_AGENT_ROUTER_URL   默认 http://127.0.0.1:8001
+    FLUX_SUBMIT_TOOL_ID     默认 flux_submit
+    FLUX_QUERY_TOOL_ID      默认 flux_query
+    FLUX_TEST_MODEL         默认 flux-2-pro-preview
+    FLUX_TEST_PROMPT        覆盖默认短提示词
+    FLUX_POLL_INTERVAL_S    默认 1.0
+    FLUX_POLL_MAX_WAIT_S    默认 300
+"""
+
+from __future__ import annotations
+
+import io
+import os
+import sys
+import time
+from typing import Any
+
+if sys.platform == "win32":
+    _out = sys.stdout
+    if isinstance(_out, io.TextIOWrapper):
+        _out.reconfigure(encoding="utf-8")
+
+import httpx
+
+ROUTER_URL = os.environ.get("TOOL_AGENT_ROUTER_URL", "http://127.0.0.1:8001")
+SUBMIT_TOOL = os.environ.get("FLUX_SUBMIT_TOOL_ID", "flux_submit")
+QUERY_TOOL = os.environ.get("FLUX_QUERY_TOOL_ID", "flux_query")
+FLUX_MODEL = os.environ.get("FLUX_TEST_MODEL", "flux-2-pro-preview").strip()
+TEST_PROMPT = os.environ.get(
+    "FLUX_TEST_PROMPT",
+    "A tiny red apple on white background, simple product photo, minimal",
+)
+POLL_INTERVAL_S = float(os.environ.get("FLUX_POLL_INTERVAL_S", "1.0"))
+POLL_MAX_WAIT_S = float(os.environ.get("FLUX_POLL_MAX_WAIT_S", "300"))
+
+
+def run_tool(tool_id: str, params: dict[str, Any], timeout: float = 120.0) -> dict[str, Any]:
+    resp = httpx.post(
+        f"{ROUTER_URL}/run_tool",
+        json={"tool_id": tool_id, "params": params},
+        timeout=timeout,
+    )
+    resp.raise_for_status()
+    body = resp.json()
+    if body.get("status") != "success":
+        raise RuntimeError(body.get("error") or str(body))
+    result = body.get("result")
+    if isinstance(result, dict) and result.get("status") == "error":
+        raise RuntimeError(result.get("error", str(result)))
+    return result if isinstance(result, dict) else {}
+
+
+def _poll_terminal_success(data: dict[str, Any]) -> bool:
+    s = str(data.get("status") or "").strip()
+    return s.lower() == "ready"
+
+
+def _poll_terminal_failure(data: dict[str, Any]) -> bool:
+    s = str(data.get("status") or "").strip().lower()
+    return s in ("error", "failed")
+
+
+def _sample_url(data: dict[str, Any]) -> str | None:
+    r = data.get("result")
+    if isinstance(r, dict):
+        u = r.get("sample")
+        if isinstance(u, str) and u.startswith("http"):
+            return u
+    return None
+
+
+def main() -> None:
+    print("=" * 50)
+    print("测试 FLUX(BFL 异步 API + 模型可切换)")
+    print("=" * 50)
+    print(f"ROUTER_URL: {ROUTER_URL}")
+    print(f"model:      {FLUX_MODEL}")
+
+    try:
+        r = httpx.get(f"{ROUTER_URL}/health", timeout=3)
+        print(f"Router 状态: {r.json()}")
+    except httpx.ConnectError:
+        print(f"无法连接 Router ({ROUTER_URL}),请先: uv run python -m tool_agent")
+        sys.exit(1)
+
+    print("\n--- 校验工具已注册 ---")
+    tr = httpx.get(f"{ROUTER_URL}/tools", timeout=30)
+    tr.raise_for_status()
+    tools = tr.json().get("tools", [])
+    ids = {t["tool_id"] for t in tools}
+    for tid in (SUBMIT_TOOL, QUERY_TOOL):
+        if tid not in ids:
+            print(f"错误: {tid!r} 不在 GET /tools 中。示例 id: {sorted(ids)[:20]}...")
+            sys.exit(1)
+        meta = next(t for t in tools if t["tool_id"] == tid)
+        print(f"  {tid}: {meta.get('name', '')} (state={meta.get('state')})")
+
+    props = (next(t for t in tools if t["tool_id"] == SUBMIT_TOOL).get("input_schema") or {}).get(
+        "properties"
+    ) or {}
+    if "model" in props:
+        print("  flux_submit input_schema 已声明 model")
+    else:
+        print("  提示: flux_submit 宜在注册表中声明 model 以便切换端点")
+
+    print("\n--- flux_submit ---")
+    submit_params: dict[str, Any] = {
+        "model": FLUX_MODEL,
+        "prompt": TEST_PROMPT,
+        "width": 512,
+        "height": 512,
+    }
+    try:
+        sub = run_tool(SUBMIT_TOOL, submit_params, timeout=120.0)
+    except (RuntimeError, httpx.HTTPError) as e:
+        print(f"错误: {e}")
+        sys.exit(1)
+
+    print(f"提交返回 keys: {list(sub.keys())}")
+    req_id = sub.get("id") or sub.get("request_id")
+    poll_url = sub.get("polling_url")
+    if not req_id or not poll_url:
+        print(f"错误: 缺少 id 或 polling_url: {sub}")
+        sys.exit(1)
+    print(f"request id: {req_id}")
+    print(f"polling_url: {poll_url[:80]}...")
+
+    print("\n--- flux_query 轮询 ---")
+    deadline = time.monotonic() + POLL_MAX_WAIT_S
+    last: dict[str, Any] = {}
+
+    while time.monotonic() < deadline:
+        time.sleep(POLL_INTERVAL_S)
+        try:
+            last = run_tool(
+                QUERY_TOOL,
+                {"polling_url": str(poll_url), "request_id": str(req_id)},
+                timeout=60.0,
+            )
+        except (RuntimeError, httpx.HTTPError) as e:
+            print(f"轮询错误: {e}")
+            sys.exit(1)
+
+        st = last.get("status")
+        print(f"  status: {st}")
+
+        if _poll_terminal_failure(last):
+            print(f"生成失败: {last}")
+            sys.exit(1)
+        if _poll_terminal_success(last):
+            url = _sample_url(last)
+            if url:
+                print(f"\n图片 URL(signed,约 10 分钟内有效): {url[:100]}...")
+            print("\n测试通过!")
+            return
+
+    print(f"\n等待超时 ({POLL_MAX_WAIT_S}s),最后一次: {last}")
+    sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 153 - 0
tests/test_jimeng.py

@@ -0,0 +1,153 @@
+"""测试 ji_meng 任务工具 — 通过 Router API 调用(范本同 test_liblibai_tool.py)
+
+用法:
+    1. 先启动 Router:uv run python -m tool_agent
+    2. 运行测试:python tests/test_jimeng.py
+
+需已注册 ji_meng_add_task、ji_meng_query_task(或改下方常量);搜索关键词默认可匹配二者。
+"""
+
+import sys
+import time
+
+if sys.platform == 'win32':
+    sys.stdout.reconfigure(encoding='utf-8')
+
+import httpx
+
+ROUTER_URL = "http://127.0.0.1:8001"
+POLL_INTERVAL_S = 2
+POLL_MAX_WAIT_S = 300
+
+
+def _extract_task_id(data: dict):
+    for key in ("task_id", "taskId", "id", "job_id", "jobId"):
+        v = data.get(key)
+        if v is not None and str(v).strip():
+            return str(v).strip()
+    inner = data.get("data")
+    if isinstance(inner, dict):
+        return _extract_task_id(inner)
+    return None
+
+
+def _terminal_success(data: dict) -> bool:
+    status = (
+        data.get("status")
+        or data.get("task_status")
+        or data.get("taskStatus")
+        or data.get("state")
+    )
+    if status is None and isinstance(data.get("data"), dict):
+        status = data["data"].get("status") or data["data"].get("task_status")
+    if status is None:
+        return False
+    s = str(status).lower()
+    return s in ("completed", "success", "done", "finished", "succeed", "complete")
+
+
+def _terminal_failure(data: dict) -> bool:
+    status = data.get("status") or data.get("task_status") or data.get("state")
+    if status is None and isinstance(data.get("data"), dict):
+        status = data["data"].get("status")
+    if status is None:
+        return False
+    s = str(status).lower()
+    return s in ("failed", "error", "cancelled", "canceled")
+
+
+def main():
+    print("=" * 50)
+    print("测试 ji_meng 任务工具")
+    print("=" * 50)
+
+    # 1. 检查 Router 是否在线
+    try:
+        resp = httpx.get(f"{ROUTER_URL}/health", timeout=3)
+        print(f"Router 状态: {resp.json()}")
+    except httpx.ConnectError:
+        print(f"无法连接 Router ({ROUTER_URL})")
+        print("请先启动: uv run python -m tool_agent")
+        sys.exit(1)
+
+    # 2. 搜索工具,确认已注册
+    print("\n--- 搜索工具 ---")
+    resp = httpx.post(f"{ROUTER_URL}/search_tools", json={"keyword": "ji_meng"})
+    tools = resp.json()
+    print(f"找到 {tools['total']} 个工具")
+    for t in tools["tools"]:
+        print(f"  {t['tool_id']}: {t['name']} (state={t['state']})")
+
+    # 3. 创建 ji_meng_add_task 工具
+    print("\n--- 调用 ji_meng_add_task 工具创建任务 ---")
+    print(f"提示词: simple white line art, cat, black background")
+    print("提交中...")
+
+    resp = httpx.post(
+        f"{ROUTER_URL}/select_tool",
+        json={
+            "tool_id": "ji_meng_add_task",
+            "params": {
+                "task_type": "image",
+                "prompt": "simple white line art, cat, black background",
+            },
+        },
+        timeout=120,
+    )
+
+    result = resp.json()
+    print(f"\n响应状态: {result.get('status')}")
+
+    if result.get("code") != 0:
+        print(f"错误: {result.get('msg')}")
+        sys.exit(1)
+
+    task_id = result.get("data", {}).get("task_id")
+    if not task_id:
+        print("错误: 无法从创建任务响应中解析 task_id")
+        sys.exit(1)
+    print(f"任务 ID: {task_id}")
+
+    # 4. 轮询查询任务
+    print("\n--- 调用 ji_meng_query_task 工具查询任务 ---")
+    deadline = time.monotonic() + POLL_MAX_WAIT_S
+    last = {}
+
+    while time.monotonic() < deadline:
+        resp = httpx.post(
+            f"{ROUTER_URL}/select_tool",
+            json={
+                "tool_id": "ji_meng_query_task",
+                "params": {"task_id": task_id},
+            },
+            timeout=60,
+        )
+        result = resp.json()
+        print(f"\n响应状态: {result.get('status')}")
+
+        if result.get("status") != "success":
+            print(f"错误: {result.get('error')}")
+            sys.exit(1)
+
+        last = result.get("result", {})
+        if not isinstance(last, dict):
+            print(f"非 dict 结果: {last}")
+            time.sleep(POLL_INTERVAL_S)
+            continue
+        if last.get("status") == "error":
+            print(f"错误: {last.get('error')}")
+            sys.exit(1)
+
+        print(f"任务状态: {last.get('status')}")
+        print(f"最终结果: {last}")
+        print("\n测试通过!")
+        return
+
+        time.sleep(POLL_INTERVAL_S)
+
+    print(f"\n等待超时 ({POLL_MAX_WAIT_S}s),最后一次响应: {last}")
+    sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 231 - 0
tests/test_midjourney.py

@@ -0,0 +1,231 @@
+"""测试 Midjourney 代理工具 — Router POST /run_tool
+
+本地服务将 JSON 原样转发至 MIDJOURNEY_API_BASE 上你已实现的三个接口:
+  POST /submit_job       cookie, prompt, user_id, mode(relaxed|fast)
+  POST /query_job_status cookie, job_id
+  POST /get_image_urls   job_id → 四张图链接
+
+用法:
+    1. tools/local/midjourney/.env:MIDJOURNEY_API_BASE
+    2. uv run python -m tool_agent
+    3. uv run python tests/test_midjourney.py
+
+端到端(可选):设置 MIDJOURNEY_TEST_COOKIE、MIDJOURNEY_TEST_USER_ID 后脚本会
+    submit → 轮询 query → get_image_urls;否则仅校验工具已注册并退出 0。
+
+环境变量:
+    TOOL_AGENT_ROUTER_URL
+    MIDJOURNEY_SUBMIT_TOOL_ID      默认 midjourney_submit_job
+    MIDJOURNEY_QUERY_TOOL_ID       默认 midjourney_query_job_status
+    MIDJOURNEY_GET_URLS_TOOL_ID    默认 midjourney_get_image_urls
+    MIDJOURNEY_TEST_COOKIE / MIDJOURNEY_TEST_USER_ID / MIDJOURNEY_TEST_PROMPT / MIDJOURNEY_TEST_MODE
+    MIDJOURNEY_POLL_INTERVAL_S / MIDJOURNEY_POLL_MAX_WAIT_S
+"""
+
+from __future__ import annotations
+
+import io
+import os
+import sys
+import time
+from typing import Any
+
+if sys.platform == "win32":
+    _out = sys.stdout
+    if isinstance(_out, io.TextIOWrapper):
+        _out.reconfigure(encoding="utf-8")
+
+import httpx
+
+ROUTER_URL = os.environ.get("TOOL_AGENT_ROUTER_URL", "http://127.0.0.1:8001")
+T_SUBMIT = os.environ.get("MIDJOURNEY_SUBMIT_TOOL_ID", "midjourney_submit_job")
+T_QUERY = os.environ.get("MIDJOURNEY_QUERY_TOOL_ID", "midjourney_query_job_status")
+T_URLS = os.environ.get("MIDJOURNEY_GET_URLS_TOOL_ID", "midjourney_get_image_urls")
+TEST_COOKIE = os.environ.get("MIDJOURNEY_TEST_COOKIE", "").strip()
+TEST_USER_ID = os.environ.get("MIDJOURNEY_TEST_USER_ID", "").strip()
+TEST_PROMPT = os.environ.get("MIDJOURNEY_TEST_PROMPT", "a red apple on white background --v 6")
+TEST_MODE = os.environ.get("MIDJOURNEY_TEST_MODE", "fast").strip().lower()
+POLL_INTERVAL_S = float(os.environ.get("MIDJOURNEY_POLL_INTERVAL_S", "3"))
+POLL_MAX_WAIT_S = float(os.environ.get("MIDJOURNEY_POLL_MAX_WAIT_S", "600"))
+
+
+def run_tool(tool_id: str, params: dict[str, Any], timeout: float = 120.0) -> Any:
+    resp = httpx.post(
+        f"{ROUTER_URL}/run_tool",
+        json={"tool_id": tool_id, "params": params},
+        timeout=timeout,
+    )
+    resp.raise_for_status()
+    body = resp.json()
+    if body.get("status") != "success":
+        raise RuntimeError(body.get("error") or str(body))
+    result = body.get("result")
+    if isinstance(result, dict) and result.get("status") == "error":
+        raise RuntimeError(result.get("error", str(result)))
+    return result
+
+
+def _extract_job_id(data: dict[str, Any]) -> str | None:
+    if not isinstance(data, dict):
+        return None
+    for key in ("job_id", "jobId", "id", "task_id", "taskId"):
+        v = data.get(key)
+        if v is not None and str(v).strip():
+            return str(v).strip()
+    inner = data.get("data")
+    if isinstance(inner, dict):
+        return _extract_job_id(inner)
+    return None
+
+
+def _status_terminal_ok(data: dict[str, Any]) -> bool:
+    if not isinstance(data, dict):
+        return False
+    s = str(
+        data.get("status")
+        or data.get("job_status")
+        or data.get("jobStatus")
+        or data.get("state")
+        or ""
+    ).lower()
+    if not s and isinstance(data.get("data"), dict):
+        return _status_terminal_ok(data["data"])
+    return any(k in s for k in ("complete", "success", "done", "finished", "succeed", "ready"))
+
+
+def _status_terminal_fail(data: dict[str, Any]) -> bool:
+    if not isinstance(data, dict):
+        return False
+    s = str(data.get("status") or data.get("job_status") or data.get("state") or "").lower()
+    return any(k in s for k in ("fail", "error", "cancel", "canceled", "cancelled"))
+
+
+def _extract_url_list(payload: Any) -> list[str]:
+    if isinstance(payload, list):
+        return [str(x) for x in payload if isinstance(x, str) and x.startswith("http")]
+    if not isinstance(payload, dict):
+        return []
+    for key in ("image_urls", "urls", "images", "data"):
+        v = payload.get(key)
+        if isinstance(v, list):
+            out = [str(x) for x in v if isinstance(x, str) and x.startswith("http")]
+            if out:
+                return out
+        if isinstance(v, dict):
+            nested = _extract_url_list(v)
+            if nested:
+                return nested
+    return _extract_url_list(payload.get("data"))
+
+
+def main() -> None:
+    print("=" * 50)
+    print("测试 Midjourney(submit / query / get_image_urls)")
+    print("=" * 50)
+    print(f"ROUTER_URL: {ROUTER_URL}")
+
+    try:
+        r = httpx.get(f"{ROUTER_URL}/health", timeout=3)
+        print(f"Router 状态: {r.json()}")
+    except httpx.ConnectError:
+        print(f"无法连接 Router ({ROUTER_URL}),请先: uv run python -m tool_agent")
+        sys.exit(1)
+
+    print("\n--- 校验工具已注册 ---")
+    tr = httpx.get(f"{ROUTER_URL}/tools", timeout=30)
+    tr.raise_for_status()
+    tools = tr.json().get("tools", [])
+    ids = {t["tool_id"] for t in tools}
+    for tid in (T_SUBMIT, T_QUERY, T_URLS):
+        if tid not in ids:
+            print(f"错误: {tid!r} 不在 GET /tools 中。示例: {sorted(ids)[:25]}...")
+            sys.exit(1)
+        meta = next(t for t in tools if t["tool_id"] == tid)
+        print(f"  {tid}: {meta.get('name', '')} (state={meta.get('state')})")
+
+    if not TEST_COOKIE or not TEST_USER_ID:
+        print(
+            "\n未设置 MIDJOURNEY_TEST_COOKIE 与 MIDJOURNEY_TEST_USER_ID,跳过端到端;"
+            "工具注册检查已通过,退出 0。"
+        )
+        return
+
+    if TEST_MODE not in ("relaxed", "fast"):
+        print(f"错误: MIDJOURNEY_TEST_MODE 须为 relaxed 或 fast,当前: {TEST_MODE!r}")
+        sys.exit(1)
+
+    print("\n--- midjourney_submit_job ---")
+    try:
+        sub = run_tool(
+            T_SUBMIT,
+            {
+                "cookie": TEST_COOKIE,
+                "prompt": TEST_PROMPT,
+                "user_id": TEST_USER_ID,
+                "mode": TEST_MODE,
+            },
+            timeout=180.0,
+        )
+    except (RuntimeError, httpx.HTTPError) as e:
+        print(f"错误: {e}")
+        sys.exit(1)
+
+    if not isinstance(sub, dict):
+        print(f"错误: submit 返回非 object: {type(sub)}")
+        sys.exit(1)
+
+    job_id = _extract_job_id(sub)
+    if not job_id:
+        print(f"错误: 无法从 submit 响应解析 job_id: {sub}")
+        sys.exit(1)
+    print(f"job_id: {job_id}")
+
+    print("\n--- midjourney_query_job_status 轮询 ---")
+    deadline = time.monotonic() + POLL_MAX_WAIT_S
+    last: dict[str, Any] = {}
+
+    while time.monotonic() < deadline:
+        time.sleep(POLL_INTERVAL_S)
+        try:
+            q = run_tool(
+                T_QUERY,
+                {"cookie": TEST_COOKIE, "job_id": job_id},
+                timeout=120.0,
+            )
+        except (RuntimeError, httpx.HTTPError) as e:
+            print(f"轮询错误: {e}")
+            sys.exit(1)
+
+        last = q if isinstance(q, dict) else {}
+        st = last.get("status") or last.get("job_status") or last.get("state")
+        print(f"  status: {st}")
+
+        if _status_terminal_fail(last):
+            print(f"任务失败: {last}")
+            sys.exit(1)
+        if _status_terminal_ok(last):
+            break
+    else:
+        print(f"等待超时 ({POLL_MAX_WAIT_S}s),最后响应: {last}")
+        sys.exit(1)
+
+    print("\n--- midjourney_get_image_urls ---")
+    try:
+        urls_payload = run_tool(T_URLS, {"job_id": job_id}, timeout=120.0)
+    except (RuntimeError, httpx.HTTPError) as e:
+        print(f"错误: {e}")
+        sys.exit(1)
+
+    urls = _extract_url_list(urls_payload)
+    if len(urls) < 4:
+        print(f"警告: 期望至少 4 个 http 链接,实际 {len(urls)};原始: {str(urls_payload)[:500]}")
+        if len(urls) == 0:
+            sys.exit(1)
+
+    for i, u in enumerate(urls[:4], 1):
+        print(f"  [{i}] {u[:96]}...")
+    print("\n测试通过!")
+
+
+if __name__ == "__main__":
+    main()

+ 144 - 0
tests/test_nano_banana.py

@@ -0,0 +1,144 @@
+"""测试 nano_banana — Router 调用 Gemini 图模(HTTP generateContent)
+
+前提:
+    - data/registry.json + data/sources.json 已注册 tool_id=nano_banana
+    - tools/local/nano_banana 已提供 POST /generate,且 .env 中配置 GEMINI_API_KEY
+
+用法:
+    1. uv run python -m tool_agent
+    2. uv run python tests/test_nano_banana.py
+
+模型切换(任选其一):
+    - 不传 NANO_BANANA_MODEL:请求体不含 model,由工具侧默认(如 gemini-2.5-flash-image /
+      环境变量 GEMINI_IMAGE_MODEL)
+    - 显式切换预览图模:
+        NANO_BANANA_MODEL=gemini-3.1-flash-image-preview uv run python tests/test_nano_banana.py
+
+环境变量:
+    TOOL_AGENT_ROUTER_URL   默认 http://127.0.0.1:8001
+    NANO_BANANA_TOOL_ID     默认 nano_banana
+    NANO_BANANA_TEST_PROMPT 覆盖默认短提示词
+    NANO_BANANA_MODEL       非空时作为 params["model"] 传给 /run_tool
+"""
+
+import io
+import os
+import sys
+from typing import Any
+
+if sys.platform == "win32":
+    _out = sys.stdout
+    if isinstance(_out, io.TextIOWrapper):
+        _out.reconfigure(encoding="utf-8")
+
+import httpx
+
+ROUTER_URL = os.environ.get("TOOL_AGENT_ROUTER_URL", "http://127.0.0.1:8001")
+TOOL_ID = os.environ.get("NANO_BANANA_TOOL_ID", "nano_banana")
+NANO_BANANA_MODEL = os.environ.get("NANO_BANANA_MODEL", "").strip()
+TEST_PROMPT = os.environ.get(
+    "NANO_BANANA_TEST_PROMPT",
+    "A minimal flat icon of a yellow banana on white background, no text",
+)
+
+
+def run_tool(params: dict[str, Any], timeout: float = 180.0) -> dict[str, Any]:
+    resp = httpx.post(
+        f"{ROUTER_URL}/run_tool",
+        json={"tool_id": TOOL_ID, "params": params},
+        timeout=timeout,
+    )
+    resp.raise_for_status()
+    body = resp.json()
+    if body.get("status") != "success":
+        raise RuntimeError(body.get("error") or str(body))
+    result = body.get("result")
+    if isinstance(result, dict) and result.get("status") == "error":
+        raise RuntimeError(result.get("error", str(result)))
+    return result if isinstance(result, dict) else {}
+
+
+def _has_image_payload(data: dict[str, Any]) -> bool:
+    if not data:
+        return False
+    if data.get("images"):
+        return True
+    if data.get("image") and isinstance(data["image"], str) and len(data["image"]) > 100:
+        return True
+    if data.get("image_base64"):
+        return True
+    cands = data.get("candidates")
+    if isinstance(cands, list) and cands:
+        parts = cands[0].get("content", {}).get("parts", [])
+        for p in parts:
+            if isinstance(p, dict) and (p.get("inlineData") or p.get("inline_data")):
+                return True
+    return False
+
+
+def main():
+    print("=" * 50)
+    print("测试 nano_banana(Gemini 图模,可切换 model)")
+    print("=" * 50)
+    print(f"ROUTER_URL: {ROUTER_URL}")
+    print(f"tool_id:    {TOOL_ID}")
+    if NANO_BANANA_MODEL:
+        print(f"model:      {NANO_BANANA_MODEL}(经 params 传入)")
+    else:
+        print("model:      (未传,使用工具默认 / GEMINI_IMAGE_MODEL)")
+
+    try:
+        r = httpx.get(f"{ROUTER_URL}/health", timeout=3)
+        print(f"Router 状态: {r.json()}")
+    except httpx.ConnectError:
+        print(f"无法连接 Router ({ROUTER_URL}),请先: uv run python -m tool_agent")
+        sys.exit(1)
+
+    print("\n--- 校验工具已注册 ---")
+    tr = httpx.get(f"{ROUTER_URL}/tools", timeout=30)
+    tr.raise_for_status()
+    tools = tr.json().get("tools", [])
+    ids = {t["tool_id"] for t in tools}
+    if TOOL_ID not in ids:
+        print(f"错误: {TOOL_ID!r} 不在 GET /tools 中。当前示例: {sorted(ids)[:15]}...")
+        sys.exit(1)
+    meta = next(t for t in tools if t["tool_id"] == TOOL_ID)
+    print(f"  {TOOL_ID}: {meta.get('name', '')} (state={meta.get('state')})")
+    props = (meta.get("input_schema") or {}).get("properties") or {}
+    if "model" in props:
+        print("  input_schema 已声明 model(注册与实现应对齐)")
+    else:
+        print("  提示: input_schema 尚无 model 字段,注册表宜补充以便编排知晓可切换模型")
+
+    params: dict[str, Any] = {"prompt": TEST_PROMPT}
+    if NANO_BANANA_MODEL:
+        params["model"] = NANO_BANANA_MODEL
+
+    print("\n--- 调用生图 ---")
+    print(f"prompt: {TEST_PROMPT[:80]}{'...' if len(TEST_PROMPT) > 80 else ''}")
+
+    try:
+        data = run_tool(params, timeout=180.0)
+    except (RuntimeError, httpx.HTTPError) as e:
+        print(f"错误: {e}")
+        sys.exit(1)
+
+    print(f"\n下游返回 keys: {list(data.keys())[:20]}")
+    if rm := data.get("model"):
+        print(f"下游报告 model: {rm}")
+        if NANO_BANANA_MODEL and rm != NANO_BANANA_MODEL:
+            print(
+                f"警告: 请求 model={NANO_BANANA_MODEL!r} 与返回 model={rm!r} 不一致(若工具会规范化 ID 可忽略)"
+            )
+
+    if _has_image_payload(data):
+        print("\n检测到图片相关字段,测试通过!")
+        return
+
+    print("\n未识别到常见图片字段(images / image / candidates[].inlineData 等)。")
+    print(f"完整结果(截断): {str(data)[:800]}")
+    sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 246 - 35
tests/test_router_api.py

@@ -5,8 +5,9 @@
     uv run python tests/test_router_api.py --search                      # 搜索工具列表
     uv run python tests/test_router_api.py --search                      # 搜索工具列表
     uv run python tests/test_router_api.py --search image                # 关键词搜索
     uv run python tests/test_router_api.py --search image                # 关键词搜索
     uv run python tests/test_router_api.py --status                      # 工具运行状态
     uv run python tests/test_router_api.py --status                      # 工具运行状态
-    uv run python tests/test_router_api.py --select image_stitcher       # 调用指定工具
+    uv run python tests/test_router_api.py --select image_stitcher       # POST /run_tool 调用工具
     uv run python tests/test_router_api.py --stitch                      # 测试图片拼接
     uv run python tests/test_router_api.py --stitch                      # 测试图片拼接
+    uv run python tests/test_router_api.py --nano_banana                 # 测试 nano_banana
     uv run python tests/test_router_api.py --create                      # 默认任务
     uv run python tests/test_router_api.py --create                      # 默认任务
     uv run python tests/test_router_api.py --create image_stitcher       # 指定任务文件
     uv run python tests/test_router_api.py --create image_stitcher       # 指定任务文件
     uv run python tests/test_router_api.py --launch-env                  # 创建 RunComfy 启动环境工具
     uv run python tests/test_router_api.py --launch-env                  # 创建 RunComfy 启动环境工具
@@ -17,13 +18,16 @@
 import argparse
 import argparse
 import base64
 import base64
 import json
 import json
+import os
+import re
 import sys
 import sys
 import time
 import time
 from pathlib import Path
 from pathlib import Path
+from typing import Any
 
 
 import httpx
 import httpx
 
 
-BASE_URL = "http://127.0.0.1:8001"
+BASE_URL = os.environ.get("TOOL_AGENT_ROUTER_URL", "http://127.0.0.1:8001")
 TASKS_DIR = Path(__file__).parent / "tasks"
 TASKS_DIR = Path(__file__).parent / "tasks"
 TEST_IMAGES_DIR = TASKS_DIR / "stitcher_images"
 TEST_IMAGES_DIR = TASKS_DIR / "stitcher_images"
 OUTPUT_DIR = Path(__file__).parent / "output"
 OUTPUT_DIR = Path(__file__).parent / "output"
@@ -99,21 +103,40 @@ def test_tools_status():
     print("  [PASS]")
     print("  [PASS]")
 
 
 
 
-def test_select_tool(tool_id: str):
-    print(f"=== Select Tool (tool_id={tool_id!r}) ===")
-    resp = httpx.post(f"{BASE_URL}/select_tool", json={
-        "tool_id": tool_id,
-        "params": {}
-    }, timeout=30)
+def _run_tool(
+    tool_id: str, params: dict[str, Any], timeout: float = 120.0
+) -> tuple[bool, str | None, Any]:
+    """POST /run_tool。成功返回 (True, None, result);失败 (False, message, None)。"""
+    resp = httpx.post(
+        f"{BASE_URL}/run_tool",
+        json={"tool_id": tool_id, "params": params},
+        timeout=timeout,
+    )
     print(f"  Status : {resp.status_code}")
     print(f"  Status : {resp.status_code}")
-    data = resp.json()
+    if resp.status_code != 200:
+        return False, f"HTTP {resp.status_code}: {resp.text[:300]}", None
+    try:
+        data = resp.json()
+    except Exception as e:
+        return False, f"Invalid JSON: {e}", None
+    if data.get("status") != "success":
+        return False, data.get("error") or str(data), None
+    result = data.get("result")
+    if isinstance(result, dict) and result.get("status") == "error":
+        return False, str(result.get("error", result)), None
+    return True, None, result
+
+
+def test_select_tool(tool_id: str):
+    print(f"=== Run Tool (tool_id={tool_id!r}) ===")
+    ok, err, result = _run_tool(tool_id, {}, timeout=30)
     print(f"  Result :")
     print(f"  Result :")
-    print(f"    status: {data.get('status')}")
-    if data.get("error"):
-        print(f"    error : {data['error']}")
-    else:
-        result_str = json.dumps(data.get("result"), ensure_ascii=False, indent=6)
-        print(f"    result: {result_str[:500]}")
+    if not ok:
+        print(f"    error : {err}")
+        print("  [FAIL]")
+        return
+    result_str = json.dumps(result, ensure_ascii=False, indent=6)
+    print(f"    body: {result_str[:500]}")
     print("  [PASS]")
     print("  [PASS]")
 
 
 
 
@@ -139,32 +162,213 @@ def test_stitch_images():
 
 
     print(f"  Calling image_stitcher (grid, 2 columns)...")
     print(f"  Calling image_stitcher (grid, 2 columns)...")
     try:
     try:
-        resp = httpx.post(f"{BASE_URL}/select_tool", json={
-            "tool_id": "image_stitcher",
-            "params": {
+        ok, err, result = _run_tool(
+            "image_stitcher",
+            {
                 "images": images_b64,
                 "images": images_b64,
                 "direction": "grid",
                 "direction": "grid",
                 "columns": 2,
                 "columns": 2,
                 "spacing": 10,
                 "spacing": 10,
                 "background_color": "#FFFFFF",
                 "background_color": "#FFFFFF",
-            }
-        }, timeout=60)
-        print(f"  Status : {resp.status_code}")
-        data = resp.json()
-        if data["status"] == "success":
-            result = data["result"]
-            print(f"  Result :")
-            print(f"    width : {result['width']}")
-            print(f"    height: {result['height']}")
-            OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
-            output_path = OUTPUT_DIR / "stitched_result.png"
-            with open(output_path, "wb") as f:
-                f.write(base64.b64decode(result["image"]))
-            print(f"    saved : {output_path}")
-            print("  [PASS]")
-        else:
-            print(f"  ERROR : {data.get('error', 'unknown')}")
+            },
+            timeout=120.0,
+        )
+        if not ok:
+            print(f"  ERROR : {err}")
+            print("  [FAIL]")
+            return
+        if not isinstance(result, dict) or "image" not in result:
+            print(f"  ERROR : 缺少 image 字段: {result!r}")
             print("  [FAIL]")
             print("  [FAIL]")
+            return
+        print(f"  Result :")
+        print(f"    width : {result.get('width')}")
+        print(f"    height: {result.get('height')}")
+        OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+        output_path = OUTPUT_DIR / "stitched_result.png"
+        with open(output_path, "wb") as f:
+            f.write(base64.b64decode(result["image"]))
+        print(f"    saved : {output_path}")
+        print("  [PASS]")
+    except httpx.TimeoutException:
+        print("  ERROR : Request timeout")
+        print("  [FAIL]")
+    except Exception as e:
+        print(f"  ERROR : {e}")
+        print("  [FAIL]")
+
+
+def _nano_has_image(data: dict[str, Any]) -> bool:
+    if data.get("images"):
+        return True
+    img = data.get("image")
+    if isinstance(img, str) and len(img) > 100:
+        return True
+    if data.get("image_base64"):
+        return True
+    cands = data.get("candidates")
+    if isinstance(cands, list) and cands:
+        parts = cands[0].get("content", {}).get("parts", [])
+        for p in parts:
+            if isinstance(p, dict) and (p.get("inlineData") or p.get("inline_data")):
+                return True
+    return False
+
+
+_NANO_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.I | re.S)
+
+
+def _nano_mime_to_ext(mime: str) -> str:
+    base = mime.lower().split(";")[0].strip()
+    if base == "image/png":
+        return "png"
+    if base in ("image/jpeg", "image/jpg"):
+        return "jpg"
+    if base == "image/webp":
+        return "webp"
+    return "png"
+
+
+def _nano_collect_image_bytes(result: dict[str, Any]) -> list[tuple[bytes, str]]:
+    """从 nano_banana 常见返回结构解析出 (raw_bytes, ext) 列表。"""
+    out: list[tuple[bytes, str]] = []
+    imgs = result.get("images")
+    if isinstance(imgs, list):
+        for item in imgs:
+            if not isinstance(item, str) or not item.strip():
+                continue
+            s = item.strip()
+            m = _NANO_DATA_URL_RE.match(s)
+            if m:
+                mime, b64 = m.group(1), m.group(2)
+                try:
+                    out.append((base64.b64decode(b64), _nano_mime_to_ext(mime)))
+                except Exception:
+                    continue
+            else:
+                try:
+                    out.append((base64.b64decode(s), "png"))
+                except Exception:
+                    continue
+    img_one = result.get("image")
+    if not out and isinstance(img_one, str) and len(img_one) > 100:
+        try:
+            out.append((base64.b64decode(img_one), "png"))
+        except Exception:
+            pass
+    b64_field = result.get("image_base64")
+    if not out and isinstance(b64_field, str) and b64_field.strip():
+        try:
+            out.append((base64.b64decode(b64_field.strip()), "png"))
+        except Exception:
+            pass
+    cands = result.get("candidates")
+    if not out and isinstance(cands, list) and cands:
+        cand0 = cands[0]
+        if isinstance(cand0, dict):
+            for p in cand0.get("content", {}).get("parts", []) or []:
+                if not isinstance(p, dict):
+                    continue
+                inline = p.get("inlineData") or p.get("inline_data")
+                if not isinstance(inline, dict):
+                    continue
+                b64 = inline.get("data")
+                if not b64:
+                    continue
+                mime = str(
+                    inline.get("mimeType") or inline.get("mime_type") or "image/png"
+                )
+                try:
+                    out.append((base64.b64decode(b64), _nano_mime_to_ext(mime)))
+                except Exception:
+                    continue
+                break
+    return out
+
+
+def _nano_save_images_default(result: dict[str, Any]) -> list[Path]:
+    """默认写入 tests/output/nano_banana_result[_{n}].{ext},返回已写入路径。"""
+    blobs = _nano_collect_image_bytes(result)
+    if not blobs:
+        return []
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+    paths: list[Path] = []
+    if len(blobs) == 1:
+        ext = blobs[0][1]
+        p = OUTPUT_DIR / f"nano_banana_result.{ext}"
+        p.write_bytes(blobs[0][0])
+        paths.append(p)
+    else:
+        for i, (raw, ext) in enumerate(blobs):
+            p = OUTPUT_DIR / f"nano_banana_result_{i}.{ext}"
+            p.write_bytes(raw)
+            paths.append(p)
+    return paths
+
+
+def test_nano_banana():
+    """POST /run_tool → nano_banana;依赖 tools/local/nano_banana/.env 中 GEMINI_API_KEY。"""
+    print("=== Test nano_banana (Gemini 图模) ===")
+    print("  需: tools/local/nano_banana/.env → GEMINI_API_KEY")
+    print("  可选环境变量: NANO_BANANA_TEST_PROMPT, NANO_BANANA_MODEL")
+    print("  通过时默认保存图片到 tests/output/nano_banana_result*.png(或多张时带序号)")
+
+    tid = os.environ.get("NANO_BANANA_TOOL_ID", "nano_banana")
+    try:
+        tr = httpx.get(f"{BASE_URL}/tools", timeout=30)
+        tr.raise_for_status()
+        ids = {t["tool_id"] for t in tr.json().get("tools", [])}
+        if tid not in ids:
+            print(f"  ERROR : 注册表中无 {tid!r},请先检查 data/registry.json")
+            print("  [FAIL]")
+            return
+        print(f"  tool_id: {tid} (已注册)")
+    except Exception as e:
+        print(f"  ERROR : GET /tools 失败: {e}")
+        print("  [FAIL]")
+        return
+
+    prompt = os.environ.get(
+        "NANO_BANANA_TEST_PROMPT",
+        "A minimal flat yellow banana icon on white background, no text",
+    )
+    params: dict[str, Any] = {"prompt": prompt}
+    model = os.environ.get("NANO_BANANA_MODEL", "").strip()
+    if model:
+        params["model"] = model
+        print(f"  model: {model}")
+    else:
+        print("  model: (使用工具默认 / GEMINI_IMAGE_MODEL)")
+
+    print(f"  calling {tid} ...")
+    try:
+        ok, err, result = _run_tool(tid, params, timeout=180.0)
+        if not ok:
+            print(f"  ERROR : {err}")
+            print("  [FAIL]")
+            return
+        if not isinstance(result, dict):
+            print(f"  ERROR : 非 dict 结果: {type(result)}")
+            print("  [FAIL]")
+            return
+        if _nano_has_image(result):
+            n = len(result["images"]) if isinstance(result.get("images"), list) else 0
+            print(f"  Result : 含图片字段 (images 条数≈{n})")
+            if result.get("model"):
+                print(f"    model: {result['model']}")
+            saved = _nano_save_images_default(result)
+            if saved:
+                for sp in saved:
+                    print(f"    saved : {sp}")
+            else:
+                print(
+                    "    WARN : 未能从响应解析出图片字节(字段存在但无法 base64 解码)"
+                )
+            print("  [PASS]")
+            return
+        print(f"  ERROR : 未识别到图片字段,keys={list(result.keys())}")
+        print(f"  截断: {str(result)[:400]}...")
+        print("  [FAIL]")
     except httpx.TimeoutException:
     except httpx.TimeoutException:
         print("  ERROR : Request timeout")
         print("  ERROR : Request timeout")
         print("  [FAIL]")
         print("  [FAIL]")
@@ -243,6 +447,8 @@ def main():
                         help="call a tool by tool_id")
                         help="call a tool by tool_id")
     parser.add_argument("--stitch", action="store_true",
     parser.add_argument("--stitch", action="store_true",
                         help="test image stitcher with sample images")
                         help="test image stitcher with sample images")
+    parser.add_argument("--nano_banana", action="store_true",
+                        help="test nano_banana (Gemini); need GEMINI_API_KEY in tools/local/nano_banana/.env")
     parser.add_argument("--create", nargs="?", const="", metavar="TASK_NAME",
     parser.add_argument("--create", nargs="?", const="", metavar="TASK_NAME",
                         help="create tool, optional task file name")
                         help="create tool, optional task file name")
     parser.add_argument("--launch-env", action="store_true",
     parser.add_argument("--launch-env", action="store_true",
@@ -281,6 +487,11 @@ def main():
         test_stitch_images()
         test_stitch_images()
         ran_any = True
         ran_any = True
 
 
+    if args.nano_banana:
+        print()
+        test_nano_banana()
+        ran_any = True
+
     if args.create is not None:
     if args.create is not None:
         print()
         print()
         test_create_tool(args.create or None)
         test_create_tool(args.create or None)

+ 7 - 0
tools/local/flux/.env.example

@@ -0,0 +1,7 @@
+# https://docs.bfl.ai/quick_start/generating_images
+BFL_API_KEY=
+
+# 可选,默认全球端点(文档要求使用响应里的 polling_url)
+BFL_API_BASE=https://api.bfl.ai/v1
+# BFL_API_BASE=https://api.eu.bfl.ai/v1
+# BFL_API_BASE=https://api.us.bfl.ai/v1

+ 3 - 0
tools/local/flux/.gitignore

@@ -0,0 +1,3 @@
+.env
+.venv/
+__pycache__/

+ 95 - 0
tools/local/flux/bfl_client.py

@@ -0,0 +1,95 @@
+"""BFL FLUX HTTP 客户端 — 异步提交 + 轮询。
+
+文档: https://docs.bfl.ai/quick_start/generating_images
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+_ = load_dotenv()
+
+DEFAULT_API_BASE = "https://api.bfl.ai/v1"
+
+
+def _api_key() -> str:
+    key = os.environ.get("BFL_API_KEY", "").strip()
+    if not key:
+        raise ValueError("缺少环境变量 BFL_API_KEY")
+    return key
+
+
+def _headers() -> dict[str, str]:
+    return {
+        "accept": "application/json",
+        "x-key": _api_key(),
+        "Content-Type": "application/json",
+    }
+
+
+def submit_generation(
+    *,
+    model: str,
+    prompt: str,
+    width: int | None = None,
+    height: int | None = None,
+    parameters: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    """POST {BFL_API_BASE}/{model},返回含 id、polling_url 等(以 BFL 响应为准)。"""
+    base = os.environ.get("BFL_API_BASE", DEFAULT_API_BASE).rstrip("/")
+    model_path = model.strip().lstrip("/")
+    url = f"{base}/{model_path}"
+
+    body: dict[str, Any] = dict(parameters) if parameters else {}
+    body["prompt"] = prompt
+    if width is not None:
+        body["width"] = width
+    if height is not None:
+        body["height"] = height
+
+    with httpx.Client(timeout=120.0) as client:
+        r = client.post(url, headers=_headers(), json=body)
+        try:
+            data = r.json()
+        except Exception:
+            r.raise_for_status()
+            raise RuntimeError(r.text[:2000]) from None
+
+    if r.status_code >= 400:
+        err = data.get("detail") if isinstance(data, dict) else None
+        msg = err if err is not None else str(data)
+        raise RuntimeError(f"BFL HTTP {r.status_code}: {msg}")
+
+    if not isinstance(data, dict):
+        raise RuntimeError("提交响应不是 JSON 对象")
+    return data
+
+
+def poll_result(*, polling_url: str, request_id: str) -> dict[str, Any]:
+    """GET polling_url,Query: id=request_id(与官方示例一致)。"""
+    with httpx.Client(timeout=60.0) as client:
+        r = client.get(
+            polling_url.strip(),
+            headers={
+                "accept": "application/json",
+                "x-key": _api_key(),
+            },
+            params={"id": request_id.strip()},
+        )
+        try:
+            data = r.json()
+        except Exception:
+            r.raise_for_status()
+            raise RuntimeError(r.text[:2000]) from None
+
+    if r.status_code >= 400:
+        msg = data if isinstance(data, dict) else str(data)
+        raise RuntimeError(f"BFL poll HTTP {r.status_code}: {msg}")
+
+    if not isinstance(data, dict):
+        raise RuntimeError("轮询响应不是 JSON 对象")
+    return data

+ 87 - 0
tools/local/flux/main.py

@@ -0,0 +1,87 @@
+"""BFL FLUX 本地封装 — 异步生图(提交 + 轮询)。
+
+环境变量:
+  BFL_API_KEY   必填,请求头 x-key
+  BFL_API_BASE  可选,默认 https://api.bfl.ai/v1(全球端点;也可用 api.eu.bfl.ai/v1 等)
+
+接口:
+  GET  /health
+  POST /submit  提交生成;body.model 为端点路径段,如 flux-2-pro-preview
+  POST /query   轮询;polling_url、request_id 来自提交响应
+
+文档: https://docs.bfl.ai/quick_start/generating_images
+"""
+
+from __future__ import annotations
+
+import argparse
+from typing import Any
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from bfl_client import poll_result, submit_generation
+
+app = FastAPI(title="BFL FLUX API Proxy")
+
+
+class SubmitRequest(BaseModel):
+    model: str = Field(
+        ...,
+        description="模型端点路径段,如 flux-2-pro-preview、flux-2-max、flux-dev(对应 /v1/{model})",
+    )
+    prompt: str = Field(..., description="文生图提示词")
+    width: int | None = Field(default=None, description="输出宽度(像素)")
+    height: int | None = Field(default=None, description="输出高度(像素)")
+    parameters: dict[str, Any] | None = Field(
+        default=None,
+        description="合并进请求体的额外字段(官方各模型可选参数)",
+    )
+
+
+class QueryRequest(BaseModel):
+    polling_url: str = Field(..., description="提交响应中的 polling_url,须原样使用")
+    request_id: str = Field(..., description="提交响应中的 id")
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/submit")
+def submit(req: SubmitRequest) -> dict[str, Any]:
+    try:
+        return submit_generation(
+            model=req.model,
+            prompt=req.prompt,
+            width=req.width,
+            height=req.height,
+            parameters=req.parameters,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@app.post("/query")
+def query(req: QueryRequest) -> dict[str, Any]:
+    try:
+        return poll_result(polling_url=req.polling_url, request_id=req.request_id)
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8001)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 12 - 0
tools/local/flux/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "bfl-flux"
+version = "0.1.0"
+description = "BFL FLUX 异步生图:POST /submit、/query(x-key + polling_url)"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "pydantic>=2.0.0",
+    "python-dotenv>=1.0.0",
+    "httpx>=0.27.0",
+]

+ 12 - 0
tools/local/ji_meng/.env.example

@@ -0,0 +1,12 @@
+# 即梦(或兼容异步任务)上游 HTTP — 与 ji_meng_client.py 一致
+# 复制为 .env 后填写:cp .env.example .env
+
+# 必填:上游服务根 URL(不要末尾 /)
+JI_MENG_API_BASE=https://crawler.aiddit.com/crawler/ji_meng
+
+# 可选:Bearer Token;不需要则留空
+JI_MENG_API_KEY=
+
+# 可选:与默认不同时再改(相对 JI_MENG_API_BASE)
+JI_MENG_ADD_TASK_PATH=/add_task
+JI_MENG_QUERY_TASK_PATH=/query_task

+ 7 - 0
tools/local/ji_meng/.gitignore

@@ -0,0 +1,7 @@
+.venv/
+__pycache__/
+*.pyc
+.env
+# 历史 SQLite 占位遗留,勿提交
+ji_meng_tasks.db
+ji_meng_tasks.db-*

+ 1 - 0
tools/local/ji_meng/.python-version

@@ -0,0 +1 @@
+3.12

+ 49 - 0
tools/local/ji_meng/ji_meng_client.py

@@ -0,0 +1,49 @@
+"""即梦(或兼容的异步任务)上游 HTTP 客户端 — 与 liblibai_client 同类,无本地状态。"""
+
+from __future__ import annotations
+
+import os
+from typing import Any, Literal
+
+import requests
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+class JiMengClient:
+    """通过环境变量配置上游 base URL 与路径,将请求原样转发并返回 JSON。"""
+
+    def __init__(self) -> None:
+        self.base = (os.getenv("JI_MENG_API_BASE") or "https://crawler.aiddit.com/crawler/ji_meng").rstrip("/")
+        if not self.base:
+            raise ValueError("缺少环境变量 JI_MENG_API_BASE(上游服务根 URL,如 https://api.example.com)")
+        self.add_path = os.getenv("JI_MENG_ADD_TASK_PATH", "/add_task")
+        self.query_path = os.getenv("JI_MENG_QUERY_TASK_PATH", "/query_task")
+        self.api_key = os.getenv("JI_MENG_API_KEY", "").strip()
+
+    def _headers(self) -> dict[str, str]:
+        h = {"Content-Type": "application/json"}
+        if self.api_key:
+            h["Authorization"] = f"Bearer {self.api_key}"
+        return h
+
+    def submit_task(self, task_type: Literal['image', 'video'], prompt: str, image_url: str | None = None) -> dict[str, Any]:
+        url = f"{self.base}{self.add_path if self.add_path.startswith('/') else '/' + self.add_path}"
+        body: dict[str, Any] = {"task_type": task_type, "prompt": prompt}
+        if image_url:
+            body["image_url"] = image_url
+        resp = requests.post(url, json=body, headers=self._headers(), timeout=120)
+        resp.raise_for_status()
+        return resp.json()
+
+    def query_task(self, task_id: str) -> dict[str, Any]:
+        url = f"{self.base}{self.query_path if self.query_path.startswith('/') else '/' + self.query_path}"
+        resp = requests.post(
+            url,
+            json={"task_id": task_id},
+            headers=self._headers(),
+            timeout=60,
+        )
+        resp.raise_for_status()
+        return resp.json()

+ 71 - 0
tools/local/ji_meng/main.py

@@ -0,0 +1,71 @@
+"""即梦任务工具 — FastAPI 调用层(范本同 liblibai_controlnet)。
+
+无本地缓存:/add_task、/query_task 直接转发到上游 HTTP(见 ji_meng_client.py)。
+
+环境变量:
+  JI_MENG_API_BASE       必填,上游根 URL
+  JI_MENG_ADD_TASK_PATH  可选,默认 /add_task
+  JI_MENG_QUERY_TASK_PATH 可选,默认 /query_task
+  JI_MENG_API_KEY        可选,Bearer Token
+
+注册(由 Agent 写入 registry + sources):
+  tool_id=ji_meng_add_task / ji_meng_query_task,host_dir=tools/local/ji_meng,
+  endpoint_path 分别为 /add_task、/query_task。
+"""
+
+from __future__ import annotations
+
+import argparse
+from typing import Literal
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from ji_meng_client import JiMengClient
+
+app = FastAPI(title="Ji Meng Task API")
+
+
+class AddTaskRequest(BaseModel):
+    task_type: Literal['image', 'video'] = Field(default=..., description="任务类型")
+    prompt: str = Field(..., description="任务描述 / 提示词")
+    image_url: str | None = Field(default=None, description="图片 URL")
+
+
+class QueryTaskRequest(BaseModel):
+    task_id: str = Field(..., description="创建任务接口返回的任务 ID")
+
+
+@app.get("/health")
+def health() -> dict:
+    return {"status": "ok"}
+
+
+@app.post("/add_task")
+def add_task(req: AddTaskRequest) -> dict:
+    try:
+        client = JiMengClient()
+        return client.submit_task(task_type=req.task_type, prompt=req.prompt, image_url=req.image_url)
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@app.post("/query_task")
+def query_task(req: QueryTaskRequest) -> dict:
+    try:
+        client = JiMengClient()
+        return client.query_task(req.task_id)
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8001)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 12 - 0
tools/local/ji_meng/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "ji-meng"
+version = "0.1.0"
+description = "即梦任务调用层:POST /add_task、/query_task,转发上游 HTTP(无本地状态)"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "pydantic>=2.0.0",
+    "python-dotenv>=1.0.0",
+    "requests>=2.32.0",
+]

+ 2 - 0
tools/local/midjourney/.env.example

@@ -0,0 +1,2 @@
+# 你已部署的 Midjourney 服务根地址(将请求 POST 到 {BASE}/submit_job 等)
+MIDJOURNEY_API_BASE=https://your-mj-api.example.com

+ 3 - 0
tools/local/midjourney/.gitignore

@@ -0,0 +1,3 @@
+.env
+.venv/
+__pycache__/

+ 99 - 0
tools/local/midjourney/main.py

@@ -0,0 +1,99 @@
+"""Midjourney 本地代理 — 三个 POST 与上游 JSON 对齐。
+
+环境变量:
+  MIDJOURNEY_API_BASE  必填,例如 https://your-host(后接 /submit_job 等)
+
+接口(与 Router 注册一致):
+  GET  /health
+  POST /submit_job        cookie, prompt, user_id, mode ∈ relaxed|fast
+  POST /query_job_status  cookie, job_id
+  POST /get_image_urls    job_id
+"""
+
+from __future__ import annotations
+
+import argparse
+from typing import Any, Literal
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from midjourney_client import forward_post
+
+app = FastAPI(title="Midjourney API Proxy")
+
+
+class SubmitJobRequest(BaseModel):
+    cookie: str = Field(..., description="Midjourney 会话 cookie")
+    prompt: str = Field(..., description="提示词")
+    user_id: str = Field(..., description="用户 ID")
+    mode: Literal["relaxed", "fast"] = Field(..., description="relaxed 或 fast")
+
+
+class QueryJobStatusRequest(BaseModel):
+    cookie: str = Field(..., description="Midjourney 会话 cookie")
+    job_id: str = Field(..., description="submit_job 返回的任务 ID")
+
+
+class GetImageUrlsRequest(BaseModel):
+    job_id: str = Field(..., description="任务 ID")
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/submit_job")
+def submit_job(req: SubmitJobRequest) -> Any:
+    try:
+        return forward_post(
+            "/submit_job",
+            {
+                "cookie": req.cookie,
+                "prompt": req.prompt,
+                "user_id": req.user_id,
+                "mode": req.mode,
+            },
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@app.post("/query_job_status")
+def query_job_status(req: QueryJobStatusRequest) -> Any:
+    try:
+        return forward_post(
+            "/query_job_status",
+            {"cookie": req.cookie, "job_id": req.job_id},
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@app.post("/get_image_urls")
+def get_image_urls(req: GetImageUrlsRequest) -> Any:
+    try:
+        return forward_post("/get_image_urls", {"job_id": req.job_id})
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8001)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 46 - 0
tools/local/midjourney/midjourney_client.py

@@ -0,0 +1,46 @@
+"""将请求转发到自建 Midjourney HTTP 服务(与 tools/local 约定一致)。"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+_ = load_dotenv()
+
+
+def _base_url() -> str:
+    base = os.environ.get("MIDJOURNEY_API_BASE", "").strip().rstrip("/")
+    if not base:
+        raise ValueError("缺少环境变量 MIDJOURNEY_API_BASE(上游根 URL,不含尾路径)")
+    return base
+
+
+def forward_post(path: str, json_body: dict[str, Any]) -> Any:
+    """POST {MIDJOURNEY_API_BASE}{path},Content-Type: application/json。"""
+    url = f"{_base_url()}{path if path.startswith('/') else '/' + path}"
+    with httpx.Client(timeout=300.0) as client:
+        r = client.post(
+            url,
+            json=json_body,
+            headers={"accept": "application/json", "Content-Type": "application/json"},
+        )
+        ct = (r.headers.get("content-type") or "").lower()
+        if "application/json" not in ct:
+            r.raise_for_status()
+            raise RuntimeError(f"非 JSON 响应 ({r.status_code}): {r.text[:1500]}")
+        try:
+            data = r.json()
+        except Exception:
+            raise RuntimeError(f"无效 JSON ({r.status_code}): {r.text[:1500]}") from None
+
+    if r.status_code >= 400:
+        if isinstance(data, dict):
+            msg = data.get("detail", data.get("message", data.get("error", str(data))))
+        else:
+            msg = str(data)
+        raise RuntimeError(f"上游 HTTP {r.status_code}: {msg}")
+
+    return data

+ 12 - 0
tools/local/midjourney/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "midjourney-proxy"
+version = "0.1.0"
+description = "Midjourney 上游 HTTP 代理:submit_job / query_job_status / get_image_urls"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "pydantic>=2.0.0",
+    "python-dotenv>=1.0.0",
+    "httpx>=0.27.0",
+]

+ 8 - 0
tools/local/nano_banana/.env.example

@@ -0,0 +1,8 @@
+# 必填:https://ai.google.dev/gemini-api/docs/image-generation
+GEMINI_API_KEY=
+
+# 未在请求体传 model 时使用(默认 gemini-2.5-flash-image)
+# GEMINI_IMAGE_MODEL=gemini-3.1-flash-image-preview
+
+# 可选,一般无需修改
+GEMINI_API_BASE=https://airouter.piaoquantv.com/v1beta

+ 4 - 0
tools/local/nano_banana/.gitignore

@@ -0,0 +1,4 @@
+.venv/
+__pycache__/
+*.pyc
+.env

+ 1 - 0
tools/local/nano_banana/.python-version

@@ -0,0 +1 @@
+3.12

+ 149 - 0
tools/local/nano_banana/gemini_image_client.py

@@ -0,0 +1,149 @@
+"""Gemini 原生图模 — REST `generateContent`(与官方文档一致,无 SDK)。
+
+参考: https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn#rest
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+_ = load_dotenv()
+
+DEFAULT_MODEL = "gemini-2.5-flash-image"
+GEMINI_API_BASE = os.environ.get(
+    "GEMINI_API_BASE", "https://airouter.piaoquantv.com/v1beta"
+)
+
+_DATA_URL_RE = re.compile(r"^data:[^;]+;base64,(.+)$", re.I | re.S)
+
+
+def _strip_data_url(b64_or_data_url: str) -> str:
+    s = b64_or_data_url.strip()
+    m = _DATA_URL_RE.match(s)
+    return m.group(1) if m else s
+
+
+def _build_parts(
+    prompt: str,
+    images: list[dict[str, str]] | None,
+) -> list[dict[str, Any]]:
+    parts: list[dict[str, Any]] = [{"text": prompt}]
+    if not images:
+        return parts
+    for img in images:
+        mime = (img.get("mime_type") or img.get("mimeType") or "image/png").strip()
+        raw = _strip_data_url(img.get("data") or "")
+        if not raw:
+            raise ValueError("images[].data 不能为空(Base64 或 data URL)")
+        parts.append({"inline_data": {"mime_type": mime, "data": raw}})
+    return parts
+
+
+def _merge_generation_config(
+    *,
+    aspect_ratio: str | None,
+    image_size: str | None,
+    response_modalities: list[str] | None,
+) -> dict[str, Any] | None:
+    cfg: dict[str, Any] = {}
+    if response_modalities:
+        cfg["responseModalities"] = response_modalities
+    img_cfg: dict[str, str] = {}
+    if aspect_ratio:
+        img_cfg["aspectRatio"] = aspect_ratio.strip()
+    if image_size:
+        img_cfg["imageSize"] = image_size.strip()
+    if img_cfg:
+        cfg["imageConfig"] = img_cfg
+    return cfg or None
+
+
+def generate_content(
+    *,
+    prompt: str,
+    model: str | None,
+    aspect_ratio: str | None = None,
+    image_size: str | None = None,
+    response_modalities: list[str] | None = None,
+    images: list[dict[str, str]] | None = None,
+) -> dict[str, Any]:
+    api_key = os.environ.get("GEMINI_API_KEY", "").strip()
+    if not api_key:
+        raise ValueError("缺少环境变量 GEMINI_API_KEY")
+
+    resolved = (model or os.environ.get("GEMINI_IMAGE_MODEL") or DEFAULT_MODEL).strip()
+    url = f"{GEMINI_API_BASE.rstrip('/')}/models/{resolved}:generateContent"
+
+    body: dict[str, Any] = {
+        "contents": [
+            {
+                "role": "user",
+                "parts": _build_parts(prompt, images),
+            }
+        ],
+    }
+    gen_cfg = _merge_generation_config(
+        aspect_ratio=aspect_ratio,
+        image_size=image_size,
+        response_modalities=response_modalities,
+    )
+    if gen_cfg:
+        body["generationConfig"] = gen_cfg
+
+    headers = {
+        "x-goog-api-key": api_key,
+        "Content-Type": "application/json",
+    }
+
+    with httpx.Client(timeout=300.0) as client:
+        r = client.post(url, headers=headers, json=body)
+        try:
+            data = r.json()
+        except Exception:
+            r.raise_for_status()
+            raise RuntimeError(r.text[:2000]) from None
+
+    if r.status_code >= 400:
+        err = data.get("error") if isinstance(data, dict) else None
+        msg = err.get("message", str(data)) if isinstance(err, dict) else str(data)
+        raise RuntimeError(f"Gemini HTTP {r.status_code}: {msg}")
+
+    if not isinstance(data, dict):
+        raise RuntimeError("响应不是 JSON 对象")
+
+    if data.get("error"):
+        raise RuntimeError(str(data["error"]))
+
+    images_out: list[str] = []
+    texts: list[str] = []
+    for cand in data.get("candidates") or []:
+        if not isinstance(cand, dict):
+            continue
+        for part in cand.get("content", {}).get("parts") or []:
+            if not isinstance(part, dict):
+                continue
+            if part.get("text"):
+                texts.append(str(part["text"]))
+            inline = part.get("inlineData") or part.get("inline_data")
+            if isinstance(inline, dict):
+                b64 = inline.get("data")
+                if b64:
+                    mime = (
+                        inline.get("mimeType")
+                        or inline.get("mime_type")
+                        or "image/png"
+                    )
+                    images_out.append(f"data:{mime};base64,{b64}")
+
+    out: dict[str, Any] = {
+        "images": images_out,
+        "model": resolved,
+    }
+    if texts:
+        out["text"] = "\n".join(texts)
+    return out

+ 93 - 0
tools/local/nano_banana/main.py

@@ -0,0 +1,93 @@
+"""nano_banana — 本地 HTTP 封装 Gemini 原生图模(REST generateContent)。
+
+环境变量:
+  GEMINI_API_KEY      必填,对应文档中的 x-goog-api-key
+  GEMINI_IMAGE_MODEL  可选,未在请求体指定 model 时使用,默认 gemini-2.5-flash-image
+  GEMINI_API_BASE     可选,默认 https://generativelanguage.googleapis.com/v1beta
+
+接口:
+  GET  /health
+  POST /generate     文生图 / 图+文生图,字段与 registry input_schema 对齐
+
+文档: https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn#rest
+"""
+
+from __future__ import annotations
+
+import argparse
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from gemini_image_client import generate_content
+
+app = FastAPI(title="Nano Banana — Gemini Image (REST)")
+
+
+class ImageInput(BaseModel):
+    """参考图:Base64 或 data URL;字段名与 REST inline_data 对应。"""
+
+    mime_type: str = Field(default="image/png", description="如 image/png、image/jpeg")
+    data: str = Field(..., description="图片 Base64,或 data:image/...;base64,...")
+
+
+class GenerateRequest(BaseModel):
+    prompt: str = Field(..., description="主提示词")
+    model: str | None = Field(
+        default=None,
+        description=(
+            "模型 ID,如 gemini-2.5-flash-image、gemini-3.1-flash-image-preview;"
+            "省略则使用 GEMINI_IMAGE_MODEL / 内置默认"
+        ),
+    )
+    aspect_ratio: str | None = Field(
+        default=None,
+        description='宽高比,如 "1:1"、"16:9"(见官方文档 imageConfig.aspectRatio)',
+    )
+    image_size: str | None = Field(
+        default=None,
+        description='Gemini 3.x 输出分辨率:512、1K、2K、4K(generationConfig.imageConfig.imageSize)',
+    )
+    response_modalities: list[str] | None = Field(
+        default=None,
+        description='如 ["TEXT","IMAGE"] 或 ["IMAGE"];省略则由 API 默认',
+    )
+    images: list[ImageInput] | None = Field(
+        default=None,
+        description="可选参考图列表(图生图 / 编辑),对应 REST parts 中的 inline_data",
+    )
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/generate")
+def generate(req: GenerateRequest) -> dict:
+    try:
+        imgs = None
+        if req.images:
+            imgs = [{"mime_type": i.mime_type, "data": i.data} for i in req.images]
+        return generate_content(
+            prompt=req.prompt,
+            model=req.model,
+            aspect_ratio=req.aspect_ratio,
+            image_size=req.image_size,
+            response_modalities=req.response_modalities,
+            images=imgs,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except RuntimeError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=8001)
+    args = parser.parse_args()
+    uvicorn.run(app, host="0.0.0.0", port=args.port)

+ 12 - 0
tools/local/nano_banana/pyproject.toml

@@ -0,0 +1,12 @@
+[project]
+name = "nano-banana"
+version = "0.1.0"
+description = "Gemini 原生图模 REST 封装:POST /generate(generateContent)"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "pydantic>=2.0.0",
+    "python-dotenv>=1.0.0",
+    "httpx>=0.27.0",
+]

+ 6 - 0
tools/local/nano_banana/tests/server.log

@@ -0,0 +1,6 @@
+INFO:     Started server process [44982]
+INFO:     Waiting for application startup.
+INFO:     Application startup complete.
+INFO:     Uvicorn running on http://0.0.0.0:57891 (Press CTRL+C to quit)
+INFO:     127.0.0.1:57895 - "POST /generate HTTP/1.1" 200 OK
+INFO:     127.0.0.1:58843 - "POST /generate HTTP/1.1" 200 OK

Разлика између датотеке није приказан због своје велике величине
+ 508 - 413
uv.lock


Неке датотеке нису приказане због велике количине промена