已合并
feat: CLI new/deploy 命令重构及模板工程支持 #111
feat: CLI new/deploy 命令重构及模板工程支持 #111
已合并
王明琦创建于 4月21日
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 
9import asyncio9import asyncio
10import json10import json
11+import os
12+import re
13+import subprocess
11from pathlib import Path14from pathlib import Path
12 15 
13import click16import click
14from dotenv import load_dotenv17from 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+ 
16from openjiuwen_runtime.management import (30from openjiuwen_runtime.management import (
17 DeployAgentParams,31 DeployAgentParams,
18 DeployPluginParams,32 DeployPluginParams,
@@ -22,10 +36,60 @@ from openjiuwen_runtime.management import (
22from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus36from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus
23from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler37from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
24 38 
25-# 加载.env配置文件(固定位置:manager根目录)39+ 
26-_manager_root = Path(__file__).parent.parent.parent40+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@click.group()95@click.group()
@@ -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@click.pass_context112@click.pass_context
49def cleanup(ctx, result, **kwargs):113def cleanup(ctx, result, **kwargs):
50 """清理资源"""114 """清理资源"""
@@ -61,32 +125,36 @@ def agent():
61 125 
62 126 
63@agent.command()127@agent.command()
64-@click.argument("python_file_path", type=click.Path(exists=True))128+@click.argument("project_dir", type=click.Path(exists=True))
65-@click.option("--name", required=True, help="部署名称(=包名,用于打包和运行)")
66@click.option("--port", type=int, help="服务端口(自动分配可用端口)")129@click.option("--port", type=int, help="服务端口(自动分配可用端口)")
67@click.pass_context130@click.pass_context
68-def deploy(ctx, python_file_path, name, port):131+def deploy(ctx, project_dir, port):
69- """部署 Agent(使用 Python 文件132+ """部署 Agent(使用项目目录
70 133 
71 \b134 \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_agent138+ agent-runtime agent deploy ./my_agent
77- agent-runtime agent deploy ./my_agent.py --name my_agent --port 8090139+ 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 return179 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@plugin.command()243@plugin.command()
175-@click.argument("python_file_path", type=click.Path(exists=True))244+@click.argument("project_dir", type=click.Path(exists=True))
176-@click.option("--name", required=True, help="部署名称(=包名,用于打包和运行)")
177@click.option("--port", type=int, help="服务端口(自动分配可用端口)")245@click.option("--port", type=int, help="服务端口(自动分配可用端口)")
178@click.pass_context246@click.pass_context
179-def deploy(ctx, python_file_path, name, port):247+def deploy(ctx, project_dir, port):
180- """部署 Plugin(使用 Python 文件248+ """部署 Plugin(使用项目目录
181 249 
182 \b250 \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_plugin254+ agent-runtime plugin deploy ./my_plugin
188- agent-runtime plugin deploy ./my_plugin.py --name my_plugin --port 8091255+ 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 return294 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+@cli.group()
352+def new():
353+ """创建新工程"""
354+ pass
355+ 
356+ 
357+@new.command()
358+@click.argument("name")
359+@click.option("--template", "template_type",
360+ type=click.Choice(["empty", "react", "workflow"]),
361+ default="empty", help="模板类型 (默认: empty)")
362+@click.option("--output-dir", default=".", help="输出目录 (默认: 当前目录)")
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+ 
277if __name__ == "__main__":416if __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+@app.init
108+async def init():
109+ print("{pkg_name} 初始化完成!")
110+ 
111+ 
112+@app.query
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+@app.shutdown
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+@app.init
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+@app.query
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+@app.shutdown
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+@app.init
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+@app.query
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+@app.shutdown
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-runner158 # 从 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"] = userdata191 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