已合并
feat: CLI new/deploy 命令重构及模板工程支持 #111
王明琦创建于 4月21日
feat: CLI new/deploy 命令重构及模板工程支持 #111
已合并
共 5 个文件变更+918-66
| @@ -1,2 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -8,11 +8,25 @@ | |||
| 8 | 8 | ||
| 9 | import asyncio | 9 | import asyncio |
| 10 | import json | 10 | import json |
| 11 | +import os | ||
| 12 | +import re | ||
| 13 | +import subprocess | ||
| 11 | from pathlib import Path | 14 | from pathlib import Path |
| 12 | 15 | ||
| 13 | import click | 16 | import click |
| 14 | from dotenv import load_dotenv | 17 | from dotenv import load_dotenv |
| 15 | 18 | ||
| 19 | +from openjiuwen_runtime.cli.templates import get_pyproject, get_init, get_main, get_runner | ||
| 20 | + | ||
| 21 | +# 加载全局配置(固定位置:~/.openjiuwen/.env) | ||
| 22 | +_GLOBAL_ENV = Path.home() / ".openjiuwen" / ".env" | ||
| 23 | +if _GLOBAL_ENV.exists(): | ||
| 24 | + load_dotenv(_GLOBAL_ENV, override=False) | ||
| 25 | + | ||
| 26 | +# 注入默认值,确保 Settings 校验通过(需在 import management 之前) | ||
| 27 | +os.environ.setdefault("IP", "127.0.0.1") | ||
| 28 | +os.environ.setdefault("LOWCODE_IMAGE", "") | ||
| 29 | + | ||
| 16 | from openjiuwen_runtime.management import ( | 30 | from openjiuwen_runtime.management import ( |
| 17 | DeployAgentParams, | 31 | DeployAgentParams, |
| 18 | DeployPluginParams, | 32 | DeployPluginParams, |
| @@ -22,10 +36,60 @@ from openjiuwen_runtime.management import ( | |||
| 22 | from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus | 36 | from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus |
| 23 | from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler | 37 | from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler |
| 24 | 38 | ||
| 25 | -# 加载.env配置文件(固定位置:manager根目录) | 39 | + |
| 26 | -_manager_root = Path(__file__).parent.parent.parent | 40 | +def _resolve_module_path(project_dir: Path) -> str: |
| 27 | -_env_file = _manager_root / ".env" | 41 | + """从项目目录推导完整 Python 模块路径。 |
| 28 | -load_dotenv(_env_file) | 42 | + |
| 43 | + 扫描 openjiuwen_runtime/ 下的子目录, | ||
| 44 | + 返回 'openjiuwen_runtime.xxx' 形式的模块路径。 | ||
| 45 | + """ | ||
| 46 | + pkg_root = project_dir / "openjiuwen_runtime" | ||
| 47 | + if not pkg_root.exists(): | ||
| 48 | + raise click.ClickException( | ||
| 49 | + f"项目目录 {project_dir} 下未找到 openjiuwen_runtime/ 结构" | ||
| 50 | + ) | ||
| 51 | + for child in sorted(pkg_root.iterdir()): | ||
| 52 | + if child.is_dir() and (child / "__main__.py").exists(): | ||
| 53 | + return f"openjiuwen_runtime.{child.name}" | ||
| 54 | + raise click.ClickException( | ||
| 55 | + f"在 {pkg_root} 下未找到包含 __main__.py 的子目录" | ||
| 56 | + ) | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +def _build_project(project_dir: Path) -> Path: | ||
| 60 | + """构建项目 WHL 包,返回 WHL 文件路径。""" | ||
| 61 | + pyproject = project_dir / "pyproject.toml" | ||
| 62 | + if not pyproject.exists(): | ||
| 63 | + raise click.ClickException(f"项目目录 {project_dir} 下未找到 pyproject.toml") | ||
| 64 | + | ||
| 65 | + dist_dir = Path(os.getenv("DIST_DIR", "dist")).resolve() | ||
| 66 | + dist_dir.mkdir(parents=True, exist_ok=True) | ||
| 67 | + | ||
| 68 | + click.echo(f"Building project: {project_dir}") | ||
| 69 | + click.echo(f"Output dist: {dist_dir}") | ||
| 70 | + | ||
| 71 | + try: | ||
| 72 | + result = subprocess.run( | ||
| 73 | + ["uv", "build", str(project_dir), "--out-dir", str(dist_dir)], | ||
| 74 | + capture_output=True, text=True, timeout=300, | ||
| 75 | + ) | ||
| 76 | + except FileNotFoundError: | ||
| 77 | + raise click.ClickException("uv not found, please install uv first") | ||
| 78 | + except subprocess.TimeoutExpired: | ||
| 79 | + raise click.ClickException("Build timed out (300s)") | ||
| 80 | + | ||
| 81 | + if result.returncode != 0: | ||
| 82 | + click.echo(result.stderr, err=True) | ||
| 83 | + raise click.ClickException("Build failed") | ||
| 84 | + | ||
| 85 | + # 找到刚构建的 WHL 文件(按修改时间取最新的) | ||
| 86 | + whl_files = sorted(dist_dir.glob("*.whl"), key=lambda f: f.stat().st_mtime, reverse=True) | ||
| 87 | + if not whl_files: | ||
| 88 | + raise click.ClickException(f"Build succeeded but no WHL file found in {dist_dir}") | ||
| 89 | + | ||
| 90 | + whl_path = whl_files[0] | ||
| 91 | + click.echo(f"Build succeeded: {whl_path.name}") | ||
| 92 | + return whl_path | ||
| 29 | 93 | ||
| 30 | 94 | ||
| 31 | 95 | ||
| @@ -44,7 +108,7 @@ def cli(ctx): | |||
| 44 | asyncio.run(manager.initialize()) | 108 | asyncio.run(manager.initialize()) |
| 45 | 109 | ||
| 46 | 110 | ||
| 47 | -@cli.resultcallback() | 111 | +@cli.result_callback() |
| 48 | 112 | ||
| 49 | def cleanup(ctx, result, **kwargs): | 113 | def cleanup(ctx, result, **kwargs): |
| 50 | """清理资源""" | 114 | """清理资源""" |
| @@ -61,32 +125,36 @@ def agent(): | |||
| 61 | 125 | ||
| 62 | 126 | ||
| 63 | 127 | ||
| 64 | -@click.argument("python_file_path", type=click.Path(exists=True)) | 128 | +@click.argument("project_dir", type=click.Path(exists=True)) |
| 65 | - | ||
| 66 | 129 | ||
| 67 | 130 | ||
| 68 | -def deploy(ctx, python_file_path, name, port): | 131 | +def deploy(ctx, project_dir, port): |
| 69 | - """部署 Agent(使用 Python 文件) | 132 | + """部署 Agent(使用项目目录) |
| 70 | 133 | ||
| 71 | \b | 134 | \b |
| 72 | - Manager SDK 内部自动将 Python 文件打包为 WHL 包。 | 135 | + 传入包含 pyproject.toml 的项目目录,CLI 会自动构建 WHL 包并部署。 |
| 73 | - name 参数既是部署名称,也是打包的包名。 | ||
| 74 | 136 | ||
| 75 | 示例: | 137 | 示例: |
| 76 | - agent-runtime agent deploy ./my_agent.py --name my_agent | 138 | + agent-runtime agent deploy ./my_agent |
| 77 | - agent-runtime agent deploy ./my_agent.py --name my_agent --port 8090 | 139 | + agent-runtime agent deploy ./my_agent --port 8090 |
| 78 | """ | 140 | """ |
| 79 | manager = ctx.obj["manager"] | 141 | manager = ctx.obj["manager"] |
| 142 | + project_path = Path(project_dir).resolve() | ||
| 143 | + module_path = _resolve_module_path(project_path) | ||
| 144 | + | ||
| 145 | + # 构建 WHL 包 | ||
| 146 | + whl_path = _build_project(project_path) | ||
| 80 | 147 | ||
| 81 | async def _deploy(): | 148 | async def _deploy(): |
| 82 | result = await manager.deploy_agent( | 149 | result = await manager.deploy_agent( |
| 83 | DeployAgentParams( | 150 | DeployAgentParams( |
| 84 | - name=name, | 151 | + name=module_path, |
| 85 | version="1.0.0", | 152 | version="1.0.0", |
| 86 | - extras={"python_file_path": python_file_path, "port": port}, | 153 | + extras={"port": port, "whl_path": str(whl_path)}, |
| 87 | ) | 154 | ) |
| 88 | ) | 155 | ) |
| 89 | - click.echo(json.dumps(result, indent=2, ensure_ascii=False)) | 156 | + data = result.model_dump(mode="json") if hasattr(result, "model_dump") else result |
| 157 | + click.echo(json.dumps(data, indent=2, ensure_ascii=False)) | ||
| 90 | 158 | ||
| 91 | asyncio.run(_deploy()) | 159 | asyncio.run(_deploy()) |
| 92 | 160 | ||
| @@ -111,16 +179,17 @@ def list_deployments(ctx, status): | |||
| 111 | return | 179 | return |
| 112 | 180 | ||
| 113 | # 显示简略信息 | 181 | # 显示简略信息 |
| 114 | - click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}") | 182 | + click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'URL'}") |
| 115 | click.echo("-" * 120) | 183 | click.echo("-" * 120) |
| 116 | for dep in deployments: | 184 | for dep in deployments: |
| 117 | - name = dep.get("name") or "-" | 185 | + name = dep.name or "-" |
| 118 | - dep_status = dep["status"].value if hasattr(dep["status"], "value") else str(dep["status"]) | 186 | + dep_status = dep.deployment_status.value |
| 119 | - package_name = dep.get("package_name") or "-" | 187 | + url = dep.url or "-" |
| 120 | - url = dep.get("url") or "-" | 188 | + data = dep.data or {} |
| 189 | + port = data.get("port", "-") | ||
| 121 | row = ( | 190 | row = ( |
| 122 | - f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} " | 191 | + f"{dep.deployment_id:<30} {name:<20} {dep_status:<10} " |
| 123 | - f"{dep['port']:<6} {package_name:<20} {url:<30}" | 192 | + f"{port:<6} {url}" |
| 124 | ) | 193 | ) |
| 125 | click.echo(row) | 194 | click.echo(row) |
| 126 | 195 | ||
| @@ -137,7 +206,7 @@ def get(ctx, deployment_id): | |||
| 137 | async def _get(): | 206 | async def _get(): |
| 138 | deployment = await manager.get_deployment(deployment_id) | 207 | deployment = await manager.get_deployment(deployment_id) |
| 139 | if deployment: | 208 | if deployment: |
| 140 | - click.echo(json.dumps(deployment, indent=2, ensure_ascii=False)) | 209 | + click.echo(json.dumps(deployment.model_dump(mode="json"), indent=2, ensure_ascii=False)) |
| 141 | else: | 210 | else: |
| 142 | click.echo(f"Deployment {deployment_id} not found", err=True) | 211 | click.echo(f"Deployment {deployment_id} not found", err=True) |
| 143 | raise click.Abort() | 212 | raise click.Abort() |
| @@ -172,32 +241,35 @@ def plugin(): | |||
| 172 | 241 | ||
| 173 | 242 | ||
| 174 | 243 | ||
| 175 | -@click.argument("python_file_path", type=click.Path(exists=True)) | 244 | +@click.argument("project_dir", type=click.Path(exists=True)) |
| 176 | - | ||
| 177 | 245 | ||
| 178 | 246 | ||
| 179 | -def deploy(ctx, python_file_path, name, port): | 247 | +def deploy(ctx, project_dir, port): |
| 180 | - """部署 Plugin(使用 Python 文件) | 248 | + """部署 Plugin(使用项目目录) |
| 181 | 249 | ||
| 182 | \b | 250 | \b |
| 183 | - Manager SDK 内部自动将 Python 文件打包为 WHL 包。 | 251 | + 传入包含 pyproject.toml 的项目目录,CLI 会自动构建 WHL 包并部署。 |
| 184 | - name 参数既是部署名称,也是打包的包名。 | ||
| 185 | 252 | ||
| 186 | 示例: | 253 | 示例: |
| 187 | - agent-runtime plugin deploy ./my_plugin.py --name my_plugin | 254 | + agent-runtime plugin deploy ./my_plugin |
| 188 | - agent-runtime plugin deploy ./my_plugin.py --name my_plugin --port 8091 | 255 | + agent-runtime plugin deploy ./my_plugin --port 8091 |
| 189 | """ | 256 | """ |
| 190 | manager = ctx.obj["manager"] | 257 | manager = ctx.obj["manager"] |
| 258 | + project_path = Path(project_dir).resolve() | ||
| 259 | + module_path = _resolve_module_path(project_path) | ||
| 260 | + | ||
| 261 | + whl_path = _build_project(project_path) | ||
| 191 | 262 | ||
| 192 | async def _deploy(): | 263 | async def _deploy(): |
| 193 | result = await manager.deploy_plugin( | 264 | result = await manager.deploy_plugin( |
| 194 | DeployPluginParams( | 265 | DeployPluginParams( |
| 195 | - name=name, | 266 | + name=module_path, |
| 196 | version="1.0.0", | 267 | version="1.0.0", |
| 197 | - extras={"python_file_path": python_file_path, "port": port}, | 268 | + extras={"port": port, "whl_path": str(whl_path)}, |
| 198 | ) | 269 | ) |
| 199 | ) | 270 | ) |
| 200 | - click.echo(json.dumps(result, indent=2, ensure_ascii=False)) | 271 | + data = result.model_dump(mode="json") if hasattr(result, "model_dump") else result |
| 272 | + click.echo(json.dumps(data, indent=2, ensure_ascii=False)) | ||
| 201 | 273 | ||
| 202 | asyncio.run(_deploy()) | 274 | asyncio.run(_deploy()) |
| 203 | 275 | ||
| @@ -221,17 +293,17 @@ def list_plugin_deployments(ctx, status): | |||
| 221 | click.echo("No deployments found") | 293 | click.echo("No deployments found") |
| 222 | return | 294 | return |
| 223 | 295 | ||
| 224 | - # 显示简略信息 | 296 | + click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'URL'}") |
| 225 | - click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}") | ||
| 226 | click.echo("-" * 120) | 297 | click.echo("-" * 120) |
| 227 | for dep in deployments: | 298 | for dep in deployments: |
| 228 | - name = dep.get("name") or "-" | 299 | + name = dep.name or "-" |
| 229 | - dep_status = dep["status"].value if hasattr(dep["status"], "value") else str(dep["status"]) | 300 | + dep_status = dep.deployment_status.value |
| 230 | - package_name = dep.get("package_name") or "-" | 301 | + url = dep.url or "-" |
| 231 | - url = dep.get("url") or "-" | 302 | + data = dep.data or {} |
| 303 | + port = data.get("port", "-") | ||
| 232 | row = ( | 304 | row = ( |
| 233 | - f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} " | 305 | + f"{dep.deployment_id:<30} {name:<20} {dep_status:<10} " |
| 234 | - f"{dep['port']:<6} {package_name:<20} {url:<30}" | 306 | + f"{port:<6} {url}" |
| 235 | ) | 307 | ) |
| 236 | click.echo(row) | 308 | click.echo(row) |
| 237 | 309 | ||
| @@ -248,7 +320,7 @@ def get(ctx, deployment_id): | |||
| 248 | async def _get(): | 320 | async def _get(): |
| 249 | deployment = await manager.get_deployment(deployment_id) | 321 | deployment = await manager.get_deployment(deployment_id) |
| 250 | if deployment: | 322 | if deployment: |
| 251 | - click.echo(json.dumps(deployment, indent=2, ensure_ascii=False)) | 323 | + click.echo(json.dumps(deployment.model_dump(mode="json"), indent=2, ensure_ascii=False)) |
| 252 | else: | 324 | else: |
| 253 | click.echo(f"Deployment {deployment_id} not found", err=True) | 325 | click.echo(f"Deployment {deployment_id} not found", err=True) |
| 254 | raise click.Abort() | 326 | raise click.Abort() |
| @@ -274,5 +346,72 @@ def delete(ctx, deployment_id): | |||
| 274 | asyncio.run(_delete()) | 346 | asyncio.run(_delete()) |
| 275 | 347 | ||
| 276 | 348 | ||
| 349 | +# ==================== New 命令 ==================== | ||
| 350 | + | ||
| 351 | + | ||
| 352 | +def new(): | ||
| 353 | + """创建新工程""" | ||
| 354 | + pass | ||
| 355 | + | ||
| 356 | + | ||
| 357 | + | ||
| 358 | + | ||
| 359 | + | ||
| 360 | + type=click.Choice(["empty", "react", "workflow"]), | ||
| 361 | + default="empty", help="模板类型 (默认: empty)") | ||
| 362 | + | ||
| 363 | +def agent(name, template_type, output_dir): | ||
| 364 | + """创建 Agent 模板工程 | ||
| 365 | + | ||
| 366 | + \b | ||
| 367 | + 模板类型: | ||
| 368 | + empty - 空白 Agent,回显消息,无 LLM | ||
| 369 | + react - ReAct Agent,带 LLM 和示例工具 | ||
| 370 | + workflow - Workflow Agent,带 LLM 和最简工作流 | ||
| 371 | + | ||
| 372 | + \b | ||
| 373 | + 示例: | ||
| 374 | + openjiuwen new agent my_agent | ||
| 375 | + openjiuwen new agent my_agent --template react | ||
| 376 | + openjiuwen new agent my_agent --template workflow --output-dir ./projects | ||
| 377 | + """ | ||
| 378 | + # 校验工程名 | ||
| 379 | + if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name): | ||
| 380 | + click.echo(f"错误:工程名 '{name}' 不合法,仅允许字母开头,包含字母、数字、下划线、连字符", err=True) | ||
| 381 | + raise click.Abort() | ||
| 382 | + | ||
| 383 | + # 转换为合法 Python 包名 | ||
| 384 | + pkg_name = name.replace("-", "_") | ||
| 385 | + project_dir = Path(output_dir) / name | ||
| 386 | + pkg_dir = project_dir / "openjiuwen_runtime" / pkg_name | ||
| 387 | + | ||
| 388 | + # 检查目标目录是否已存在 | ||
| 389 | + if project_dir.exists(): | ||
| 390 | + click.echo(f"错误:目录 {project_dir} 已存在", err=True) | ||
| 391 | + raise click.Abort() | ||
| 392 | + | ||
| 393 | + # 创建目录结构 | ||
| 394 | + pkg_dir.mkdir(parents=True, exist_ok=True) | ||
| 395 | + | ||
| 396 | + # 写入文件 | ||
| 397 | + (project_dir / "pyproject.toml").write_text( | ||
| 398 | + get_pyproject(name, pkg_name, template_type), encoding="utf-8" | ||
| 399 | + ) | ||
| 400 | + (pkg_dir / "__init__.py").write_text(get_init(), encoding="utf-8") | ||
| 401 | + (pkg_dir / "__main__.py").write_text(get_main(pkg_name), encoding="utf-8") | ||
| 402 | + (pkg_dir / f"{pkg_name}_runner.py").write_text( | ||
| 403 | + get_runner(pkg_name, template_type), encoding="utf-8" | ||
| 404 | + ) | ||
| 405 | + | ||
| 406 | + # 输出结果 | ||
| 407 | + click.echo(f"Agent template created: {project_dir}") | ||
| 408 | + click.echo(f" 模板类型: {template_type}") | ||
| 409 | + click.echo() | ||
| 410 | + click.echo("下一步:") | ||
| 411 | + click.echo(f" 1. cd {project_dir}") | ||
| 412 | + click.echo(f" 2. 编辑 {pkg_name}_runner.py 添加业务逻辑") | ||
| 413 | + click.echo(f" 3. 部署: openjiuwen agent deploy {project_dir}") | ||
| 414 | + | ||
| 415 | + | ||
| 277 | if __name__ == "__main__": | 416 | if __name__ == "__main__": |
| 278 | cli(obj={}) | 417 | cli(obj={}) |
| @@ -0,0 +1,562 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Agent 模板内容生成器 | ||
| 5 | + | ||
| 6 | +为 openjiuwen new agent 命令提供三种模板类型: | ||
| 7 | +- empty: 空白 Agent,回显用户消息,无 LLM / 无工具 | ||
| 8 | +- react: ReAct Agent,带 LLM 和示例工具 | ||
| 9 | +- workflow: Workflow Agent,带 LLM 和最简工作流 | ||
| 10 | +""" | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def get_pyproject(project_name: str, pkg_name: str, template_type: str) -> str: | ||
| 14 | + """生成 pyproject.toml 内容。 | ||
| 15 | + | ||
| 16 | + Args: | ||
| 17 | + project_name: 连字符形式的工程名 (如 my-agent) | ||
| 18 | + pkg_name: 下划线形式的包名 (如 my_agent) | ||
| 19 | + template_type: empty | react | workflow | ||
| 20 | + """ | ||
| 21 | + needs_openjiuwen = template_type in ("react", "workflow") | ||
| 22 | + deps = [ | ||
| 23 | + '"fastapi==0.115.11"', | ||
| 24 | + '"uvicorn[standard]==0.42.0"', | ||
| 25 | + '"pydantic==2.11.7"', | ||
| 26 | + ] | ||
| 27 | + if needs_openjiuwen: | ||
| 28 | + deps.insert(0, '"openjiuwen==0.1.10"') | ||
| 29 | + | ||
| 30 | + deps_str = ",\n ".join(deps) | ||
| 31 | + desc = _description(template_type) | ||
| 32 | + return f'''[build-system] | ||
| 33 | +requires = ["setuptools>=61.0", "wheel"] | ||
| 34 | +build-backend = "setuptools.build_meta" | ||
| 35 | + | ||
| 36 | +[project] | ||
| 37 | +name = "{project_name}-runner" | ||
| 38 | +version = "0.1.0" | ||
| 39 | +description = "{desc}" | ||
| 40 | +requires-python = ">=3.11.4" | ||
| 41 | +dependencies = [ | ||
| 42 | + {deps_str} | ||
| 43 | +] | ||
| 44 | + | ||
| 45 | +[project.scripts] | ||
| 46 | +{project_name}-runner = "openjiuwen_runtime.{pkg_name}.__main__:main" | ||
| 47 | + | ||
| 48 | +[tool.setuptools.packages.find] | ||
| 49 | +where = ["."] | ||
| 50 | +include = ["openjiuwen_runtime*"] | ||
| 51 | +''' | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def get_init() -> str: | ||
| 55 | + return "# coding: utf-8\n# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved\n" | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +def get_main(pkg_name: str) -> str: | ||
| 59 | + return f'''#!/usr/bin/env python | ||
| 60 | +# coding: utf-8 | ||
| 61 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def main(): | ||
| 65 | + from .{pkg_name}_runner import app | ||
| 66 | + app.run() | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +if __name__ == "__main__": | ||
| 70 | + main() | ||
| 71 | +''' | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def get_runner(pkg_name: str, template_type: str) -> str: | ||
| 75 | + if template_type == "react": | ||
| 76 | + return _runner_react(pkg_name) | ||
| 77 | + if template_type == "workflow": | ||
| 78 | + return _runner_workflow(pkg_name) | ||
| 79 | + return _runner_empty(pkg_name) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +# ==================== empty 模板 ==================== | ||
| 83 | + | ||
| 84 | +def _runner_empty(pkg_name: str) -> str: | ||
| 85 | + return f'''#!/usr/bin/env python | ||
| 86 | +# coding: utf-8 | ||
| 87 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 88 | + | ||
| 89 | +""" | ||
| 90 | +{pkg_name} - 空白 Agent 模板 | ||
| 91 | + | ||
| 92 | +运行方式: | ||
| 93 | + python -m openjiuwen_runtime.{pkg_name} --port 8090 | ||
| 94 | +""" | ||
| 95 | + | ||
| 96 | +from typing import AsyncIterator, Tuple | ||
| 97 | + | ||
| 98 | +from openjiuwen_runtime.service.app.agent_app import AgentApp | ||
| 99 | + | ||
| 100 | +app = AgentApp( | ||
| 101 | + app_name="{pkg_name}", | ||
| 102 | + app_description="空白 Agent 模板", | ||
| 103 | + version="0.1.0", | ||
| 104 | +) | ||
| 105 | + | ||
| 106 | + | ||
| 107 | + | ||
| 108 | +async def init(): | ||
| 109 | + print("{pkg_name} 初始化完成!") | ||
| 110 | + | ||
| 111 | + | ||
| 112 | + | ||
| 113 | +async def query(msgs, request, cancel_event=None) -> AsyncIterator[Tuple[dict, bool]]: | ||
| 114 | + """处理查询请求""" | ||
| 115 | + last_user_msg = None | ||
| 116 | + for msg in reversed(msgs or []): | ||
| 117 | + if msg.get("role") == "user": | ||
| 118 | + last_user_msg = msg.get("content", "") | ||
| 119 | + break | ||
| 120 | + | ||
| 121 | + if not last_user_msg: | ||
| 122 | + yield {{"type": "text", "content": "请输入您的问题"}}, True | ||
| 123 | + return | ||
| 124 | + | ||
| 125 | + reply = f"收到:{{last_user_msg}}" | ||
| 126 | + yield {{"type": "text_delta", "content": reply}}, False | ||
| 127 | + yield {{"type": "result", "content": reply}}, True | ||
| 128 | + | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +async def shutdown(): | ||
| 132 | + print("{pkg_name} 关闭") | ||
| 133 | + | ||
| 134 | + | ||
| 135 | +if __name__ == "__main__": | ||
| 136 | + app.run() | ||
| 137 | +''' | ||
| 138 | + | ||
| 139 | + | ||
| 140 | +# ==================== react 模板 ==================== | ||
| 141 | + | ||
| 142 | +def _runner_react(pkg_name: str) -> str: | ||
| 143 | + return f'''#!/usr/bin/env python | ||
| 144 | +# coding: utf-8 | ||
| 145 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 146 | + | ||
| 147 | +""" | ||
| 148 | +{pkg_name} - ReAct Agent 模板 | ||
| 149 | + | ||
| 150 | +运行方式: | ||
| 151 | + python -m openjiuwen_runtime.{pkg_name} --port 8090 | ||
| 152 | + | ||
| 153 | +环境变量: | ||
| 154 | + API_BASE - 模型 API 地址 | ||
| 155 | + API_KEY - 模型 API Key | ||
| 156 | + MODEL_NAME - 模型名称 | ||
| 157 | + MODEL_PROVIDER - 模型提供商 | ||
| 158 | +""" | ||
| 159 | + | ||
| 160 | +import asyncio | ||
| 161 | +import os | ||
| 162 | +from typing import AsyncIterator, Tuple | ||
| 163 | + | ||
| 164 | +os.environ.setdefault("SSRF_PROTECT_ENABLED", "false") | ||
| 165 | +os.environ.setdefault("RESTFUL_SSL_VERIFY", "false") | ||
| 166 | + | ||
| 167 | +from openjiuwen.core.foundation.llm import ModelRequestConfig, ModelClientConfig | ||
| 168 | +from openjiuwen.core.foundation.tool import RestfulApi, RestfulApiCard | ||
| 169 | +from openjiuwen.core.runner import Runner | ||
| 170 | +from openjiuwen.core.single_agent import AgentCard, ReActAgentConfig, ReActAgent | ||
| 171 | + | ||
| 172 | +from openjiuwen_runtime.service.app.agent_app import AgentApp | ||
| 173 | + | ||
| 174 | +# ==================== 配置 ==================== | ||
| 175 | +API_BASE = os.getenv("API_BASE", "https://api.siliconflow.cn/v1") | ||
| 176 | +API_KEY = os.getenv("API_KEY", "") | ||
| 177 | +MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen3-8B") | ||
| 178 | +MODEL_PROVIDER = os.getenv("MODEL_PROVIDER", "siliconflow") | ||
| 179 | + | ||
| 180 | + | ||
| 181 | +def _create_tool(): | ||
| 182 | + """创建示例工具(地理编码查询)""" | ||
| 183 | + card = RestfulApiCard( | ||
| 184 | + name="GeocodingSearch", | ||
| 185 | + description="根据城市名称查询经纬度信息", | ||
| 186 | + url="https://geocoding-api.open-meteo.com/v1/search", | ||
| 187 | + method="GET", | ||
| 188 | + headers={{}}, | ||
| 189 | + input_params={{ | ||
| 190 | + "type": "object", | ||
| 191 | + "properties": {{ | ||
| 192 | + "name": {{"type": "string", "description": "城市名称(英文)"}}, | ||
| 193 | + "count": {{"type": "string", "description": "返回结果数量"}}, | ||
| 194 | + "language": {{"type": "string", "description": "语言"}}, | ||
| 195 | + "format": {{"type": "string", "description": "返回格式"}}, | ||
| 196 | + }}, | ||
| 197 | + "required": ["name"], | ||
| 198 | + }}, | ||
| 199 | + ) | ||
| 200 | + return RestfulApi(card=card) | ||
| 201 | + | ||
| 202 | + | ||
| 203 | +def _build_prompt_template(): | ||
| 204 | + return [ | ||
| 205 | + {{ | ||
| 206 | + "role": "system", | ||
| 207 | + "content": "你是一个地理助手。你可以根据已有工具,为用户查询指定地点的经纬度信息。\\n注意:调用工具时,城市名称请使用英文。", | ||
| 208 | + }} | ||
| 209 | + ] | ||
| 210 | + | ||
| 211 | + | ||
| 212 | +# ==================== AgentApp ==================== | ||
| 213 | + | ||
| 214 | +app = AgentApp( | ||
| 215 | + app_name="{pkg_name}", | ||
| 216 | + app_description="ReAct Agent 模板,带地理编码工具", | ||
| 217 | + version="0.1.0", | ||
| 218 | +) | ||
| 219 | + | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +async def init(): | ||
| 223 | + print("=" * 50) | ||
| 224 | + print("{pkg_name} (ReAct) 初始化中...") | ||
| 225 | + print(f"Model: {{MODEL_NAME}} | Provider: {{MODEL_PROVIDER}}") | ||
| 226 | + print("=" * 50) | ||
| 227 | + | ||
| 228 | + model_config = ModelRequestConfig(model=MODEL_NAME, temperature=0.7, top_p=0.9) | ||
| 229 | + client_config = ModelClientConfig( | ||
| 230 | + client_provider=MODEL_PROVIDER, | ||
| 231 | + api_key=API_KEY, | ||
| 232 | + api_base=API_BASE, | ||
| 233 | + timeout=60, | ||
| 234 | + verify_ssl=False, | ||
| 235 | + ) | ||
| 236 | + react_config = ReActAgentConfig( | ||
| 237 | + model_config_obj=model_config, | ||
| 238 | + model_client_config=client_config, | ||
| 239 | + prompt_template=_build_prompt_template(), | ||
| 240 | + ) | ||
| 241 | + agent_card = AgentCard(id="{pkg_name}", description="ReAct Agent") | ||
| 242 | + agent = ReActAgent(card=agent_card).configure(react_config) | ||
| 243 | + | ||
| 244 | + tool = _create_tool() | ||
| 245 | + Runner.resource_mgr.add_tool(tool) | ||
| 246 | + agent.ability_manager.add(tool.card) | ||
| 247 | + | ||
| 248 | + started = await Runner.start() | ||
| 249 | + if not started: | ||
| 250 | + print("WARNING: Runner.start() returned False") | ||
| 251 | + | ||
| 252 | + app.agent = agent | ||
| 253 | + print("{pkg_name} (ReAct) 初始化完成!") | ||
| 254 | + | ||
| 255 | + | ||
| 256 | + | ||
| 257 | +async def query(msgs, request, cancel_event=None) -> AsyncIterator[Tuple[dict, bool]]: | ||
| 258 | + """处理查询请求""" | ||
| 259 | + conversation_id = request.conversation_id | ||
| 260 | + | ||
| 261 | + last_user_msg = None | ||
| 262 | + for msg in reversed(msgs or []): | ||
| 263 | + if msg.get("role") == "user": | ||
| 264 | + last_user_msg = msg.get("content", "") | ||
| 265 | + break | ||
| 266 | + | ||
| 267 | + if not last_user_msg: | ||
| 268 | + yield {{"type": "text", "content": "请输入您的问题"}}, True | ||
| 269 | + return | ||
| 270 | + | ||
| 271 | + print(f"[query] conversation_id={{conversation_id}}, query={{last_user_msg[:100]}}") | ||
| 272 | + inputs = {{"query": last_user_msg}} | ||
| 273 | + | ||
| 274 | + try: | ||
| 275 | + collected_text = [] | ||
| 276 | + stream_iter = await asyncio.to_thread( | ||
| 277 | + Runner.run_agent_streaming, | ||
| 278 | + agent=app.agent, | ||
| 279 | + inputs=inputs, | ||
| 280 | + session=conversation_id, | ||
| 281 | + ) | ||
| 282 | + | ||
| 283 | + while True: | ||
| 284 | + if cancel_event and cancel_event.is_set(): | ||
| 285 | + break | ||
| 286 | + try: | ||
| 287 | + chunk = await stream_iter.__anext__() | ||
| 288 | + except StopAsyncIteration: | ||
| 289 | + break | ||
| 290 | + | ||
| 291 | + if chunk: | ||
| 292 | + chunk_type = getattr(chunk, "type", "") | ||
| 293 | + payload = getattr(chunk, "payload", None) | ||
| 294 | + | ||
| 295 | + if chunk_type == "end node stream" and isinstance(payload, dict): | ||
| 296 | + delta = payload.get("response") or payload.get("output") or "" | ||
| 297 | + if isinstance(delta, dict): | ||
| 298 | + delta = str(delta) | ||
| 299 | + if delta: | ||
| 300 | + collected_text.append(str(delta)) | ||
| 301 | + yield {{"type": "text_delta", "content": str(delta)}}, False | ||
| 302 | + elif chunk_type in ("llm_output", "workflow_final") and isinstance(payload, dict): | ||
| 303 | + delta = payload.get("content") or payload.get("output") or "" | ||
| 304 | + if delta: | ||
| 305 | + collected_text.append(str(delta)) | ||
| 306 | + yield {{"type": "text_delta", "content": str(delta)}}, False | ||
| 307 | + elif chunk_type == "answer": | ||
| 308 | + pass | ||
| 309 | + elif isinstance(payload, dict): | ||
| 310 | + output = payload.get("output") or payload.get("content") or "" | ||
| 311 | + if output: | ||
| 312 | + collected_text.append(str(output)) | ||
| 313 | + yield {{"type": "text_delta", "content": str(output)}}, False | ||
| 314 | + | ||
| 315 | + full_text = "".join(collected_text) | ||
| 316 | + yield {{"type": "result", "content": full_text}}, True | ||
| 317 | + | ||
| 318 | + except asyncio.CancelledError: | ||
| 319 | + raise | ||
| 320 | + except Exception as e: | ||
| 321 | + print(f"[query] error: {{e}}") | ||
| 322 | + yield {{"type": "error", "content": f"执行失败:{{str(e)}}"}}, True | ||
| 323 | + | ||
| 324 | + | ||
| 325 | + | ||
| 326 | +async def shutdown(): | ||
| 327 | + print("{pkg_name} 关闭") | ||
| 328 | + | ||
| 329 | + | ||
| 330 | +if __name__ == "__main__": | ||
| 331 | + app.run() | ||
| 332 | +''' | ||
| 333 | + | ||
| 334 | + | ||
| 335 | +# ==================== workflow 模板 ==================== | ||
| 336 | + | ||
| 337 | +def _runner_workflow(pkg_name: str) -> str: | ||
| 338 | + return f'''#!/usr/bin/env python | ||
| 339 | +# coding: utf-8 | ||
| 340 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 341 | + | ||
| 342 | +""" | ||
| 343 | +{pkg_name} - Workflow Agent 模板 | ||
| 344 | + | ||
| 345 | +运行方式: | ||
| 346 | + python -m openjiuwen_runtime.{pkg_name} --port 8090 | ||
| 347 | + | ||
| 348 | +环境变量: | ||
| 349 | + API_BASE - 模型 API 地址 | ||
| 350 | + API_KEY - 模型 API Key | ||
| 351 | + MODEL_NAME - 模型名称 | ||
| 352 | + MODEL_PROVIDER - 模型提供商 | ||
| 353 | +""" | ||
| 354 | + | ||
| 355 | +import asyncio | ||
| 356 | +import os | ||
| 357 | +from typing import AsyncIterator, Tuple | ||
| 358 | + | ||
| 359 | +os.environ.setdefault("SSRF_PROTECT_ENABLED", "false") | ||
| 360 | +os.environ.setdefault("RESTFUL_SSL_VERIFY", "false") | ||
| 361 | + | ||
| 362 | +from openjiuwen.core.application.workflow_agent import WorkflowAgentConfig, WorkflowAgent | ||
| 363 | +from openjiuwen.core.foundation.llm import ModelRequestConfig, ModelClientConfig | ||
| 364 | +from openjiuwen.core.runner import Runner | ||
| 365 | +from openjiuwen.core.workflow import ( | ||
| 366 | + Workflow, | ||
| 367 | + Start, | ||
| 368 | + End, | ||
| 369 | + LLMComponent, | ||
| 370 | + LLMCompConfig, | ||
| 371 | +) | ||
| 372 | +from openjiuwen.core.workflow import WorkflowCard | ||
| 373 | +from openjiuwen.core.workflow.workflow_config import WorkflowConfig | ||
| 374 | + | ||
| 375 | +from openjiuwen_runtime.service.app.agent_app import AgentApp | ||
| 376 | + | ||
| 377 | +# ==================== 配置 ==================== | ||
| 378 | +API_BASE = os.getenv("API_BASE", "https://api.siliconflow.cn/v1") | ||
| 379 | +API_KEY = os.getenv("API_KEY", "") | ||
| 380 | +MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen3-8B") | ||
| 381 | +MODEL_PROVIDER = os.getenv("MODEL_PROVIDER", "siliconflow") | ||
| 382 | + | ||
| 383 | + | ||
| 384 | +def _create_model_config(): | ||
| 385 | + return ModelRequestConfig(model=MODEL_NAME, temperature=0.7, top_p=0.9) | ||
| 386 | + | ||
| 387 | +def _create_client_config(): | ||
| 388 | + return ModelClientConfig( | ||
| 389 | + client_provider=MODEL_PROVIDER, | ||
| 390 | + api_key=API_KEY, | ||
| 391 | + api_base=API_BASE, | ||
| 392 | + timeout=60, | ||
| 393 | + verify_ssl=False, | ||
| 394 | + ) | ||
| 395 | + | ||
| 396 | + | ||
| 397 | +def _build_workflow(): | ||
| 398 | + """构建最简工作流: Start -> LLM改写 -> End""" | ||
| 399 | + workflow_config = WorkflowConfig( | ||
| 400 | + card=WorkflowCard( | ||
| 401 | + id="{pkg_name}_workflow", | ||
| 402 | + name="{pkg_name}", | ||
| 403 | + version="1.0", | ||
| 404 | + description="{pkg_name} 工作流", | ||
| 405 | + ) | ||
| 406 | + ) | ||
| 407 | + flow = Workflow(workflow_config=workflow_config) | ||
| 408 | + | ||
| 409 | + # Start | ||
| 410 | + start = Start() | ||
| 411 | + | ||
| 412 | + # LLM 组件: 简单改写 | ||
| 413 | + llm_config = LLMCompConfig( | ||
| 414 | + model_client_config=_create_client_config(), | ||
| 415 | + model_config=_create_model_config(), | ||
| 416 | + template_content=[{{"role": "user", "content": "请用一句话回答:{{{{query}}}}"}}], | ||
| 417 | + response_format={{"type": "text"}}, | ||
| 418 | + output_config={{ | ||
| 419 | + "query": {{"type": "string", "description": "回复内容", "required": True}} | ||
| 420 | + }}, | ||
| 421 | + ) | ||
| 422 | + llm = LLMComponent(llm_config) | ||
| 423 | + | ||
| 424 | + # End | ||
| 425 | + end = End({{"responseTemplate": "{{{{output}}}}"}}) | ||
| 426 | + | ||
| 427 | + # 注册组件 | ||
| 428 | + flow.set_start_comp("start", start, inputs_schema={{"query": "${{query}}"}}) | ||
| 429 | + flow.add_workflow_comp("llm", llm, inputs_schema={{"query": "${{start.query}}"}}) | ||
| 430 | + flow.set_end_comp("end", end, inputs_schema={{"output": "${{llm.query}}"}}) | ||
| 431 | + | ||
| 432 | + # 连接 | ||
| 433 | + flow.add_connection("start", "llm") | ||
| 434 | + flow.add_connection("llm", "end") | ||
| 435 | + | ||
| 436 | + return flow | ||
| 437 | + | ||
| 438 | + | ||
| 439 | +# ==================== AgentApp ==================== | ||
| 440 | + | ||
| 441 | +app = AgentApp( | ||
| 442 | + app_name="{pkg_name}", | ||
| 443 | + app_description="Workflow Agent 模板", | ||
| 444 | + version="0.1.0", | ||
| 445 | +) | ||
| 446 | + | ||
| 447 | + | ||
| 448 | + | ||
| 449 | +async def init(): | ||
| 450 | + print("=" * 50) | ||
| 451 | + print("{pkg_name} (Workflow) 初始化中...") | ||
| 452 | + print(f"Model: {{MODEL_NAME}} | Provider: {{MODEL_PROVIDER}}") | ||
| 453 | + print("=" * 50) | ||
| 454 | + | ||
| 455 | + flow = _build_workflow() | ||
| 456 | + agent_config = WorkflowAgentConfig( | ||
| 457 | + id="{pkg_name}", | ||
| 458 | + version="0.1.0", | ||
| 459 | + description="Workflow Agent 模板", | ||
| 460 | + ) | ||
| 461 | + agent = WorkflowAgent(agent_config) | ||
| 462 | + agent.add_workflows([flow]) | ||
| 463 | + | ||
| 464 | + app.agent = agent | ||
| 465 | + print("{pkg_name} (Workflow) 初始化完成!") | ||
| 466 | + | ||
| 467 | + | ||
| 468 | + | ||
| 469 | +async def query(msgs, request, cancel_event=None) -> AsyncIterator[Tuple[dict, bool]]: | ||
| 470 | + """处理查询请求""" | ||
| 471 | + conversation_id = request.conversation_id | ||
| 472 | + | ||
| 473 | + last_user_msg = None | ||
| 474 | + for msg in reversed(msgs or []): | ||
| 475 | + if msg.get("role") == "user": | ||
| 476 | + last_user_msg = msg.get("content", "") | ||
| 477 | + break | ||
| 478 | + | ||
| 479 | + if not last_user_msg: | ||
| 480 | + yield {{"type": "text", "content": "请输入您的问题"}}, True | ||
| 481 | + return | ||
| 482 | + | ||
| 483 | + print(f"[query] conversation_id={{conversation_id}}, query={{last_user_msg[:100]}}") | ||
| 484 | + inputs = {{"query": last_user_msg, "conversation_id": conversation_id}} | ||
| 485 | + | ||
| 486 | + try: | ||
| 487 | + collected_text = [] | ||
| 488 | + stream_iter = await asyncio.to_thread( | ||
| 489 | + Runner.run_agent_streaming, | ||
| 490 | + agent=app.agent, | ||
| 491 | + inputs=inputs, | ||
| 492 | + session=conversation_id, | ||
| 493 | + ) | ||
| 494 | + | ||
| 495 | + while True: | ||
| 496 | + if cancel_event and cancel_event.is_set(): | ||
| 497 | + break | ||
| 498 | + try: | ||
| 499 | + chunk = await stream_iter.__anext__() | ||
| 500 | + except StopAsyncIteration: | ||
| 501 | + break | ||
| 502 | + | ||
| 503 | + if chunk: | ||
| 504 | + chunk_type = getattr(chunk, "type", "") | ||
| 505 | + payload = getattr(chunk, "payload", None) | ||
| 506 | + | ||
| 507 | + if chunk_type == "end node stream" and isinstance(payload, dict): | ||
| 508 | + delta = payload.get("response") or payload.get("output") or "" | ||
| 509 | + if isinstance(delta, dict): | ||
| 510 | + delta = str(delta) | ||
| 511 | + if delta: | ||
| 512 | + collected_text.append(str(delta)) | ||
| 513 | + yield {{"type": "text_delta", "content": str(delta)}}, False | ||
| 514 | + elif chunk_type in ("llm_output", "workflow_final") and isinstance(payload, dict): | ||
| 515 | + delta = payload.get("content") or payload.get("output") or "" | ||
| 516 | + if delta: | ||
| 517 | + collected_text.append(str(delta)) | ||
| 518 | + yield {{"type": "text_delta", "content": str(delta)}}, False | ||
| 519 | + elif chunk_type == "answer": | ||
| 520 | + pass | ||
| 521 | + elif chunk_type == "tracer_workflow" and isinstance(payload, dict): | ||
| 522 | + if payload.get("status") == "finish" and payload.get("componentType") == "LLMExecutable": | ||
| 523 | + outputs = payload.get("outputs", {{}}) | ||
| 524 | + if isinstance(outputs, dict): | ||
| 525 | + delta = outputs.get("query", "") | ||
| 526 | + if delta: | ||
| 527 | + collected_text.append(str(delta)) | ||
| 528 | + yield {{"type": "text_delta", "content": str(delta)}}, False | ||
| 529 | + elif isinstance(payload, dict): | ||
| 530 | + output = payload.get("output") or payload.get("content") or "" | ||
| 531 | + if output: | ||
| 532 | + collected_text.append(str(output)) | ||
| 533 | + yield {{"type": "text_delta", "content": str(output)}}, False | ||
| 534 | + | ||
| 535 | + full_text = "".join(collected_text) | ||
| 536 | + yield {{"type": "result", "content": full_text}}, True | ||
| 537 | + | ||
| 538 | + except asyncio.CancelledError: | ||
| 539 | + raise | ||
| 540 | + except Exception as e: | ||
| 541 | + print(f"[query] error: {{e}}") | ||
| 542 | + yield {{"type": "error", "content": f"执行失败:{{str(e)}}"}}, True | ||
| 543 | + | ||
| 544 | + | ||
| 545 | + | ||
| 546 | +async def shutdown(): | ||
| 547 | + print("{pkg_name} 关闭") | ||
| 548 | + | ||
| 549 | + | ||
| 550 | +if __name__ == "__main__": | ||
| 551 | + app.run() | ||
| 552 | +''' | ||
| 553 | + | ||
| 554 | + | ||
| 555 | +# ==================== 辅助 ==================== | ||
| 556 | + | ||
| 557 | +def _description(template_type: str) -> str: | ||
| 558 | + return { | ||
| 559 | + "empty": "空白 Agent 模板", | ||
| 560 | + "react": "ReAct Agent 模板,带 LLM 和示例工具", | ||
| 561 | + "workflow": "Workflow Agent 模板,带 LLM 和最简工作流", | ||
| 562 | + }.get(template_type, "Agent 模板") | ||
| @@ -142,18 +142,18 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]): | |||
| 142 | logger.info("Virtual environment created: %s", venv_path) | 142 | logger.info("Virtual environment created: %s", venv_path) |
| 143 | 143 | ||
| 144 | if settings.MODE == "dev": | 144 | if settings.MODE == "dev": |
| 145 | - # 开发模式,本地源码部署: 获取所有 .whl 文件 | 145 | + # 安装基础运行时 WHL(foundation/management/service) |
| 146 | - logger.info("dist_path: %s", settings.dist_path) | 146 | + base_whl_files = sorted(settings.dist_path.glob("openjiuwen_runtime_*.whl")) |
| 147 | - whl_files = list(settings.dist_path.glob("*.whl")) | 147 | + for whl_file in base_whl_files: |
| 148 | - if not whl_files: | ||
| 149 | - raise RuntimeError(f"No .whl files found in dist directory: {settings.dist_path}") | ||
| 150 | - | ||
| 151 | - logger.info("whl_files: %s", whl_files) | ||
| 152 | - | ||
| 153 | - # 循环安装所有 whl 包 | ||
| 154 | - for whl_file in whl_files: | ||
| 155 | self.venv_manager.pip_install(deployment_id, str(whl_file)) | 148 | self.venv_manager.pip_install(deployment_id, str(whl_file)) |
| 156 | - logger.info("Installed WHL package: %s", whl_file) | 149 | + logger.info("Installed base WHL: %s", whl_file.name) |
| 150 | + | ||
| 151 | + # 安装 CLI 指定的 agent/plugin WHL | ||
| 152 | + if whl_path and Path(whl_path).exists(): | ||
| 153 | + self.venv_manager.pip_install(deployment_id, str(whl_path)) | ||
| 154 | + logger.info("Installed agent WHL: %s", whl_path) | ||
| 155 | + else: | ||
| 156 | + raise RuntimeError(f"Agent WHL not found: {whl_path}") | ||
| 157 | else: | 157 | else: |
| 158 | # 从 PyPI仓库安装 lowcode-agent-runner | 158 | # 从 PyPI仓库安装 lowcode-agent-runner |
| 159 | self.venv_manager.pip_install(deployment_id, "lowcode-agent-runner") | 159 | self.venv_manager.pip_install(deployment_id, "lowcode-agent-runner") |
| @@ -165,12 +165,15 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]): | |||
| 165 | if not package_name: | 165 | if not package_name: |
| 166 | raise RuntimeError("package_name is required for subprocess deployment") | 166 | raise RuntimeError("package_name is required for subprocess deployment") |
| 167 | 167 | ||
| 168 | + # 未指定端口时自动分配可用端口 | ||
| 169 | + port = ctx.port if ctx.port else self._get_available_port() | ||
| 170 | + | ||
| 168 | cmd = [ | 171 | cmd = [ |
| 169 | str(python_executable), | 172 | str(python_executable), |
| 170 | "-m", | 173 | "-m", |
| 171 | package_name, | 174 | package_name, |
| 172 | "--host", "0.0.0.0", | 175 | "--host", "0.0.0.0", |
| 173 | - "--port", str(ctx.port) | 176 | + "--port", str(port) |
| 174 | ] | 177 | ] |
| 175 | if ir_path: | 178 | if ir_path: |
| 176 | cmd.extend(["--irpath", ir_path]) | 179 | cmd.extend(["--irpath", ir_path]) |
| @@ -188,27 +191,36 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]): | |||
| 188 | env["RUNTIME_USERDATA"] = userdata | 191 | env["RUNTIME_USERDATA"] = userdata |
| 189 | logger.info("Using userdata: %s", mask_userdata(userdata)) | 192 | logger.info("Using userdata: %s", mask_userdata(userdata)) |
| 190 | 193 | ||
| 194 | + # 创建日志目录,将 stdout/stderr 重定向到日志文件 | ||
| 195 | + log_dir = venv_path.parent / "logs" | ||
| 196 | + log_dir.mkdir(parents=True, exist_ok=True) | ||
| 197 | + log_file = log_dir / "agent.log" | ||
| 198 | + log_fp = open(log_file, "a", encoding="utf-8") | ||
| 199 | + | ||
| 191 | process = subprocess.Popen( | 200 | process = subprocess.Popen( |
| 192 | cmd, | 201 | cmd, |
| 193 | env=env, | 202 | env=env, |
| 194 | - stdout=subprocess.PIPE, | 203 | + stdout=log_fp, |
| 195 | - stderr=subprocess.PIPE, | 204 | + stderr=log_fp, |
| 196 | creationflags=creation_flags, | 205 | creationflags=creation_flags, |
| 197 | ) | 206 | ) |
| 207 | + logger.info("Agent log file: %s", log_file) | ||
| 198 | 208 | ||
| 199 | # 6. 等待进程启动并检查状态 | 209 | # 6. 等待进程启动并检查状态 |
| 200 | await asyncio.sleep(2) | 210 | await asyncio.sleep(2) |
| 201 | 211 | ||
| 202 | if process.poll() is not None: | 212 | if process.poll() is not None: |
| 203 | - # 进程已经退出,读取错误信息 | 213 | + # 进程已经退出,从日志文件读取错误信息 |
| 204 | - stdout, stderr = process.communicate() | 214 | + log_fp.close() |
| 205 | - stderr_txt = stderr.decode("utf-8", errors="ignore") | 215 | + error_msg = "Unknown error" |
| 206 | - stdout_txt = stdout.decode("utf-8", errors="ignore") | 216 | + try: |
| 207 | - error_msg = stderr_txt or stdout_txt or "Unknown error" | 217 | + error_msg = log_file.read_text(encoding="utf-8", errors="ignore").strip() or error_msg |
| 218 | + except Exception: | ||
| 219 | + pass | ||
| 208 | logger.error("Process exited for %s: %s", deployment_id, error_msg) | 220 | logger.error("Process exited for %s: %s", deployment_id, error_msg) |
| 209 | raise RuntimeError(f"Process exited: {error_msg}") | 221 | raise RuntimeError(f"Process exited: {error_msg}") |
| 210 | 222 | ||
| 211 | - url = f"http://{settings.IP}:{ctx.port}/" | 223 | + url = f"http://{settings.IP}:{port}/" |
| 212 | logger.info( | 224 | logger.info( |
| 213 | "Deployment %s succeeded, PID: %s, URL: %s", | 225 | "Deployment %s succeeded, PID: %s, URL: %s", |
| 214 | deployment_id, | 226 | deployment_id, |
| @@ -0,0 +1,141 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +set -euo pipefail | ||
| 3 | + | ||
| 4 | +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
| 5 | +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" | ||
| 6 | +CLI_DIR="$PROJECT_DIR/cli" | ||
| 7 | +DIST_DIR="${DIST_DIR:-$PROJECT_DIR/dist}" | ||
| 8 | + | ||
| 9 | +FOUNDATION_TARGET="../foundation" | ||
| 10 | +if [[ "${DB_TYPE:-}" =~ ^([Gg]auss[Dd]b|[Oo]pen[Gg]auss)$ ]]; then | ||
| 11 | + FOUNDATION_TARGET="../foundation[gaussdb]" | ||
| 12 | +fi | ||
| 13 | + | ||
| 14 | +# 清除可能指向其他 venv 的环境变量 | ||
| 15 | +unset VIRTUAL_ENV | ||
| 16 | +export PYTHONPATH="" | ||
| 17 | + | ||
| 18 | +# 加载全局配置:~/.openjiuwen/.env | ||
| 19 | +GLOBAL_ENV="$HOME/.openjiuwen/.env" | ||
| 20 | +if [ -f "$GLOBAL_ENV" ]; then | ||
| 21 | + echo "==> Loading config from $GLOBAL_ENV" | ||
| 22 | + while IFS='=' read -r key value; do | ||
| 23 | + key="$(echo "$key" | xargs)" | ||
| 24 | + [ -z "$key" ] && continue | ||
| 25 | + [[ "$key" == \#* ]] && continue | ||
| 26 | + value="$(echo "$value" | xargs)" | ||
| 27 | + export "$key=$value" | ||
| 28 | + done < "$GLOBAL_ENV" | ||
| 29 | +fi | ||
| 30 | + | ||
| 31 | +DIST_DIR="${DIST_DIR:-$PROJECT_DIR/dist}" | ||
| 32 | + | ||
| 33 | +# ========== 1. 构建基础运行时 WHL ========== | ||
| 34 | +echo "==> Building runtime base WHL packages..." | ||
| 35 | +mkdir -p "$DIST_DIR" | ||
| 36 | +rm -f "$DIST_DIR"/openjiuwen_runtime_*.whl | ||
| 37 | + | ||
| 38 | +for pkg in foundation management service; do | ||
| 39 | + pkg_dir="$PROJECT_DIR/$pkg" | ||
| 40 | + if [ -d "$pkg_dir" ]; then | ||
| 41 | + echo " Building $pkg..." | ||
| 42 | + uv build "$pkg_dir" --out-dir "$DIST_DIR" | ||
| 43 | + else | ||
| 44 | + echo " Skipping $pkg (directory not found)" | ||
| 45 | + fi | ||
| 46 | +done | ||
| 47 | + | ||
| 48 | +# ========== 2. 构建 CLI 二进制 ========== | ||
| 49 | +cd "$CLI_DIR" | ||
| 50 | + | ||
| 51 | +echo "==> Setting up build venv..." | ||
| 52 | +uv venv | ||
| 53 | + | ||
| 54 | +echo "==> Installing CLI dependencies..." | ||
| 55 | +uv sync | ||
| 56 | + | ||
| 57 | +echo "==> Installing management package (editable)..." | ||
| 58 | +uv pip install --python .venv/Scripts/python.exe -e ../management | ||
| 59 | + | ||
| 60 | +echo "==> Installing foundation package (editable)..." | ||
| 61 | +uv pip install --python .venv/Scripts/python.exe -e "$FOUNDATION_TARGET" | ||
| 62 | + | ||
| 63 | +echo "==> Installing PyInstaller..." | ||
| 64 | +uv pip install --python .venv/Scripts/python.exe pyinstaller | ||
| 65 | + | ||
| 66 | +echo "==> Building standalone binary..." | ||
| 67 | +.venv/Scripts/python.exe -m PyInstaller --onefile \ | ||
| 68 | + --name openjiuwen \ | ||
| 69 | + --distpath "$DIST_DIR" \ | ||
| 70 | + --workpath build/ \ | ||
| 71 | + --specpath build/ \ | ||
| 72 | + --paths "$CLI_DIR" \ | ||
| 73 | + --paths "$PROJECT_DIR/management" \ | ||
| 74 | + --paths "$PROJECT_DIR/foundation" \ | ||
| 75 | + --hidden-import openjiuwen_runtime \ | ||
| 76 | + --hidden-import openjiuwen_runtime.cli \ | ||
| 77 | + --hidden-import openjiuwen_runtime.cli.main \ | ||
| 78 | + --hidden-import openjiuwen_runtime.cli.templates \ | ||
| 79 | + --hidden-import openjiuwen_runtime.management \ | ||
| 80 | + --hidden-import openjiuwen_runtime.management.manager \ | ||
| 81 | + --hidden-import openjiuwen_runtime.management.models \ | ||
| 82 | + --hidden-import openjiuwen_runtime.management.models.enums \ | ||
| 83 | + --hidden-import openjiuwen_runtime.management.models.schemas \ | ||
| 84 | + --hidden-import openjiuwen_runtime.management.models.deployment_params \ | ||
| 85 | + --hidden-import openjiuwen_runtime.management.deployments \ | ||
| 86 | + --hidden-import openjiuwen_runtime.management.deployments.base \ | ||
| 87 | + --hidden-import openjiuwen_runtime.management.deployments.base.deployer \ | ||
| 88 | + --hidden-import openjiuwen_runtime.management.deployments.base.models \ | ||
| 89 | + --hidden-import openjiuwen_runtime.management.deployments.base.strategy \ | ||
| 90 | + --hidden-import openjiuwen_runtime.management.deployments.subprocess \ | ||
| 91 | + --hidden-import openjiuwen_runtime.management.deployments.subprocess.deployer \ | ||
| 92 | + --hidden-import openjiuwen_runtime.management.deployments.subprocess.models \ | ||
| 93 | + --hidden-import openjiuwen_runtime.management.deployments.subprocess.strategy \ | ||
| 94 | + --hidden-import openjiuwen_runtime.management.deployments.docker \ | ||
| 95 | + --hidden-import openjiuwen_runtime.management.deployments.docker.deployer \ | ||
| 96 | + --hidden-import openjiuwen_runtime.management.deployments.docker.models \ | ||
| 97 | + --hidden-import openjiuwen_runtime.management.deployments.docker.strategy \ | ||
| 98 | + --hidden-import openjiuwen_runtime.management.deployments.k8s \ | ||
| 99 | + --hidden-import openjiuwen_runtime.management.deployments.k8s.deployer \ | ||
| 100 | + --hidden-import openjiuwen_runtime.management.deployments.k8s.models \ | ||
| 101 | + --hidden-import openjiuwen_runtime.management.deployments.k8s.strategy \ | ||
| 102 | + --hidden-import openjiuwen_runtime.foundation \ | ||
| 103 | + --hidden-import openjiuwen_runtime.foundation.config \ | ||
| 104 | + --hidden-import openjiuwen_runtime.foundation.packaging \ | ||
| 105 | + --hidden-import openjiuwen_runtime.foundation.port_utils \ | ||
| 106 | + --hidden-import openjiuwen_runtime.foundation.venv_manager \ | ||
| 107 | + --hidden-import openjiuwen_runtime.foundation.docker_utils \ | ||
| 108 | + --hidden-import openjiuwen_runtime.foundation.db \ | ||
| 109 | + --hidden-import openjiuwen_runtime.foundation.db.handler \ | ||
| 110 | + --hidden-import openjiuwen_runtime.foundation.db.sqlite_handler \ | ||
| 111 | + --hidden-import openjiuwen_runtime.foundation.db.mysql_handler \ | ||
| 112 | + --hidden-import openjiuwen_runtime.foundation.db.gaussdb_handler \ | ||
| 113 | + --hidden-import openjiuwen_runtime.foundation.db.sqlalchemy_handler \ | ||
| 114 | + --hidden-import openjiuwen_runtime.foundation.db.redis_handler \ | ||
| 115 | + --hidden-import openjiuwen_runtime.foundation.db.table_def \ | ||
| 116 | + --hidden-import openjiuwen_runtime.foundation.log \ | ||
| 117 | + --hidden-import openjiuwen_runtime.foundation.log.config \ | ||
| 118 | + --hidden-import openjiuwen_runtime.foundation.log.handler \ | ||
| 119 | + --hidden-import openjiuwen_runtime.foundation.log.utils \ | ||
| 120 | + --hidden-import aiosqlite \ | ||
| 121 | + --hidden-import aiomysql \ | ||
| 122 | + openjiuwen_runtime/cli/main.py | ||
| 123 | + | ||
| 124 | +# ========== 3. 验证 ========== | ||
| 125 | +BINARY="$DIST_DIR/openjiuwen.exe" | ||
| 126 | +if [ -f "$BINARY" ]; then | ||
| 127 | + SIZE=$(du -h "$BINARY" | cut -f1) | ||
| 128 | + echo "" | ||
| 129 | + echo "==========================================" | ||
| 130 | + echo "Build succeeded!" | ||
| 131 | + echo "==========================================" | ||
| 132 | + echo "Binary: $BINARY ($SIZE)" | ||
| 133 | + echo "" | ||
| 134 | + echo "Runtime base WHL packages:" | ||
| 135 | + ls "$DIST_DIR"/openjiuwen_runtime_*.whl 2>/dev/null | while read f; do echo " $(basename "$f")"; done | ||
| 136 | + echo "" | ||
| 137 | + "$BINARY" --help | ||
| 138 | +else | ||
| 139 | + echo "ERROR: Binary not found at $BINARY" >&2 | ||
| 140 | + exit 1 | ||
| 141 | +fi | ||