diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 000000000..4a613a10c --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,53 @@ +name: Build and Publish to PyPI + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for trusted publishing to PyPI + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build frontend + run: | + cd web + npm install + npm run build + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true diff --git a/.gitignore b/.gitignore index ecfb72074..07d7eb9ab 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ uv.lock plugins.bak coverage.xml .coverage + +# Build artifacts +/dist +/build +*.egg-info diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..4b8d62b84 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,23 @@ +include README.md +include LICENSE +include pyproject.toml + +# Include all Python packages +recursive-include langbot *.py +recursive-include pkg *.py +recursive-include libs *.py + +# Include templates and resources +recursive-include langbot/templates * +recursive-include res * + +# Include compiled frontend files (will be added during build) +recursive-include web/out * + +# Exclude unnecessary files +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__ +global-exclude .DS_Store +global-exclude *.so +global-exclude *.dylib diff --git a/README.md b/README.md index 1c3c56bd7..b426a0d6a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,25 @@ LangBot 是一个开源的大语言模型原生即时通信机器人开发平台 ## 📦 开始使用 +#### 快速体验(推荐) + +使用 `uvx` 一键启动(无需安装): + +```bash +uvx langbot +``` + +或使用 `pip` 安装后运行: + +```bash +pip install langbot +langbot +``` + +访问 http://localhost:5300 即可开始使用。 + +详细文档[PyPI 安装](docs/PYPI_INSTALLATION.md)。 + #### Docker Compose 部署 ```bash diff --git a/README_EN.md b/README_EN.md index 86a021fdd..bb7e481c1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -25,6 +25,25 @@ LangBot is an open-source LLM native instant messaging robot development platfor ## 📦 Getting Started +#### Quick Start (Recommended) + +Use `uvx` to start with one command (no installation required): + +```bash +uvx langbot +``` + +Or install with `pip` and run: + +```bash +pip install langbot +langbot +``` + +Visit http://localhost:5300 to start using it. + +Detailed documentation [PyPI Installation](docs/PYPI_INSTALLATION.md). + #### Docker Compose Deployment ```bash diff --git a/docs/PYPI_INSTALLATION.md b/docs/PYPI_INSTALLATION.md new file mode 100644 index 000000000..1144d5cb3 --- /dev/null +++ b/docs/PYPI_INSTALLATION.md @@ -0,0 +1,117 @@ +# LangBot PyPI Package Installation + +## Quick Start with uvx + +The easiest way to run LangBot is using `uvx` (recommended for quick testing): + +```bash +uvx langbot +``` + +This will automatically download and run the latest version of LangBot. + +## Install with pip/uv + +You can also install LangBot as a regular Python package: + +```bash +# Using pip +pip install langbot + +# Using uv +uv pip install langbot +``` + +Then run it: + +```bash +langbot +``` + +Or using Python module syntax: + +```bash +python -m langbot +``` + +## Installation with Frontend + +When published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately. + +## Data Directory + +When running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there. + +## Command Line Options + +LangBot supports the following command line options: + +- `--standalone-runtime`: Use standalone plugin runtime +- `--debug`: Enable debug mode + +Example: + +```bash +langbot --debug +``` + +## Comparison with Other Installation Methods + +### PyPI Package (uvx/pip) +- **Pros**: Easy to install and update, no need to clone repository or build frontend +- **Cons**: Less flexible for development/customization + +### Docker +- **Pros**: Isolated environment, easy deployment +- **Cons**: Requires Docker + +### Manual Source Installation +- **Pros**: Full control, easy to customize and develop +- **Cons**: Requires building frontend, managing dependencies manually + +## Development + +If you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository: + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot +uv sync +cd web +npm install +npm run build +cd .. +uv run main.py +``` + +## Updating + +To update to the latest version: + +```bash +# With pip +pip install --upgrade langbot + +# With uv +uv pip install --upgrade langbot + +# With uvx (automatically uses latest) +uvx langbot +``` + +## System Requirements + +- Python 3.10.1 or higher +- Operating System: Linux, macOS, or Windows + +## Differences from Source Installation + +When running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source: + +1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD. + +2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory. + +3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately. + +These differences are intentional to make the package more user-friendly and suitable for various deployment scenarios. diff --git a/langbot/__init__.py b/langbot/__init__.py new file mode 100644 index 000000000..55de650fc --- /dev/null +++ b/langbot/__init__.py @@ -0,0 +1,3 @@ +"""LangBot - Easy-to-use global IM bot platform designed for LLM era""" + +__version__ = "4.4.1" diff --git a/langbot/__main__.py b/langbot/__main__.py new file mode 100644 index 000000000..bba1bfc42 --- /dev/null +++ b/langbot/__main__.py @@ -0,0 +1,103 @@ +"""LangBot entry point for package execution""" +import asyncio +import argparse +import sys +import os + +# ASCII art banner +asciiart = r""" + _ ___ _ +| | __ _ _ _ __ _| _ ) ___| |_ +| |__/ _` | ' \/ _` | _ \/ _ \ _| +|____\__,_|_||_\__, |___/\___/\__| + |___/ + +⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot +📖 Documentation 文档地址: https://docs.langbot.app +""" + + +async def main_entry(loop: asyncio.AbstractEventLoop): + """Main entry point for LangBot""" + parser = argparse.ArgumentParser(description='LangBot') + parser.add_argument( + '--standalone-runtime', + action='store_true', + help='Use standalone plugin runtime / 使用独立插件运行时', + default=False, + ) + parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) + args = parser.parse_args() + + if args.standalone_runtime: + from pkg.utils import platform + + platform.standalone_runtime = True + + if args.debug: + from pkg.utils import constants + + constants.debug_mode = True + + print(asciiart) + + # Check dependencies + from pkg.core.bootutils import deps + + missing_deps = await deps.check_deps() + + if missing_deps: + print('以下依赖包未安装,将自动安装,请完成后重启程序:') + print( + 'These dependencies are missing, they will be installed automatically, please restart the program after completion:' + ) + for dep in missing_deps: + print('-', dep) + await deps.install_deps(missing_deps) + print('已自动安装缺失的依赖包,请重启程序。') + print('The missing dependencies have been installed automatically, please restart the program.') + sys.exit(0) + + # Check configuration files + from pkg.core.bootutils import files + + generated_files = await files.generate_files() + + if generated_files: + print('以下文件不存在,已自动生成:') + print('Following files do not exist and have been automatically generated:') + for file in generated_files: + print('-', file) + + from pkg.core import boot + + await boot.main(loop) + + +def main(): + """Main function to be called by console script entry point""" + # Check Python version + if sys.version_info < (3, 10, 1): + print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version) + print('Your Python version is not supported.') + print('Python 3.10.1 or higher is required. Current version:', sys.version) + sys.exit(1) + + # Set up the working directory + # When installed as a package, we need to handle the working directory differently + # We'll create data directory in current working directory if not exists + os.makedirs('data', exist_ok=True) + + loop = asyncio.new_event_loop() + + try: + loop.run_until_complete(main_entry(loop)) + except KeyboardInterrupt: + print('\n正在退出...') + print('Exiting...') + finally: + loop.close() + + +if __name__ == '__main__': + main() diff --git a/templates/__init__.py b/langbot/templates/__init__.py similarity index 100% rename from templates/__init__.py rename to langbot/templates/__init__.py diff --git a/templates/config.yaml b/langbot/templates/config.yaml similarity index 100% rename from templates/config.yaml rename to langbot/templates/config.yaml diff --git a/templates/default-pipeline-config.json b/langbot/templates/default-pipeline-config.json similarity index 100% rename from templates/default-pipeline-config.json rename to langbot/templates/default-pipeline-config.json diff --git a/templates/legacy/command.json b/langbot/templates/legacy/command.json similarity index 100% rename from templates/legacy/command.json rename to langbot/templates/legacy/command.json diff --git a/templates/legacy/pipeline.json b/langbot/templates/legacy/pipeline.json similarity index 100% rename from templates/legacy/pipeline.json rename to langbot/templates/legacy/pipeline.json diff --git a/templates/legacy/platform.json b/langbot/templates/legacy/platform.json similarity index 100% rename from templates/legacy/platform.json rename to langbot/templates/legacy/platform.json diff --git a/templates/legacy/provider.json b/langbot/templates/legacy/provider.json similarity index 100% rename from templates/legacy/provider.json rename to langbot/templates/legacy/provider.json diff --git a/templates/legacy/system.json b/langbot/templates/legacy/system.json similarity index 100% rename from templates/legacy/system.json rename to langbot/templates/legacy/system.json diff --git a/templates/metadata/pipeline/ai.yaml b/langbot/templates/metadata/pipeline/ai.yaml similarity index 100% rename from templates/metadata/pipeline/ai.yaml rename to langbot/templates/metadata/pipeline/ai.yaml diff --git a/templates/metadata/pipeline/output.yaml b/langbot/templates/metadata/pipeline/output.yaml similarity index 100% rename from templates/metadata/pipeline/output.yaml rename to langbot/templates/metadata/pipeline/output.yaml diff --git a/templates/metadata/pipeline/safety.yaml b/langbot/templates/metadata/pipeline/safety.yaml similarity index 100% rename from templates/metadata/pipeline/safety.yaml rename to langbot/templates/metadata/pipeline/safety.yaml diff --git a/templates/metadata/pipeline/trigger.yaml b/langbot/templates/metadata/pipeline/trigger.yaml similarity index 100% rename from templates/metadata/pipeline/trigger.yaml rename to langbot/templates/metadata/pipeline/trigger.yaml diff --git a/templates/metadata/sensitive-words.json b/langbot/templates/metadata/sensitive-words.json similarity index 100% rename from templates/metadata/sensitive-words.json rename to langbot/templates/metadata/sensitive-words.json diff --git a/pkg/api/http/controller/main.py b/pkg/api/http/controller/main.py index ca12f4bb4..1647aa21b 100644 --- a/pkg/api/http/controller/main.py +++ b/pkg/api/http/controller/main.py @@ -86,7 +86,9 @@ async def healthz(): ginst = g(self.ap, self.quart_app) await ginst.initialize() - frontend_path = 'web/out' + from ....utils import paths + + frontend_path = paths.get_frontend_path() @self.quart_app.route('/') async def index(): diff --git a/pkg/api/http/service/pipeline.py b/pkg/api/http/service/pipeline.py index 2e00a74d0..62e0879f6 100644 --- a/pkg/api/http/service/pipeline.py +++ b/pkg/api/http/service/pipeline.py @@ -30,12 +30,12 @@ class PipelineService: def __init__(self, ap: app.Application) -> None: self.ap = ap - async def get_pipeline_metadata(self) -> dict: + async def get_pipeline_metadata(self) -> list[dict]: return [ - self.ap.pipeline_config_meta_trigger.data, - self.ap.pipeline_config_meta_safety.data, - self.ap.pipeline_config_meta_ai.data, - self.ap.pipeline_config_meta_output.data, + self.ap.pipeline_config_meta_trigger, + self.ap.pipeline_config_meta_safety, + self.ap.pipeline_config_meta_ai, + self.ap.pipeline_config_meta_output, ] async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]: @@ -74,11 +74,16 @@ async def get_pipeline(self, pipeline_uuid: str) -> dict | None: return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: + from ....utils import paths as path_utils + pipeline_data['uuid'] = str(uuid.uuid4()) pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version() pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default - pipeline_data['config'] = json.load(open('templates/default-pipeline-config.json', 'r', encoding='utf-8')) + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + pipeline_data['config'] = json.load(f) await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data) @@ -137,7 +142,9 @@ async def delete_pipeline(self, pipeline_uuid: str) -> None: ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) - async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None) -> None: + async def update_pipeline_extensions( + self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None + ) -> None: """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline result = await self.ap.persistence_mgr.execute_async( @@ -145,23 +152,23 @@ async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: li persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid ) ) - + pipeline = result.first() if pipeline is None: raise ValueError(f'Pipeline {pipeline_uuid} not found') - + # Update extensions_preferences extensions_preferences = pipeline.extensions_preferences or {} extensions_preferences['plugins'] = bound_plugins if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) .values(extensions_preferences=extensions_preferences) ) - + # Reload pipeline to apply changes await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) pipeline = await self.get_pipeline(pipeline_uuid) diff --git a/pkg/config/impls/json.py b/pkg/config/impls/json.py index 44b4843ce..59ab6dee3 100644 --- a/pkg/config/impls/json.py +++ b/pkg/config/impls/json.py @@ -1,6 +1,6 @@ import os -import shutil import json +import importlib.resources as resources from .. import model as file_model @@ -11,19 +11,29 @@ class JSONConfigFile(file_model.ConfigFile): def __init__( self, config_file_name: str, - template_file_name: str = None, + template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name - self.template_file_name = template_file_name + self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + async def create(self): - if self.template_file_name is not None: - shutil.copyfile(self.template_file_name, self.config_file_name) + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: json.dump(self.template_data, f, indent=4, ensure_ascii=False) @@ -34,9 +44,10 @@ async def load(self, completion: bool = True) -> dict: if not self.exists(): await self.create() - if self.template_file_name is not None: - with open(self.template_file_name, 'r', encoding='utf-8') as f: - self.template_data = json.load(f) + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = json.loads(template_file_str) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: diff --git a/pkg/config/impls/yaml.py b/pkg/config/impls/yaml.py index 0d69ef9eb..56e3fc8de 100644 --- a/pkg/config/impls/yaml.py +++ b/pkg/config/impls/yaml.py @@ -1,6 +1,6 @@ import os -import shutil import yaml +import importlib.resources as resources from .. import model as file_model @@ -11,19 +11,29 @@ class YAMLConfigFile(file_model.ConfigFile): def __init__( self, config_file_name: str, - template_file_name: str = None, + template_resource_name: str = None, template_data: dict = None, ) -> None: self.config_file_name = config_file_name - self.template_file_name = template_file_name + self.template_resource_name = template_resource_name self.template_data = template_data def exists(self) -> bool: return os.path.exists(self.config_file_name) + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + async def create(self): - if self.template_file_name is not None: - shutil.copyfile(self.template_file_name, self.config_file_name) + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) elif self.template_data is not None: with open(self.config_file_name, 'w', encoding='utf-8') as f: yaml.dump(self.template_data, f, indent=4, allow_unicode=True) @@ -34,9 +44,10 @@ async def load(self, completion: bool = True) -> dict: if not self.exists(): await self.create() - if self.template_file_name is not None: - with open(self.template_file_name, 'r', encoding='utf-8') as f: - self.template_data = yaml.load(f, Loader=yaml.FullLoader) + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader) with open(self.config_file_name, 'r', encoding='utf-8') as f: try: diff --git a/pkg/config/manager.py b/pkg/config/manager.py index d552b0384..d22591b05 100644 --- a/pkg/config/manager.py +++ b/pkg/config/manager.py @@ -62,7 +62,7 @@ async def load_python_module_config(config_name: str, template_name: str, comple async def load_json_config( config_name: str, - template_name: str = None, + template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: @@ -70,11 +70,11 @@ async def load_json_config( Args: config_name (str): Config file name - template_name (str): Template file name + template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory """ - cfg_inst = json_file.JSONConfigFile(config_name, template_name, template_data) + cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) @@ -84,7 +84,7 @@ async def load_json_config( async def load_yaml_config( config_name: str, - template_name: str = None, + template_resource_name: str = None, template_data: dict = None, completion: bool = True, ) -> ConfigManager: @@ -92,14 +92,14 @@ async def load_yaml_config( Args: config_name (str): Config file name - template_name (str): Template file name + template_resource_name (str): Template resource name template_data (dict): Template data completion (bool): Whether to automatically complete the config file in memory Returns: ConfigManager: Config file manager """ - cfg_inst = yaml_file.YAMLConfigFile(config_name, template_name, template_data) + cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data) cfg_mgr = ConfigManager(cfg_inst) await cfg_mgr.load_config(completion=completion) diff --git a/pkg/core/app.py b/pkg/core/app.py index 9b29fdc73..83825dbd4 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -179,8 +179,12 @@ def dispose(self): async def print_web_access_info(self): """Print access webui tips""" + + from ..utils import paths + + frontend_path = paths.get_frontend_path() - if not os.path.exists(os.path.join('.', 'web/out')): + if not os.path.exists(frontend_path): self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh') self.logger.warning( 'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en' diff --git a/pkg/core/bootutils/files.py b/pkg/core/bootutils/files.py index 07bb77793..eaa272625 100644 --- a/pkg/core/bootutils/files.py +++ b/pkg/core/bootutils/files.py @@ -19,6 +19,8 @@ async def generate_files() -> list[str]: global required_files, required_paths + + from ...utils import paths as path_utils for required_paths in required_paths: if not os.path.exists(required_paths): @@ -27,7 +29,8 @@ async def generate_files() -> list[str]: generated_files = [] for file in required_files: if not os.path.exists(file): - shutil.copyfile(required_files[file], file) + template_path = path_utils.get_resource_path(required_files[file]) + shutil.copyfile(template_path, file) generated_files.append(file) return generated_files diff --git a/pkg/core/stages/load_config.py b/pkg/core/stages/load_config.py index 2ef5623eb..b2b5abbae 100644 --- a/pkg/core/stages/load_config.py +++ b/pkg/core/stages/load_config.py @@ -2,6 +2,8 @@ import os from typing import Any +import yaml +import importlib.resources as resources from .. import stage, app from ..bootutils import config @@ -95,47 +97,45 @@ class LoadConfigStage(stage.BootingStage): async def run(self, ap: app.Application): """Load config file""" - # ======= deprecated ======= - if os.path.exists('data/config/command.json'): - ap.command_cfg = await config.load_json_config( - 'data/config/command.json', - 'templates/legacy/command.json', - completion=False, - ) - - if os.path.exists('data/config/pipeline.json'): - ap.pipeline_cfg = await config.load_json_config( - 'data/config/pipeline.json', - 'templates/legacy/pipeline.json', - completion=False, - ) - - if os.path.exists('data/config/platform.json'): - ap.platform_cfg = await config.load_json_config( - 'data/config/platform.json', - 'templates/legacy/platform.json', - completion=False, - ) - - if os.path.exists('data/config/provider.json'): - ap.provider_cfg = await config.load_json_config( - 'data/config/provider.json', - 'templates/legacy/provider.json', - completion=False, - ) - - if os.path.exists('data/config/system.json'): - ap.system_cfg = await config.load_json_config( - 'data/config/system.json', - 'templates/legacy/system.json', - completion=False, - ) - - # ======= deprecated ======= - - ap.instance_config = await config.load_yaml_config( - 'data/config.yaml', 'templates/config.yaml', completion=False - ) + # # ======= deprecated ======= + # if os.path.exists('data/config/command.json'): + # ap.command_cfg = await config.load_json_config( + # 'data/config/command.json', + # 'templates/legacy/command.json', + # completion=False, + # ) + + # if os.path.exists('data/config/pipeline.json'): + # ap.pipeline_cfg = await config.load_json_config( + # 'data/config/pipeline.json', + # 'templates/legacy/pipeline.json', + # completion=False, + # ) + + # if os.path.exists('data/config/platform.json'): + # ap.platform_cfg = await config.load_json_config( + # 'data/config/platform.json', + # 'templates/legacy/platform.json', + # completion=False, + # ) + + # if os.path.exists('data/config/provider.json'): + # ap.provider_cfg = await config.load_json_config( + # 'data/config/provider.json', + # 'templates/legacy/provider.json', + # completion=False, + # ) + + # if os.path.exists('data/config/system.json'): + # ap.system_cfg = await config.load_json_config( + # 'data/config/system.json', + # 'templates/legacy/system.json', + # completion=False, + # ) + + # # ======= deprecated ======= + + ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) # Apply environment variable overrides to data/config.yaml ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) @@ -144,22 +144,15 @@ async def run(self, ap: app.Application): ap.sensitive_meta = await config.load_json_config( 'data/metadata/sensitive-words.json', - 'templates/metadata/sensitive-words.json', + 'metadata/sensitive-words.json', ) await ap.sensitive_meta.dump_config() - ap.pipeline_config_meta_trigger = await config.load_yaml_config( - 'templates/metadata/pipeline/trigger.yaml', - 'templates/metadata/pipeline/trigger.yaml', - ) - ap.pipeline_config_meta_safety = await config.load_yaml_config( - 'templates/metadata/pipeline/safety.yaml', - 'templates/metadata/pipeline/safety.yaml', - ) - ap.pipeline_config_meta_ai = await config.load_yaml_config( - 'templates/metadata/pipeline/ai.yaml', 'templates/metadata/pipeline/ai.yaml' - ) - ap.pipeline_config_meta_output = await config.load_yaml_config( - 'templates/metadata/pipeline/output.yaml', - 'templates/metadata/pipeline/output.yaml', - ) + async def load_resource_yaml_template_data(resource_name: str) -> dict: + with resources.files('langbot.templates').joinpath(resource_name).open('r', encoding='utf-8') as f: + return yaml.load(f, Loader=yaml.FullLoader) + + ap.pipeline_config_meta_trigger = await load_resource_yaml_template_data('metadata/pipeline/trigger.yaml') + ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml') + ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml') + ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml') diff --git a/pkg/utils/paths.py b/pkg/utils/paths.py new file mode 100644 index 000000000..20880c571 --- /dev/null +++ b/pkg/utils/paths.py @@ -0,0 +1,92 @@ +"""Utility functions for finding package resources""" +import os +import sys +from pathlib import Path + + +_is_source_install = None + + +def _check_if_source_install() -> bool: + """ + Check if we're running from source directory or an installed package. + Cached to avoid repeated file I/O. + """ + global _is_source_install + + if _is_source_install is not None: + return _is_source_install + + # Check if main.py exists in current directory with LangBot marker + if os.path.exists('main.py'): + try: + with open('main.py', 'r', encoding='utf-8') as f: + # Only read first 500 chars to check for marker + content = f.read(500) + if 'LangBot/main.py' in content: + _is_source_install = True + return True + except (IOError, OSError, UnicodeDecodeError): + # If we can't read the file, assume not a source install + pass + + _is_source_install = False + return False + + +def get_frontend_path() -> str: + """ + Get the path to the frontend build files. + + Returns the path to web/out directory, handling both: + - Development mode: running from source directory + - Package mode: installed via pip/uvx + """ + # First, check if we're running from source directory + if _check_if_source_install() and os.path.exists('web/out'): + return 'web/out' + + # Second, check current directory for web/out (in case user is in source dir) + if os.path.exists('web/out'): + return 'web/out' + + # Third, find it relative to the package installation + # Get the directory where this file is located + # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root + pkg_dir = Path(__file__).parent.parent.parent + frontend_path = pkg_dir / 'web' / 'out' + if frontend_path.exists(): + return str(frontend_path) + + # Return the default path (will be checked by caller) + return 'web/out' + + +def get_resource_path(resource: str) -> str: + """ + Get the path to a resource file. + + Args: + resource: Relative path to resource (e.g., 'templates/config.yaml') + + Returns: + Absolute path to the resource + """ + # First, check if resource exists in current directory (source install) + if _check_if_source_install() and os.path.exists(resource): + return resource + + # Second, check current directory anyway + if os.path.exists(resource): + return resource + + # Third, find it relative to package directory + # Get the directory where this file is located + # paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root + pkg_dir = Path(__file__).parent.parent.parent + resource_path = pkg_dir / resource + if resource_path.exists(): + return str(resource_path) + + # Return the original path + return resource diff --git a/pyproject.toml b/pyproject.toml index 22df7131f..c33c7ea45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ name = "langbot" version = "4.4.1" description = "Easy-to-use global IM bot platform designed for LLM era" readme = "README.md" +license-files = ["LICENSE"] requires-python = ">=3.10.1,<4.0" dependencies = [ "aiocqhttp>=1.4.4", @@ -84,11 +85,10 @@ keywords = [ "onebot", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Framework :: Robot Framework", "Framework :: Robot Framework :: Library", - "License :: OSI Approved :: AGPL-3 License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Communications :: Chat", @@ -99,6 +99,17 @@ Homepage = "https://langbot.app" Documentation = "https://docs.langbot.app" Repository = "https://github.com/langbot-app/LangBot" +[project.scripts] +langbot = "langbot.__main__:main" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = { find = {} } +include-package-data = true + [dependency-groups] dev = [ "pre-commit>=4.2.0",