chore: Add PyPI package support for uvx/pip installation (#1764)

* Initial plan

* Add package structure and resource path utilities

- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI publishing workflow and update license

- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI installation documentation

- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Address code review feedback

- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix code review issues

- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Simplify package configuration and document behavioral differences

- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: update pyproject.toml

* chore: try pack templates in langbot/

* chore: update

* chore: update

* chore: update

* chore: update

* chore: update

* chore: adjust dir structure

* chore: fix imports

* fix: read default-pipeline-config.json

* fix: read default-pipeline-config.json

* fix: tests

* ci: publish pypi

* chore: bump version 4.6.0-beta.1 for testing

* chore: add templates/**

* fix: send adapters and requesters icons

* chore: bump version 4.6.0b2 for testing

* chore: add platform field for docker-compose.yaml

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-16 19:53:01 +08:00
committed by GitHub
parent 6a24c951e0
commit e642ffa5b3
477 changed files with 1001 additions and 1002 deletions
@@ -0,0 +1,252 @@
from .. import migration
from copy import deepcopy
import uuid
import os
import sqlalchemy
import shutil
from ...config import manager as config_manager
from ...entity.persistence import (
model as persistence_model,
pipeline as persistence_pipeline,
bot as persistence_bot,
)
@migration.migration_class(1)
class DBMigrateV3Config(migration.DBMigration):
"""Migrate v3 config to v4 database"""
async def upgrade(self):
"""Upgrade"""
"""
Migrate all config files under data/config.
After migration, all previous config files are saved under data/legacy/config.
After migration, all config files under data/metadata/ are saved under data/legacy/metadata.
"""
if self.ap.provider_cfg is None:
return
# ======= Migrate model =======
# Only migrate the currently selected model
model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o')
model_requester = 'openai-chat-completions'
model_requester_config = {}
model_api_keys = ['sk-proj-1234567890']
model_abilities = []
model_extra_args = {}
if os.path.exists('data/metadata/llm-models.json'):
_llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False)
for item in _llm_model_meta.data.get('list', []):
if item.get('name') == model_name:
if 'model_name' in item:
model_name = item['model_name']
if 'requester' in item:
model_requester = item['requester']
if 'token_mgr' in item:
_token_mgr = item['token_mgr']
if _token_mgr in self.ap.provider_cfg.data.get('keys', {}):
model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr]
if 'tool_call_supported' in item and item['tool_call_supported']:
model_abilities.append('func_call')
if 'vision_supported' in item and item['vision_supported']:
model_abilities.append('vision')
if (
model_requester in self.ap.provider_cfg.data.get('requester', {})
and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester]
):
model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args']
if model_requester in self.ap.provider_cfg.data.get('requester', {}):
model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester]
model_requester_config = {
'base_url': model_requester_config['base-url'],
'timeout': model_requester_config['timeout'],
}
break
model_uuid = str(uuid.uuid4())
llm_model_data = {
'uuid': model_uuid,
'name': model_name,
'description': '由 LangBot v3 迁移而来',
'requester': model_requester,
'requester_config': model_requester_config,
'api_keys': model_api_keys,
'abilities': model_abilities,
'extra_args': model_extra_args,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data)
)
# ======= Migrate pipeline config =======
# Modify to default pipeline
default_pipeline = [
self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline in (
await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
)
)
).all()
][0]
pipeline_uuid = str(uuid.uuid4())
pipeline_name = 'ChatPipeline'
if default_pipeline:
pipeline_name = default_pipeline['name']
pipeline_uuid = default_pipeline['uuid']
pipeline_config = default_pipeline['config']
# ai
pipeline_config['ai']['runner'] = {
'runner': self.ap.provider_cfg.data['runner'],
}
pipeline_config['ai']['local-agent']['model'] = model_uuid
pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][
'max-round'
]
pipeline_config['ai']['local-agent']['prompt'] = [
{
'role': 'system',
'content': self.ap.provider_cfg.data['prompt']['default'],
}
]
pipeline_config['ai']['dify-service-api'] = {
'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'],
'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'],
'api-key': self.ap.provider_cfg.data['dify-service-api'][
self.ap.provider_cfg.data['dify-service-api']['app-type']
]['api-key'],
'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'],
'timeout': self.ap.provider_cfg.data['dify-service-api'][
self.ap.provider_cfg.data['dify-service-api']['app-type']
]['timeout'],
}
pipeline_config['ai']['dashscope-app-api'] = {
'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'],
'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'],
'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][
self.ap.provider_cfg.data['dashscope-app-api']['app-type']
]['references_quote'],
}
# trigger
pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default']
pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control']
pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules']
# safety
pipeline_config['safety']['content-filter'] = {
'scope': 'all',
'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'],
}
pipeline_config['safety']['rate-limit'] = {
'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'],
'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'],
'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'],
}
# output
pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process']
pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay']
pipeline_config['output']['misc'] = {
'hide-exception': self.ap.platform_cfg.data['hide-exception-info'],
'quote-origin': self.ap.platform_cfg.data['quote-origin'],
'at-sender': self.ap.platform_cfg.data['at-sender'],
'track-function-calls': self.ap.platform_cfg.data['track-function-calls'],
}
default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]'
default_pipeline['config'] = pipeline_config
default_pipeline.pop('created_at')
default_pipeline.pop('updated_at')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.values(default_pipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid'])
)
# ======= Migrate bot =======
# Only migrate enabled bots
for adapter in self.ap.platform_cfg.data.get('platform-adapters', []):
if not adapter.get('enable'):
continue
args = deepcopy(adapter)
args.pop('adapter')
args.pop('enable')
bot_data = {
'uuid': str(uuid.uuid4()),
'name': adapter.get('adapter'),
'description': '由 LangBot v3 迁移而来',
'adapter': adapter.get('adapter'),
'adapter_config': args,
'enable': True,
'use_pipeline_uuid': pipeline_uuid,
'use_pipeline_name': pipeline_name,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data))
# ======= Migrate system settings =======
self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions']
self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port']
self.ap.instance_config.data['command'] = {
'prefix': self.ap.command_cfg.data['command-prefix'],
'enable': self.ap.command_cfg.data['command-enable']
if 'command-enable' in self.ap.command_cfg.data
else True,
'privilege': self.ap.command_cfg.data['privilege'],
}
self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency']
self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][
'default'
]
self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp']
self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies']
await self.ap.instance_config.dump_config()
# ======= move files =======
# Migrate all config files under data/config
all_legacy_dir_name = [
'config',
# 'metadata',
'prompts',
'scenario',
]
def move_legacy_files(dir_name: str):
if not os.path.exists(f'data/legacy/{dir_name}'):
os.makedirs(f'data/legacy/{dir_name}')
if os.path.exists(f'data/{dir_name}'):
for file in os.listdir(f'data/{dir_name}'):
if file.endswith('.json'):
shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}')
os.rmdir(f'data/{dir_name}')
for dir_name in all_legacy_dir_name:
move_legacy_files(dir_name)
async def downgrade(self):
"""Downgrade"""