Compare commits

..

7 Commits

Author SHA1 Message Date
RockChinQ
98ccbf0f99 refactor: extract RoutingRulesEditor component, revert log levels to debug
- Extract ~250 lines of inline routing rules UI from BotForm into
  a dedicated RoutingRulesEditor component
- Revert stage interrupt and event prevented-default log levels
  from warning back to debug (these are normal flow, not errors)
- Remove message content from log lines to avoid leaking user data
2026-04-02 22:19:28 +08:00
Typer_Body
eb633f8849 fix: format BotForm.tsx with prettier 2026-04-02 01:38:21 +08:00
Typer_Body
ac337b31df feat: pipeline routing fix - add routed_by_rule bypass and diagnostic logging
- Skip GroupRespondRuleCheckStage when message is routed by rule
- Add WARNING logs when queries are silently dropped
- Add pipeline routing rules support (bot entity, migration, web UI)
- Pass routed_by_rule flag through aggregator -> pool -> query variables
2026-04-02 01:33:17 +08:00
Typer_Body
c3e2d5e055 Merge remote-tracking branch 'origin/master' into temp-update
# Conflicts:
#	web/pnpm-lock.yaml
2026-04-02 01:18:38 +08:00
Junyan Qin
723c57d751 fix: linter err 2026-03-29 23:57:48 +08:00
Junyan Qin
0a69875c09 feat: enhance plugin installation process and improve task management 2026-03-29 23:55:36 +08:00
Typer_Body
f41d69324c Optimize the plugin system 2026-03-29 16:45:54 +08:00
195 changed files with 8876 additions and 9353 deletions

View File

@@ -43,10 +43,10 @@ jobs:
run: |
cd /tmp/langbot_build_web/web
npm install
npx vite build
npm run build
- name: Package Output
run: |
cp -r /tmp/langbot_build_web/web/dist ./web
cp -r /tmp/langbot_build_web/web/out ./web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -1,25 +0,0 @@
name: Check i18n Keys
on:
push:
branches:
- main
- master
jobs:
check-i18n:
name: Check i18n Key Consistency
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check i18n keys against en-US reference
run: node web/scripts/check-i18n.mjs

View File

@@ -29,8 +29,8 @@ jobs:
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
mkdir -p ../src/langbot/web/out
cp -r out ../src/langbot/web/
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6

View File

@@ -1,171 +0,0 @@
name: Test Migrations
on:
push:
branches:
- main
- master
- dev
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
jobs:
test-migrations-sqlite:
name: Migrations (SQLite)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (SQLite)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
async def main():
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
# Create all tables (simulates existing DB)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None, 'Expected a revision after upgrade'
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: upgrade from scratch
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All SQLite migration tests passed!')
asyncio.run(main())
"
test-migrations-postgres:
name: Migrations (PostgreSQL)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langbot
POSTGRES_PASSWORD: langbot
POSTGRES_DB: langbot_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langbot"
--health-interval=5s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (PostgreSQL)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
async def main():
engine = create_async_engine(DB_URL)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: drop all and upgrade from scratch
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
# Create fresh database
from sqlalchemy import text
async with engine.connect() as conn:
await conn.execute(text('COMMIT'))
await conn.execute(text('CREATE DATABASE langbot_fresh'))
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All PostgreSQL migration tests passed!')
asyncio.run(main())
"

3
.gitignore vendored
View File

@@ -52,6 +52,3 @@ src/langbot/web/
/dist
/build
*.egg-info
# Next.js build cache (legacy)
web/.next/

View File

@@ -70,7 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
## Some Principles

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY web ./web
RUN cd web && npm install && npx vite build
RUN cd web && npm install && npm run build
FROM python:3.12.7-slim
@@ -12,7 +12,7 @@ WORKDIR /app
COPY . .
COPY --from=node /app/web/dist ./web/dist
COPY --from=node /app/web/out ./web/out
RUN apt update \
&& apt install gcc -y \

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.6"
version = "4.9.5"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
dependencies = [
"aiocqhttp>=1.4.4",
"aiofiles>=24.1.0",
"aiohttp>=3.13.4",
"aiohttp>=3.11.18",
"aioshutil>=1.5",
"aiosqlite>=0.21.0",
"anthropic>=0.51.0",
@@ -16,7 +16,7 @@ dependencies = [
"async-lru>=2.0.5",
"certifi>=2025.4.26",
"colorlog~=6.6.0",
"cryptography>=46.0.7",
"cryptography>=44.0.3",
"dashscope>=1.25.10",
"dingtalk-stream>=0.24.0",
"discord-py>=2.5.2",
@@ -27,7 +27,7 @@ dependencies = [
"nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8",
"openai>1.0.0",
"pillow>=12.2.0",
"pillow>=11.2.1",
"psutil>=7.0.0",
"pycryptodome>=3.22.0",
"pydantic>2.0",
@@ -39,7 +39,6 @@ dependencies = [
"quart-cors>=0.8.0",
"requests>=2.32.3",
"slack-sdk>=3.35.0",
"alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40",
"sqlmodel>=0.0.24",
"telegramify-markdown>=0.5.1",
@@ -50,7 +49,7 @@ dependencies = [
"pip>=25.1.1",
"ruff>=0.11.9",
"pre-commit>=4.2.0",
"uv>=0.11.6",
"uv>=0.7.11",
"mypy>=1.16.0",
"PyPDF2>=3.0.1",
"python-docx>=1.1.0",
@@ -61,15 +60,11 @@ dependencies = [
"ebooklib>=0.18",
"html2text>=2024.2.26",
"langchain>=0.2.0",
"langchain-core>=1.2.28",
"langsmith>=0.7.31",
"python-multipart>=0.0.26",
"Mako>=1.3.11",
"langchain-text-splitters>=1.1.2",
"langchain-text-splitters>=0.0.1",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.8",
"langbot-plugin==0.3.6",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -116,12 +111,12 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
[dependency-groups]
dev = [
"pre-commit>=4.2.0",
"pytest>=9.0.3",
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
"pytest-cov>=7.0.0",
"ruff>=0.11.9",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.6'
__version__ = '4.9.5'

View File

@@ -182,88 +182,6 @@ class DingTalkClient:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
"""Parse the quoted/replied message and extract its content.
Args:
replied_msg: The repliedMsg object from DingTalk message
Returns:
A dict containing the quoted message info with keys:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
quote_info = {
'message_id': replied_msg.get('msgId', ''),
'msg_type': replied_msg.get('msgType', ''),
'sender_id': replied_msg.get('senderId', ''),
}
msg_type = replied_msg.get('msgType', '')
content = replied_msg.get('content', {})
# Handle content as string (JSON) or dict
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
content = {}
if msg_type == 'text':
# Text message
if isinstance(content, dict):
quote_info['content'] = content.get('content', '')
else:
quote_info['content'] = str(content)
elif msg_type == 'file':
# File message
download_code = content.get('downloadCode')
file_name = content.get('fileName')
if download_code and file_name:
try:
quote_info['file_url'] = await self.get_file_url(download_code)
quote_info['file_name'] = file_name
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted file URL: {e}')
elif msg_type == 'picture':
# Picture message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['picture'] = await self.download_image(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to download quoted image: {e}')
elif msg_type == 'audio':
# Audio message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['audio'] = await self.get_audio_url(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted audio: {e}')
elif msg_type == 'richText':
# Rich text message - extract text content
rich_text = content.get('richText', [])
texts = []
for item in rich_text:
if 'text' in item and item['text'] != '\n':
texts.append(item['text'])
quote_info['content'] = '\n'.join(texts)
return quote_info
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
try:
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
@@ -275,15 +193,6 @@ class DingTalkClient:
elif str(incoming_message.conversation_type) == '2':
message_data['conversation_type'] = 'GroupMessage'
# Check for quoted/replied message
raw_data = incoming_message.to_dict()
text_data = raw_data.get('text', {})
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
replied_msg = text_data.get('repliedMsg', {})
if replied_msg:
quote_info = await self._parse_quoted_message(replied_msg)
message_data['QuotedMessage'] = quote_info
if incoming_message.message_type == 'richText':
data = incoming_message.rich_text_content.to_dict()
@@ -359,25 +268,7 @@ class DingTalkClient:
message_data['Type'] = 'image'
elif incoming_message.message_type == 'audio':
raw_content = incoming_message.to_dict().get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(raw_content, str):
try:
raw_content = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
raw_content = {}
if self.logger:
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
# 提取钉钉自带的语音转写文字Powered by Qwen
recognition = raw_content.get('recognition', '')
if recognition:
message_data['Content'] = recognition
download_code = raw_content.get('downloadCode')
if download_code:
message_data['Audio'] = await self.get_audio_url(download_code)
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file':

View File

@@ -47,22 +47,6 @@ class DingTalkEvent(dict):
def conversation(self):
return self.get('conversation_type', '')
@property
def quoted_message(self) -> Optional[Dict[str, Any]]:
"""Get the quoted/replied message info if this is a reply message.
Returns:
A dict containing:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
return self.get('QuotedMessage')
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。

View File

@@ -71,11 +71,6 @@ class StreamSession:
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
# Sessions with registered feedback_ids use a longer TTL to survive the
# full like → cancel → dislike feedback flow. Must align with the adapter's
# _stream_to_monitoring_msg TTL (wecombot.py).
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
self.logger = logger
@@ -219,17 +214,11 @@ class StreamSessionManager:
session.last_access = time.time()
def cleanup(self) -> None:
"""定期清理过期会话,防止队列与映射无上限累积。
已注册 feedback_id 的会话使用更长的 TTL确保用户在点赞/取消/点踩流程中
不会因为 session 被提前清除而丢失上下文信息。
"""
"""定期清理过期会话,防止队列与映射无上限累积。"""
now = time.time()
expired: list[str] = []
for stream_id, session in self._sessions.items():
# Sessions with registered feedback_ids use a longer TTL
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
if now - session.last_access > effective_ttl:
if now - session.last_access > self.ttl:
expired.append(stream_id)
for stream_id in expired:
@@ -239,9 +228,6 @@ class StreamSessionManager:
msg_id = session.msg_id
if msg_id and self._msg_index.get(msg_id) == stream_id:
self._msg_index.pop(msg_id, None)
# Clean up feedback index for expired sessions
if session.feedback_id:
self._feedback_index.pop(session.feedback_id, None)
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
@@ -448,10 +434,10 @@ async def parse_wecom_bot_message(
}
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if voice_base64:
# message_data['voice']['base64'] = voice_base64
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
@@ -463,12 +449,10 @@ async def parse_wecom_bot_message(
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
# if video_base64:
# video_data['base64'] = video_base64
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
@@ -482,15 +466,12 @@ async def parse_wecom_bot_message(
'download_url': download_url,
'extra': file_info,
}
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
# if file_bytes:
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
# if dl_filename and not file_data.get('filename'):
# file_data['filename'] = dl_filename
# 应为需要解密但是目前暂时不能下载到内部进行解密所以先将下载链接拼接aeskey返回给用户由插件去处理该链接的下载和解密逻辑
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
@@ -606,120 +587,6 @@ async def parse_wecom_bot_message(
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
# Handle quote (referenced message) - important for group chat file references
quote_info = msg_json.get('quote')
if quote_info:
quote_data: dict[str, Any] = {}
quote_type = quote_info.get('msgtype', '')
quote_data['msgtype'] = quote_type
if quote_type == 'text':
quote_data['content'] = quote_info.get('text', {}).get('content', '')
elif quote_type == 'image':
img_info = quote_info.get('image', {})
img_url = img_info.get('url', '')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
quote_data['picurl'] = base64_data
quote_data['images'] = [base64_data]
elif quote_type == 'file':
file_info = quote_info.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['file'] = file_data
elif quote_type == 'voice':
voice_info = quote_info.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
quote_data['content'] = voice_info.get('content')
# Same as private chat: append aeskey to url for plugin processing
if download_url and item_aeskey:
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['voice'] = voice_data
elif quote_type == 'video':
video_info = quote_info.get('video', {}) or {}
download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['video'] = video_data
elif quote_type == 'link':
quote_data['link'] = quote_info.get('link', {})
link = quote_data['link']
title = link.get('title', '')
desc = link.get('description') or link.get('digest', '')
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
elif quote_type == 'mixed':
# Handle mixed type in quote (text + images + files etc.)
items = quote_info.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
files.append(file_data)
if texts:
quote_data['content'] = ' '.join(texts)
if images:
quote_data['images'] = images
quote_data['picurl'] = images[0]
if files:
quote_data['files'] = files
quote_data['file'] = files[0]
message_data['quote'] = quote_data
return message_data
@@ -1031,38 +898,35 @@ class WecomBotClient:
)
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
if session:
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
# Dispatch feedback event regardless of session availability
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
except Exception:
await self.logger.error(traceback.format_exc())

View File

@@ -147,10 +147,3 @@ class WecomBotEvent(dict):
流式消息 ID
"""
return self.get('stream_id', '')
@property
def quote(self):
"""
引用消息信息(群聊中用户引用其他消息时返回)
"""
return self.get('quote', {})

View File

@@ -20,7 +20,7 @@ from typing import Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
@@ -96,12 +96,6 @@ class WecomBotWsClient:
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# Stream session info for feedback tracking
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
# Feedback tracking: feedback_id -> session info
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
# msg_id -> feedback_id (for associating feedback with message)
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
# ── Public API ──────────────────────────────────────────────────
@@ -170,27 +164,12 @@ class WecomBotWsClient:
return decorator
def on_feedback(self) -> Callable:
"""Decorator to register a feedback event handler.
Same interface as WecomBotClient.on_feedback for compatibility.
"""
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def reply_stream(
self,
req_id: str,
stream_id: str,
content: str,
finish: bool = False,
feedback_id: str = '',
) -> Optional[dict]:
"""Send a streaming reply frame.
@@ -199,22 +178,17 @@ class WecomBotWsClient:
stream_id: The stream ID for this streaming session.
content: The content to send (supports Markdown).
finish: Whether this is the final chunk.
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
Returns:
The ACK frame dict, or None on failure.
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
body = {
'msgtype': 'stream',
'stream': stream_payload,
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
}
return await self._send_reply(req_id, body)
@@ -279,23 +253,11 @@ class WecomBotWsClient:
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
return True
# Generate feedback_id for final chunk
feedback_id = ''
if is_final:
feedback_id = _generate_req_id('feedback')
self._msg_feedback_ids[msg_id] = feedback_id
# Store session info for feedback tracking
session_info = self._stream_sessions.get(msg_id)
if session_info:
self._feedback_sessions[feedback_id] = session_info
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
await self.reply_stream(req_id, stream_id, content, finish=is_final)
self._stream_last_content[msg_id] = content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
except Exception:
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
@@ -483,15 +445,6 @@ class WecomBotWsClient:
msg_id = message_data.get('msgid', '')
if msg_id:
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
# Store session info for feedback tracking
self._stream_sessions[msg_id] = {
'req_id': req_id,
'stream_id': stream_id,
'msg_id': msg_id,
'user_id': message_data.get('userid', ''),
'chat_id': message_data.get('chatid', ''),
'chat_type': message_data.get('type', 'single'),
}
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -501,7 +454,7 @@ class WecomBotWsClient:
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
async def _handle_event_callback(self, frame: dict):
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
@@ -526,54 +479,14 @@ class WecomBotWsClient:
if body.get('chatid'):
message_data['chatid'] = body.get('chatid', '')
if event_type == 'feedback_event':
feedback_event = event_info.get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
# Look up session by feedback_id
session_info = self._feedback_sessions.get(feedback_id)
session = None
if session_info:
session = StreamSession(
stream_id=session_info.get('stream_id', ''),
msg_id=session_info.get('msg_id', ''),
chat_id=session_info.get('chat_id') or None,
user_id=session_info.get('user_id') or None,
feedback_id=feedback_id,
)
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
return
event = wecombotevent.WecomBotEvent(message_data)
# Dispatch to event-specific handlers
if event_type in self._message_handlers:
for handler in self._message_handlers[event_type]:
await handler(event)
# Also dispatch to generic 'event' handlers
if 'event' in self._message_handlers:
for handler in self._message_handlers['event']:
await handler(event)

View File

@@ -43,12 +43,3 @@ class ModelProvidersRouterGroup(group.RouterGroup):
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(provider_uuid: str) -> str:
try:
model_type = quart.request.args.get('type')
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
return self.success(data=result)
except ValueError as e:
return self.http_status(400, -1, str(e))

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
return self.success(data={'tools': tool_list})
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
for tool in tools:
if tool.name == tool_name:
return self.success(
data={
'tool': {
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
}
)
return self.http_status(404, -1, f'Tool not found: {tool_name}')

View File

@@ -105,24 +105,23 @@ class HTTPController:
):
if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html'
elif not path.startswith('api/'):
# SPA fallback: serve index.html for all non-API, non-static routes
# so that React Router can handle client-side routing (Vite SPA).
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
if path.startswith('home/'):
segments = path.rstrip('/').split('/')
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(
frontend_path, parent_path, mimetype='text/html'
)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
elif path.startswith('home/'):
# SPA fallback for /home/* sub-routes.
# Entity detail views use query params (e.g. /home/bots?id=uuid),
# so the pre-rendered list page is served directly via path + '.html'.
# This fallback handles any remaining unmatched sub-paths.
segments = path.rstrip('/').split('/')
# Fallback to index.html for SPA client-side routing
# Walk up parent segments looking for matching .html files
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Final fallback to index.html for /home/* routes
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'

View File

@@ -173,7 +173,7 @@ class LLMModelsService:
raise Exception('provider not found')
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(uuid=model_uuid, **model_data),
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
@@ -334,7 +334,7 @@ class EmbeddingModelsService:
raise Exception('provider not found')
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(uuid=model_uuid, **model_data),
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)

View File

@@ -1224,83 +1224,30 @@ class MonitoringService:
"""
import json
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
return None
# Check if record with this feedback_id already exists
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
)
existing_row = existing_result.first()
if existing_row:
# UPDATE existing record
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
bot_id=bot_id or existing.bot_id,
bot_name=bot_name or existing.bot_name,
pipeline_id=pipeline_id or existing.pipeline_id,
pipeline_name=pipeline_name or existing.pipeline_name,
session_id=session_id or existing.session_id,
message_id=message_id or existing.message_id,
stream_id=stream_id or existing.stream_id,
user_id=user_id or existing.user_id,
platform=platform or existing.platform,
)
)
return existing.id
else:
# INSERT new record with IntegrityError defense
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': now,
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': reasons_json,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
return record_id
except Exception:
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
)
)
return feedback_id
return record_id
async def get_feedback_stats(
self,

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import uuid
import traceback
import sqlalchemy
@@ -165,66 +164,3 @@ class ModelProviderService:
.values(api_keys=[api_key])
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
provider = await self.get_provider(provider_uuid)
if provider is None:
raise ValueError('provider not found')
runtime_provider = await self.ap.model_mgr.load_provider(provider)
try:
scan_result = await runtime_provider.requester.scan_models(
runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None
)
except NotImplementedError:
raise ValueError('current provider does not support model scanning')
except Exception as exc:
self.ap.logger.warning(
f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}'
)
raise ValueError(str(exc)) from exc
if isinstance(scan_result, dict):
scanned_models = scan_result.get('models', [])
debug_info = scan_result.get('debug')
else:
scanned_models = scan_result
debug_info = None
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
filtered_models = []
for model in scanned_models:
scanned_type = model.get('type', 'llm')
if model_type and scanned_type != model_type:
continue
model_name = model.get('name') or model.get('id')
if not model_name:
continue
filtered_models.append(
{
'id': model.get('id', model_name),
'name': model_name,
'type': scanned_type,
'abilities': model.get('abilities', []),
'display_name': model.get('display_name'),
'description': model.get('description'),
'context_length': model.get('context_length'),
'owned_by': model.get('owned_by'),
'input_modalities': model.get('input_modalities', []),
'output_modalities': model.get('output_modalities', []),
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_llm_names
),
}
)
return {'models': filtered_models, 'debug': debug_info}

View File

@@ -65,8 +65,8 @@ class UserService:
user_obj = result_list[0]
# Check if this user has a local password set
if not user_obj.password:
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher()
@@ -108,8 +108,9 @@ class UserService:
if user_obj is None:
raise ValueError('User not found')
if not user_obj.password:
raise ValueError('No local password set, please set a password first')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password)

View File

@@ -80,12 +80,8 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
if i == len(keys) - 1:
# At the final key
if key in current:
if isinstance(current[key], list):
# Convert comma-separated string to list
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
elif isinstance(current[key], dict):
# Skip dict types
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it

View File

@@ -1,51 +0,0 @@
"""Alembic environment for LangBot.
This env.py is designed to be called programmatically (not via CLI).
It supports both SQLite and PostgreSQL.
The sync connection is passed via config attributes by the runner.
"""
from __future__ import annotations
from alembic import context
from sqlalchemy.engine import Connection
from langbot.pkg.entity.persistence.base import Base
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
url = context.config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations with a live sync connection passed via config attributes."""
connection: Connection = context.config.attributes.get('connection')
if connection is None:
raise RuntimeError('connection not provided in alembic config attributes')
context.configure(
connection=connection,
target_metadata=target_metadata,
# render_as_batch=True is critical for SQLite ALTER TABLE support
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,24 +0,0 @@
# Alembic script.py.mako — template for auto-generated revisions
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,24 +0,0 @@
"""baseline: stamp existing schema (db version 25)
This is a no-op migration that marks the starting point for Alembic.
All tables already exist via create_all() + legacy DBMigration system.
Revision ID: 0001_baseline
Revises: None
Create Date: 2026-04-08
"""
revision = '0001_baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: existing schema is already at database_version=25
# This revision serves as the Alembic baseline.
pass
def downgrade() -> None:
pass

View File

@@ -1,62 +0,0 @@
"""example: sample migration demonstrating Alembic patterns
This is a SAMPLE showing how to write migrations that work
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
Revision ID: 0002_sample
Revises: 0001_baseline
Create Date: 2026-04-08
Patterns demonstrated:
1. Schema change (add column) — works on both DBs via render_as_batch
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
"""
revision = '0002_sample'
down_revision = '0001_baseline'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
EXAMPLE: Uncomment to use. This shows the patterns.
# --- Pattern 1: Schema change (add/drop column) ---
# render_as_batch=True in env.py makes this work on SQLite too.
#
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
# --- Pattern 2: Data migration (read + modify JSON field) ---
# No if/else for sqlite vs postgres needed!
#
# conn = op.get_bind()
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
# for row in rows:
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
# # Modify the config
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
# conn.execute(
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
# {"cfg": json.dumps(config), "uuid": row[0]}
# )
# --- Pattern 3: Create a new table ---
#
# op.create_table(
# 'audit_log',
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
# sa.Column('action', sa.String(255), nullable=False),
# sa.Column('detail', sa.Text),
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
# )
"""
pass
def downgrade() -> None:
"""
# op.drop_column('pipelines', 'description')
# op.drop_table('audit_log')
"""
pass

View File

@@ -1,150 +0,0 @@
"""Programmatic Alembic runner for LangBot.
Usage from async code:
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
await run_alembic_upgrade(async_engine)
CLI usage (autogenerate):
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
python -m langbot.pkg.persistence.alembic_runner upgrade
python -m langbot.pkg.persistence.alembic_runner current
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Connection
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
def _build_config(connection: Connection) -> Config:
"""Build an Alembic Config with sync connection attached."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
cfg.attributes['connection'] = connection
return cfg
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
"""Synchronous upgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.upgrade(cfg, revision)
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
"""Synchronous stamp — runs inside run_sync."""
cfg = _build_config(connection)
command.stamp(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
command.revision(cfg, message=message, autogenerate=True)
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Run Alembic upgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_upgrade, revision)
await conn.commit()
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Stamp the database with a revision without running migrations."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_stamp, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
return await conn.run_sync(_do_get_current)
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
"""Compare ORM models against DB schema and generate a migration script."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_autogenerate, message)
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
if __name__ == '__main__':
import sys
import asyncio
def _get_engine():
"""Create engine from data/config.yaml or default SQLite."""
from sqlalchemy.ext.asyncio import create_async_engine
try:
import yaml
with open('data/config.yaml') as f:
config = yaml.safe_load(f)
db_cfg = config.get('database', {})
db_type = db_cfg.get('use', 'sqlite')
if db_type == 'postgresql':
pg = db_cfg.get('postgresql', {})
url = (
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
)
else:
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
url = f'sqlite+aiosqlite:///{path}'
except Exception:
url = 'sqlite+aiosqlite:///data/langbot.db'
return create_async_engine(url)
def main():
if len(sys.argv) < 2:
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
cmd = sys.argv[1]
engine = _get_engine()
if cmd == 'autogenerate':
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
asyncio.run(run_alembic_autogenerate(engine, msg))
print(f'Migration generated: {msg}')
elif cmd == 'upgrade':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_upgrade(engine, rev))
print(f'Upgraded to: {rev}')
elif cmd == 'stamp':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
else:
print(f'Unknown command: {cmd}')
sys.exit(1)
main()

View File

@@ -76,9 +76,6 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
await self.write_space_model_providers()
async def create_tables(self):
@@ -138,28 +135,6 @@ class PersistenceManager:
# =================================
async def _run_alembic_migrations(self):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
engine = self.get_db_engine()
try:
current_rev = await alembic_runner.get_alembic_current(engine)
if current_rev is None:
# First time: stamp baseline so Alembic knows existing schema is up-to-date
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
# Upgrade to head
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)

View File

@@ -247,7 +247,9 @@ class RuntimePipeline:
await self._check_output(query, result)
if result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
self.ap.logger.debug(
f'Stage {stage_container.inst_name} interrupted query {query.query_id}'
)
break
elif result.result_type == pipeline_entities.ResultType.CONTINUE:
query = result.new_query
@@ -261,7 +263,9 @@ class RuntimePipeline:
await self._check_output(query, sub_result)
if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
self.ap.logger.debug(
f'Stage {stage_container.inst_name} interrupted query {query.query_id}'
)
break
elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
query = sub_result.new_query
@@ -297,9 +301,6 @@ class RuntimePipeline:
)
# Store message_id in query variables for LLM call monitoring
query.variables['_monitoring_message_id'] = message_id
# Notify adapter so it can map platform-specific IDs to monitoring message ID
if hasattr(query.adapter, 'on_monitoring_message_created'):
await query.adapter.on_monitoring_message_created(query, message_id)
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')

View File

@@ -160,6 +160,7 @@ class PreProcessor(stage.PipelineStage):
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File):
# if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
elif isinstance(me, platform_message.Quote) and quote_msg:
for msg in me.origin:
@@ -171,15 +172,6 @@ class PreProcessor(stage.PipelineStage):
):
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File):
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
elif isinstance(msg, platform_message.Voice):
if msg.base64:
content_list.append(
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
)
elif msg.url:
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
query.variables['user_message_text'] = plain_text

View File

@@ -208,7 +208,6 @@ class ChatMessageHandler(handler.MessageHandler):
'model_name': model_name,
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'edition': constants.edition,
'pipeline_plugins': pipeline_plugins,
'error': locals().get('error_info', None),
'timestamp': datetime.utcnow().isoformat(),

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import json
import re
import traceback
import sqlalchemy
@@ -74,15 +73,11 @@ class RuntimeBot:
return False
return False
PIPELINE_DISCARD = '__discard__'
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
def resolve_pipeline_uuid(
self,
launcher_type: str,
launcher_id: str,
message_text: str,
message_element_types: list[str] | None = None,
) -> tuple[str | None, bool]:
"""Resolve pipeline UUID based on routing rules.
@@ -93,22 +88,14 @@ class RuntimeBot:
- launcher_type: session type ("person" / "group")
- launcher_id: session / group id
- message_content: message text content
- message_has_element: message contains element of given type
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
Operators: eq (has), neq (doesn't have)
Operators: eq, neq, contains, not_contains, starts_with, regex
When pipeline_uuid is ``__discard__``, the message should be
silently dropped by the caller.
Returns:
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
when a routing rule matched, False when falling back to default.
"""
rules = self.bot_entity.pipeline_routing_rules or []
element_type_set = set(message_element_types or [])
for rule in rules:
rule_type = rule.get('type')
operator = rule.get('operator', 'eq')
@@ -126,76 +113,9 @@ class RuntimeBot:
elif rule_type == 'message_content':
if self._match_operator(message_text, operator, rule_value):
return target_uuid, True
elif rule_type == 'message_has_element':
has_element = rule_value in element_type_set
if operator == 'eq' and has_element:
return target_uuid, True
elif operator == 'neq' and not has_element:
return target_uuid, True
return self.bot_entity.use_pipeline_uuid, False
async def _record_discarded_message(
self,
launcher_type: provider_session.LauncherTypes,
launcher_id: str | int,
sender_id: str | int,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
) -> None:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
else:
message_content = str(message_chain)
sender_name = None
if hasattr(message_event, 'sender'):
if hasattr(message_event.sender, 'nickname'):
sender_name = message_event.sender.nickname
elif hasattr(message_event.sender, 'member_name'):
sender_name = message_event.sender.member_name
# Use the same session_id format as monitoring_helper.py
session_id = f'{launcher_type}_{launcher_id}'
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
await self.ap.monitoring_service.record_message(
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
message_content=message_content,
session_id=session_id,
status='discarded',
level='info',
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
# Ensure the session exists so the message appears in the session monitor.
# Don't overwrite pipeline info — a session may have messages from
# multiple pipelines; discarding shouldn't change the displayed pipeline.
session_updated = await self.ap.monitoring_service.update_session_activity(
session_id,
)
if not session_updated:
# No session yet (first message for this launcher was discarded).
await self.ap.monitoring_service.record_session_start(
session_id=session_id,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
platform=platform,
user_id=str(sender_id),
user_name=sender_name,
)
except Exception as e:
await self.logger.error(f'Failed to record discarded message: {e}')
async def initialize(self):
async def on_friend_message(
event: platform_events.FriendMessage,
@@ -228,21 +148,7 @@ class RuntimeBot:
launcher_id = custom_launcher_id
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
'person', launcher_id, message_text, element_types
)
if pipeline_uuid == self.PIPELINE_DISCARD:
await self.logger.info('Person message discarded by routing rule')
await self._record_discarded_message(
provider_session.LauncherTypes.PERSON,
launcher_id,
event.sender.id,
event,
event.message_chain,
)
return
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid('person', launcher_id, message_text)
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
@@ -289,21 +195,7 @@ class RuntimeBot:
launcher_id = custom_launcher_id
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
'group', launcher_id, message_text, element_types
)
if pipeline_uuid == self.PIPELINE_DISCARD:
await self.logger.info('Group message discarded by routing rule')
await self._record_discarded_message(
provider_session.LauncherTypes.GROUP,
launcher_id,
event.sender.id,
event,
event.message_chain,
)
return
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid('group', launcher_id, message_text)
await self.ap.msg_aggregator.add_message(
bot_uuid=self.bot_entity.uuid,
@@ -421,20 +313,12 @@ class PlatformManager:
# delete all bot log images
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
for component in self.adapter_components:
if component.metadata.name in disabled_adapters:
continue
adapter_dict[component.metadata.name] = component.get_python_component_class()
self.adapter_dict = adapter_dict
# Filter out disabled adapters from components list (for API responses)
if disabled_adapters:
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
# initialize websocket adapter
websocket_adapter_class = self.adapter_dict['websocket']
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)

View File

@@ -71,8 +71,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
else:
# 回退到原有简单逻辑
# 对于音频消息content 来自 recognition 转写文字,在下方音频处理块中统一处理
if event.content and event.type != 'audio':
if event.content:
text_content = event.content.replace('@' + bot_name, '')
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
@@ -82,38 +81,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.file:
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
if event.audio:
# 优先使用钉钉自带的语音转写文字recognition字段
if event.content and event.type == 'audio':
yiri_msg_list.append(platform_message.Plain(text=event.content))
else:
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
# Handle quoted/replied message - extract content as top-level components
# so that plugins like FileReader can process them the same way as direct messages
if event.quoted_message:
quote_info = event.quoted_message
msg_type = quote_info.get('msg_type', '')
# Process quoted file - add as top-level File component (same as private chat)
if msg_type == 'file' and quote_info.get('file_url'):
file_name = quote_info.get('file_name', 'file')
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
# Process quoted image - add as top-level Image component
elif msg_type == 'picture' and quote_info.get('picture'):
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
# Process quoted audio - add as top-level Voice component
elif msg_type == 'audio' and quote_info.get('audio'):
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
# Process quoted text - add as Plain text with context prefix
elif msg_type == 'text' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
# Process quoted rich text - add as Plain text with context prefix
elif msg_type == 'richText' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
chain = platform_message.MessageChain(yiri_msg_list)

View File

@@ -709,29 +709,21 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
# Check for quote/reply message
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
if quote_message_id:
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
if quote_chain:
# Filter out Source component from quoted chain, keep only content
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
# Add quoted content as top-level components instead of wrapping in Quote
for comp in quote_components:
if isinstance(comp, platform_message.File):
# Add file as top-level component (same as direct message)
message_chain.append(comp)
elif isinstance(comp, platform_message.Image):
# Add image as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Voice):
# Add voice as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Plain):
# Add text with context prefix
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
quote_origin = platform_message.MessageChain(
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
)
if quote_origin:
message_chain.append(
platform_message.Quote(
message_id=quote_message_id,
origin=quote_origin,
)
)
if event.event.message.chat_type == 'p2p':
return platform_events.FriendMessage(
@@ -787,13 +779,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
# Monitoring message ID mapping for feedback correlation
# Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card)
pending_monitoring_msg: dict[str, str]
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
reply_to_monitoring_msg: dict[str, tuple[str, float]]
_MONITORING_MAPPING_TTL = 600 # 10 minutes
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
@@ -812,71 +797,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
asyncio.create_task(on_message(event))
def sync_on_card_action(event):
try:
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
if action_value == '有帮助':
feedback_type = 1
elif action_value == '无帮助':
feedback_type = 2
else:
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
operator = getattr(event.event, 'operator', None)
context = getattr(event.event, 'context', None)
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
open_chat_id = getattr(context, 'open_chat_id', None)
open_message_id = getattr(context, 'open_message_id', None)
if open_chat_id:
session_id = f'group_{open_chat_id}'
elif user_id:
session_id = f'person_{user_id}'
else:
session_id = None
# Resolve monitoring message ID from reply message mapping
monitoring_msg_id = None
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
feedback_event = platform_events.FeedbackEvent(
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
feedback_content=action_value,
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
stream_id=monitoring_msg_id,
source_platform_object=event,
)
if platform_events.FeedbackEvent in self.listeners:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
else:
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
except Exception:
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
event_handler = (
lark_oapi.EventDispatcherHandler.builder('', '')
.register_p2_im_message_receive_v1(sync_on_message)
.register_p2_card_action_trigger(sync_on_card_action)
.build()
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
)
bot_account_id = config['bot_name']
@@ -891,8 +813,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
logger=logger,
lark_tenant_key=config.get('lark_tenant_key', ''),
card_id_dict={},
pending_monitoring_msg={},
reply_to_monitoring_msg={},
seq=1,
listeners={},
quart_app=quart_app,
@@ -1033,22 +953,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
is_stream = True
return is_stream
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
"""Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID."""
try:
user_msg_id = query.message_event.message_chain.message_id
if user_msg_id:
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
except Exception as e:
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
def _cleanup_monitoring_mapping(self):
"""Remove entries older than TTL from the reply-to-monitoring mapping."""
now = time.time()
expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL]
for k in expired:
del self.reply_to_monitoring_msg[k]
async def create_card_id(self, message_id):
try:
# self.logger.debug('飞书支持stream输出,创建卡片......')
@@ -1184,7 +1088,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1208,7 +1111,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'size': 'medium',
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
'margin': '0px 0px 0px 0px',
}
],
@@ -1288,18 +1190,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise Exception(
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
# Transfer monitoring message mapping: user msg ID → reply msg ID
try:
user_msg_id = event.message_chain.message_id
reply_msg_id = getattr(response.data, 'message_id', None)
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
if reply_msg_id and monitoring_msg_id:
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
self._cleanup_monitoring_mapping()
except Exception as e:
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
return True
async def reply_message(
@@ -1582,58 +1472,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
elif 'card.action.trigger' == type:
try:
event_data = data.get('event', {})
operator = event_data.get('operator', {})
action = event_data.get('action', {})
context_data = event_data.get('context', {})
action_value_obj = action.get('value', {})
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
if action_value == '有帮助':
feedback_type = 1
elif action_value == '无帮助':
feedback_type = 2
else:
return {'toast': {'type': 'success', 'content': '操作成功'}}
user_id = operator.get('open_id') or operator.get('user_id')
open_chat_id = context_data.get('open_chat_id')
open_message_id = context_data.get('open_message_id')
if open_chat_id:
session_id = f'group_{open_chat_id}'
elif user_id:
session_id = f'person_{user_id}'
else:
session_id = None
# Resolve monitoring message ID from reply message mapping
monitoring_msg_id = None
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
feedback_event = platform_events.FeedbackEvent(
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
feedback_content=action_value,
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
stream_id=monitoring_msg_id,
source_platform_object=data,
)
if platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
except Exception:
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
elif 'im.chat.member.bot.added_v1' == type:
try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import typing
import asyncio
import time
import traceback
import datetime
@@ -127,107 +126,6 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
# Handle quoted message (引用消息) - important for group chat file references
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_info = event.quote or {}
if quote_info:
# Process quote text content - add as Plain for context
if quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
# Process quote images - add as top-level Image components
quote_images = quote_info.get('images', [])
if not quote_images and quote_info.get('picurl'):
quote_images = [quote_info.get('picurl')]
for img_data in quote_images:
if img_data:
yiri_msg_list.append(platform_message.Image(base64=img_data))
# Process quote file - add as top-level File component (same as private chat)
quote_file = quote_info.get('file') or {}
if quote_file:
file_url = (
quote_file.get('base64')
or quote_file.get('download_url')
or quote_file.get('url')
or quote_file.get('fileurl')
)
file_name = quote_file.get('filename') or quote_file.get('name')
file_size = quote_file.get('filesize') or quote_file.get('size')
if file_url or file_name:
file_kwargs = {}
if file_url:
file_kwargs['url'] = file_url
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
# Process quote voice - add as top-level Voice/File component
quote_voice = quote_info.get('voice') or {}
if quote_voice:
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
if voice_payload:
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = quote_voice.get('filename') or quote_voice.get('name')
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
# Process quote video - add as top-level File component
quote_video = quote_info.get('video') or {}
if quote_video:
video_payload = (
quote_video.get('base64')
or quote_video.get('url')
or quote_video.get('download_url')
or quote_video.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = quote_video.get('filename') or quote_video.get('name')
video_size = quote_video.get('filesize') or quote_video.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
# Process quote link - add as Plain text
quote_link = quote_info.get('link') or {}
if quote_link:
link_summary = '\n'.join(
filter(
None,
[
quote_link.get('title', ''),
quote_link.get('description') or quote_link.get('digest', ''),
quote_link.get('url', ''),
],
)
)
if link_summary:
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
@@ -294,8 +192,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_ws_mode: bool = False
bot_name: str = ''
listeners: dict = {}
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
_STREAM_MAPPING_TTL = 600 # 10 minutes
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -332,9 +228,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id=bot_account_id,
bot_name=bot_name,
event_converter=event_converter,
listeners={},
_stream_to_monitoring_msg={},
)
self.listeners = {}
async def reply_message(
self,
@@ -426,23 +321,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
try:
stream_id = query.message_event.source_platform_object.stream_id
if stream_id:
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
self._cleanup_stream_mapping()
except Exception as e:
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
def _cleanup_stream_mapping(self):
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
now = time.time()
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
for k in expired:
del self._stream_to_monitoring_msg[k]
async def _on_feedback(self, **kwargs):
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
try:
@@ -450,9 +328,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
feedback_type = kwargs.get('feedback_type', 0)
feedback_content = kwargs.get('feedback_content', '') or None
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
if inaccurate_reasons:
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
session = kwargs.get('session')
session_id = None
@@ -468,16 +343,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_id = session.msg_id
stream_id = session.stream_id
# Resolve stream_id to LangBot monitoring message ID if available
monitoring_msg_id = None
if stream_id and stream_id in self._stream_to_monitoring_msg:
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
await self.logger.info(
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
)
event = platform_events.FeedbackEvent(
feedback_id=feedback_id,
feedback_type=feedback_type,
@@ -486,7 +351,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_id=user_id,
session_id=session_id,
message_id=message_id,
stream_id=monitoring_msg_id or stream_id,
stream_id=stream_id,
source_platform_object=session,
)

View File

@@ -227,8 +227,7 @@ class ModelManager:
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
requester_inst = self.requester_dict[provider_entity.requester](
ap=self.ap,
config={'base_url': provider_entity.base_url},
ap=self.ap, config={'base_url': provider_entity.base_url}
)
await requester_inst.initialize()

View File

@@ -303,14 +303,6 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
async def initialize(self):
pass
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
"""Scan models supported by the provider.
The default implementation does not support scanning. Requesters that
can enumerate remote models should override this method.
"""
raise NotImplementedError('This provider does not support model scanning')
@abc.abstractmethod
async def invoke_llm(
self,

View File

@@ -31,192 +31,6 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
)
def _mask_api_key(self, api_key: str | None) -> str:
if not api_key:
return ''
if len(api_key) <= 8:
return '****'
return f'{api_key[:4]}...{api_key[-4:]}'
def _infer_model_type(self, model_id: str) -> str:
normalized_model_id = (model_id or '').lower()
embedding_keywords = (
'embedding',
'embed',
'bge-',
'e5-',
'm3e',
'gte-',
'multilingual-e5',
'text-embedding',
)
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
normalized_model_id = (model_id or '').lower()
abilities: set[str] = set()
def _flatten(value: typing.Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value.lower()]
if isinstance(value, dict):
flattened: list[str] = []
for nested_value in value.values():
flattened.extend(_flatten(nested_value))
return flattened
if isinstance(value, (list, tuple, set)):
flattened: list[str] = []
for nested_value in value:
flattened.extend(_flatten(nested_value))
return flattened
return [str(value).lower()]
capability_tokens = _flatten(item.get('capabilities'))
capability_tokens.extend(_flatten(item.get('modalities')))
capability_tokens.extend(_flatten(item.get('input_modalities')))
capability_tokens.extend(_flatten(item.get('output_modalities')))
capability_tokens.extend(_flatten(item.get('supported_generation_methods')))
capability_tokens.extend(_flatten(item.get('supported_parameters')))
capability_tokens.extend(_flatten(item.get('architecture')))
combined_tokens = capability_tokens + [normalized_model_id]
vision_keywords = (
'vision',
'image',
'file',
'video',
'multimodal',
'vl',
'ocr',
'omni',
)
function_call_keywords = (
'function',
'tool',
'tools',
'tool_choice',
'tool_call',
'tool-use',
'tool_use',
)
if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens):
abilities.add('vision')
if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens):
abilities.add('func_call')
return sorted(abilities)
def _normalize_modalities(self, value: typing.Any) -> list[str]:
normalized: list[str] = []
def _collect(item: typing.Any):
if item is None:
return
if isinstance(item, str):
for part in item.replace('->', ',').replace('+', ',').split(','):
token = part.strip().lower()
if token and token not in normalized:
normalized.append(token)
return
if isinstance(item, dict):
for nested in item.values():
_collect(nested)
return
if isinstance(item, (list, tuple, set)):
for nested in item:
_collect(nested)
return
_collect(value)
return normalized
def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]:
display_name = item.get('name')
if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id:
display_name = ''
description = item.get('description')
if not isinstance(description, str) or not description.strip():
description = ''
context_length = item.get('context_length')
if context_length is None and isinstance(item.get('top_provider'), dict):
context_length = item['top_provider'].get('context_length')
if not isinstance(context_length, int):
try:
context_length = int(context_length) if context_length is not None else None
except (TypeError, ValueError):
context_length = None
input_modalities = self._normalize_modalities(item.get('input_modalities'))
output_modalities = self._normalize_modalities(item.get('output_modalities'))
if isinstance(item.get('architecture'), dict):
if not input_modalities:
input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities'))
if not output_modalities:
output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities'))
owned_by = item.get('owned_by')
if not isinstance(owned_by, str) or not owned_by.strip():
owned_by = ''
return {
'display_name': display_name or None,
'description': description or None,
'context_length': context_length,
'owned_by': owned_by or None,
'input_modalities': input_modalities,
'output_modalities': output_modalities,
}
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
headers = {}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models'
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
response = await client.get(models_url, headers=headers)
response.raise_for_status()
payload = response.json()
models = []
for item in payload.get('data', []):
model_id = item.get('id')
if not model_id:
continue
models.append(
{
'id': model_id,
'name': model_id,
'type': self._infer_model_type(model_id),
'abilities': self._infer_model_abilities(item, model_id),
**self._extract_scan_metadata(item, model_id),
}
)
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
return {
'models': models,
'debug': {
'request': {
'method': 'GET',
'url': models_url,
'headers': {
'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '',
},
},
'response': payload,
},
}
async def _req(
self,
args: dict,

View File

@@ -1,7 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="5" fill="#7B68EE"/>
<circle cx="12" cy="12" r="6" fill="#FF6B35"/>
<circle cx="12" cy="12" r="3" fill="#7B68EE"/>
<path d="M12 6V18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6 12H18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

View File

@@ -1,61 +0,0 @@
from __future__ import annotations
import typing
from .. import requester
REQUESTER_NAME: str = 'chroma-embedding'
class ChromaEmbedding(requester.ProviderAPIRequester):
"""Chroma built-in embedding requester.
Uses chromadb's DefaultEmbeddingFunction (all-MiniLM-L6-v2).
The embedding function runs locally using ONNX Runtime.
"""
default_config: dict[str, typing.Any] = {
'base_url': '',
}
_embedding_function = None
async def initialize(self):
try:
from chromadb.utils import embedding_functions
except ImportError:
raise ImportError('chromadb is not installed. Install it with: pip install chromadb')
self._embedding_function = embedding_functions.DefaultEmbeddingFunction()
async def invoke_llm(
self,
query,
model: requester.RuntimeLLMModel,
messages: typing.List,
funcs: typing.List = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
):
raise NotImplementedError('Chroma embedding does not support LLM inference')
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[typing.List[float]]:
"""Generate embeddings using Chroma's DefaultEmbeddingFunction."""
if self._embedding_function is None:
await self.initialize()
try:
result = self._embedding_function(input_text)
# DefaultEmbeddingFunction returns list of ndarray, convert for JSON
if isinstance(result, list):
return [item.tolist() if hasattr(item, 'tolist') else item for item in result]
return result.tolist() if hasattr(result, 'tolist') else result
except Exception as e:
from .. import errors
raise errors.RequesterError(f'Chroma embedding failed: {str(e)}')

View File

@@ -1,21 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: chroma-embedding
label:
en_US: Chroma Embedding
zh_Hans: Chroma 嵌入
description:
en_US: Chroma built-in embedding model (all-MiniLM-L6-v2), runs locally using ONNX Runtime. First-time use will download model files automatically.
zh_Hans: 使用 Chroma 内置嵌入模型 (all-MiniLM-L6-v2),基于 ONNX Runtime 本地运行。首次使用时将自动下载模型文件。
ja_JP: Chroma 組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。ONNX Runtime でローカル実行。初回使用時にモデルファイルが自動ダウンロードされます。
icon: chroma.svg
spec:
config: []
support_type:
- text-embedding
provider_category: builtin
execution:
python:
path: ./chromaembed.py
attr: ChromaEmbedding

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import typing
import httpx
from . import chatcmpl
@@ -21,68 +20,6 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
'timeout': 120,
}
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
models_url = 'https://generativelanguage.googleapis.com/v1beta/models'
params = {'key': api_key} if api_key else {}
all_models: list[dict[str, typing.Any]] = []
next_page_token = ''
last_payload: dict[str, typing.Any] = {}
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
while True:
request_params = dict(params)
if next_page_token:
request_params['pageToken'] = next_page_token
response = await client.get(models_url, params=request_params)
response.raise_for_status()
payload = response.json()
last_payload = payload
for item in payload.get('models', []):
model_name = item.get('name', '')
model_id = model_name.replace('models/', '', 1)
if not model_id:
continue
supported_methods = item.get('supportedGenerationMethods', []) or []
if 'embedContent' in supported_methods and 'generateContent' not in supported_methods:
model_type = 'embedding'
else:
model_type = 'llm'
all_models.append(
{
'id': model_id,
'name': model_id,
'type': model_type,
'abilities': self._infer_model_abilities(item, model_id),
'display_name': item.get('displayName') or None,
'description': item.get('description') or None,
'context_length': item.get('inputTokenLimit'),
'input_modalities': self._normalize_modalities(item.get('inputModalities')),
'output_modalities': self._normalize_modalities(item.get('outputModalities')),
}
)
next_page_token = payload.get('nextPageToken', '')
if not next_page_token:
break
all_models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
return {
'models': all_models,
'debug': {
'request': {
'method': 'GET',
'url': models_url,
'query': {'key': self._mask_api_key(api_key)} if api_key else {},
},
'response': last_payload,
},
}
async def _closure_stream(
self,
query: pipeline_query.Query,

View File

@@ -31,175 +31,6 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
)
def _mask_api_key(self, api_key: str | None) -> str:
if not api_key:
return ''
if len(api_key) <= 8:
return '****'
return f'{api_key[:4]}...{api_key[-4:]}'
def _infer_model_type(self, model_id: str) -> str:
normalized_model_id = (model_id or '').lower()
embedding_keywords = (
'embedding',
'embed',
'bge-',
'e5-',
'm3e',
'gte-',
'multilingual-e5',
'text-embedding',
)
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
normalized_model_id = (model_id or '').lower()
abilities: set[str] = set()
def _flatten(value: typing.Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value.lower()]
if isinstance(value, dict):
flattened: list[str] = []
for nested_value in value.values():
flattened.extend(_flatten(nested_value))
return flattened
if isinstance(value, (list, tuple, set)):
flattened: list[str] = []
for nested_value in value:
flattened.extend(_flatten(nested_value))
return flattened
return [str(value).lower()]
capability_tokens = _flatten(item.get('capabilities'))
capability_tokens.extend(_flatten(item.get('modalities')))
capability_tokens.extend(_flatten(item.get('input_modalities')))
capability_tokens.extend(_flatten(item.get('output_modalities')))
capability_tokens.extend(_flatten(item.get('supported_generation_methods')))
capability_tokens.extend(_flatten(item.get('supported_parameters')))
capability_tokens.extend(_flatten(item.get('architecture')))
combined_tokens = capability_tokens + [normalized_model_id]
vision_keywords = ('vision', 'image', 'file', 'video', 'multimodal', 'vl', 'ocr', 'omni')
function_call_keywords = ('function', 'tool', 'tools', 'tool_choice', 'tool_call', 'tool-use', 'tool_use')
if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens):
abilities.add('vision')
if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens):
abilities.add('func_call')
return sorted(abilities)
def _normalize_modalities(self, value: typing.Any) -> list[str]:
normalized: list[str] = []
def _collect(item: typing.Any):
if item is None:
return
if isinstance(item, str):
for part in item.replace('->', ',').replace('+', ',').split(','):
token = part.strip().lower()
if token and token not in normalized:
normalized.append(token)
return
if isinstance(item, dict):
for nested in item.values():
_collect(nested)
return
if isinstance(item, (list, tuple, set)):
for nested in item:
_collect(nested)
return
_collect(value)
return normalized
def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]:
display_name = item.get('name')
if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id:
display_name = ''
description = item.get('description')
if not isinstance(description, str) or not description.strip():
description = ''
context_length = item.get('context_length')
if context_length is None and isinstance(item.get('top_provider'), dict):
context_length = item['top_provider'].get('context_length')
if not isinstance(context_length, int):
try:
context_length = int(context_length) if context_length is not None else None
except (TypeError, ValueError):
context_length = None
input_modalities = self._normalize_modalities(item.get('input_modalities'))
output_modalities = self._normalize_modalities(item.get('output_modalities'))
if isinstance(item.get('architecture'), dict):
if not input_modalities:
input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities'))
if not output_modalities:
output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities'))
owned_by = item.get('owned_by')
if not isinstance(owned_by, str) or not owned_by.strip():
owned_by = ''
return {
'display_name': display_name or None,
'description': description or None,
'context_length': context_length,
'owned_by': owned_by or None,
'input_modalities': input_modalities,
'output_modalities': output_modalities,
}
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
headers = {}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models'
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
response = await client.get(models_url, headers=headers)
response.raise_for_status()
payload = response.json()
models = []
for item in payload.get('data', []):
model_id = item.get('id')
if not model_id:
continue
models.append(
{
'id': model_id,
'name': model_id,
'type': self._infer_model_type(model_id),
'abilities': self._infer_model_abilities(item, model_id),
**self._extract_scan_metadata(item, model_id),
}
)
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
return {
'models': models,
'debug': {
'request': {
'method': 'GET',
'url': models_url,
'headers': {
'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '',
},
},
'response': payload,
},
}
async def _req(
self,
query: pipeline_query.Query,

View File

@@ -8,7 +8,6 @@ import uuid
import json
import ollama
import httpx
from .. import errors, requester
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -32,60 +31,6 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
os.environ['OLLAMA_HOST'] = self.requester_cfg['base_url']
self.client = ollama.AsyncClient(timeout=self.requester_cfg['timeout'])
def _infer_model_type(self, model_id: str) -> str:
normalized_model_id = (model_id or '').lower()
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
normalized_model_id = (model_id or '').lower()
abilities: set[str] = set()
details = item.get('details', {}) or {}
families = details.get('families', []) or []
tokens = [normalized_model_id, str(details.get('family', '')).lower()]
tokens.extend(str(family).lower() for family in families)
if any(keyword in token for token in tokens for keyword in ('vision', 'vl', 'omni', 'llava', 'ocr')):
abilities.add('vision')
if any(keyword in token for token in tokens for keyword in ('tool', 'function')):
abilities.add('func_call')
return sorted(abilities)
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
del api_key
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/api/tags'
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
response = await client.get(models_url)
response.raise_for_status()
payload = response.json()
models: list[dict[str, typing.Any]] = []
for item in payload.get('models', []):
model_id = item.get('model') or item.get('name')
if not model_id:
continue
models.append(
{
'id': model_id,
'name': item.get('name', model_id),
'type': self._infer_model_type(model_id),
'abilities': self._infer_model_abilities(item, model_id),
}
)
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
return {
'models': models,
'debug': {
'request': {
'method': 'GET',
'url': models_url,
},
'response': payload,
},
}
async def _req(
self,
args: dict,
@@ -159,21 +104,6 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
return ret_msg
async def _prepare_messages(
self,
messages: typing.List[provider_message.Message],
) -> list[dict]:
"""Prepare messages for Ollama API request."""
req_messages: list = []
for m in messages:
msg_dict: dict = m.dict(exclude_none=True)
content: Any = msg_dict.get('content')
if isinstance(content, list):
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
msg_dict['content'] = '\n'.join(part['text'] for part in content)
req_messages.append(msg_dict)
return req_messages
async def invoke_llm(
self,
query: pipeline_query.Query,
@@ -183,7 +113,14 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
req_messages = await self._prepare_messages(messages)
req_messages: list = []
for m in messages:
msg_dict: dict = m.dict(exclude_none=True)
content: Any = msg_dict.get('content')
if isinstance(content, list):
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
msg_dict['content'] = '\n'.join(part['text'] for part in content)
req_messages.append(msg_dict)
try:
return await self._closure(
query=query,
@@ -196,109 +133,6 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
except asyncio.TimeoutError:
raise errors.RequesterError('请求超时')
async def invoke_llm_stream(
self,
query: pipeline_query.Query,
model: requester.RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.MessageChunk:
req_messages = await self._prepare_messages(messages)
try:
args = extra_args.copy()
args['model'] = model.model_entity.name
# Process messages for Ollama format
msgs: list[dict] = req_messages.copy()
for msg in msgs:
if 'content' in msg and isinstance(msg['content'], list):
text_content: list = []
image_urls: list = []
for me in msg['content']:
if me['type'] == 'text':
text_content.append(me['text'])
elif me['type'] == 'image_base64':
image_urls.append(me['image_base64'])
msg['content'] = '\n'.join(text_content)
msg['images'] = [url.split(',')[1] for url in image_urls]
if 'tool_calls' in msg:
for tool_call in msg['tool_calls']:
tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments'])
args['messages'] = msgs
args['tools'] = []
if funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
if tools:
args['tools'] = tools
args['stream'] = True
chunk_idx = 0
thinking_started = False
thinking_ended = False
role = 'assistant'
async for chunk in await self.client.chat(**args):
message: ollama.Message = chunk.message
done = chunk.done
delta_content = message.content or ''
reasoning_content = getattr(message, 'thinking', '') or ''
# Handle reasoning/thinking content
if reasoning_content:
if remove_think:
chunk_idx += 1
continue
if not thinking_started:
thinking_started = True
delta_content = '<think>\n' + reasoning_content
else:
delta_content = reasoning_content
elif thinking_started and not thinking_ended and delta_content:
thinking_ended = True
delta_content = '\n</think>\n' + delta_content
# Handle tool calls
tool_calls_data = None
if message.tool_calls:
tool_calls_data = []
for tc in message.tool_calls:
tool_calls_data.append(
{
'id': uuid.uuid4().hex,
'type': 'function',
'function': {
'name': tc.function.name,
'arguments': json.dumps(tc.function.arguments),
},
}
)
# Skip empty first chunk
if chunk_idx == 0 and not delta_content and not reasoning_content and not tool_calls_data:
chunk_idx += 1
continue
chunk_data = {
'role': role,
'content': delta_content if delta_content else None,
'tool_calls': tool_calls_data,
'is_final': bool(done),
}
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
except asyncio.TimeoutError:
raise errors.RequesterError('请求超时')
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,

View File

@@ -15,11 +15,3 @@ class OpenRouterChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
'base_url': 'https://openrouter.ai/api/v1',
'timeout': 120,
}
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
original_base_url = self.requester_cfg.get('base_url', '')
self.requester_cfg['base_url'] = 'https://openrouter.ai/api/v1'
try:
return await super().scan_models(api_key)
finally:
self.requester_cfg['base_url'] = original_base_url

View File

@@ -46,15 +46,14 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {},
) -> typing.List[typing.List[float]]:
"""Generate embeddings using SeekDB's built-in embedding function."""
if self._embedding_function is None:
await self.initialize()
try:
result = self._embedding_function(input_text)
# Ensure JSON serialization compatibility
if isinstance(result, list):
return [item.tolist() if hasattr(item, 'tolist') else item for item in result]
return result.tolist() if hasattr(result, 'tolist') else result
if self._embedding_function is None:
await self.initialize()
if self._embedding_function is None:
raise RuntimeError('SeekDB embedding function initialization failed')
return self._embedding_function(input_text)
except Exception as e:
from .. import errors

View File

@@ -107,7 +107,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
plain_text, image_ids = await self._preprocess_user_message(query)
has_thoughts = True # 获取思考过程
remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think')
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
if remove_think:
has_thoughts = False
# 发送对话请求
@@ -141,7 +141,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
idx_chunk += 1
# 获取流式传输的output
stream_output = chunk.get('output', {})
stream_think = stream_output.get('thoughts') or []
stream_think = stream_output.get('thoughts', [])
if stream_think and stream_think[0].get('thought'):
if not think_start:
think_start = True
@@ -149,7 +149,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
else:
# 继续输出 reasoning_content
pending_content += stream_think[0].get('thought')
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
elif (not stream_think or stream_think[0].get('thought') == '') and not think_end:
think_end = True
pending_content += '\n</think>\n'
if stream_output.get('text') is not None:
@@ -188,15 +188,15 @@ class DashScopeAPIRunner(runner.RequestRunner):
idx_chunk += 1
# 获取流式传输的output
stream_output = chunk.get('output', {})
stream_think = stream_output.get('thoughts') or []
if stream_think and stream_think[0].get('thought'):
stream_think = stream_output.get('thoughts', [])
if stream_think[0].get('thought'):
if not think_start:
think_start = True
pending_content += f'<think>\n{stream_think[0].get("thought")}'
else:
# 继续输出 reasoning_content
pending_content += stream_think[0].get('thought')
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
elif stream_think[0].get('thought') == '' and not think_end:
think_end = True
pending_content += '\n</think>\n'
if stream_output.get('text') is not None:

View File

@@ -70,12 +70,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
return plain_text
async def _process_response(
async def _process_stream_response(
self, response: aiohttp.ClientResponse
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""处理响应——支持流式格式和普通 JSON 格式"""
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
full_content = ''
full_text = ''
chunk_idx = 0
is_final = False
message_idx = 0
@@ -94,7 +93,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
else:
chunk_str = str(raw_chunk)
full_text += chunk_str
buffer += chunk_str
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
@@ -117,7 +115,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
elif obj.get('type') == 'end':
is_final = True
if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0):
if is_final or chunk_idx % 8 == 0:
message_idx += 1
yield provider_message.MessageChunk(
role='assistant',
@@ -144,7 +142,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
obj, _ = decoder.raw_decode(buffer)
if isinstance(obj, dict):
if obj.get('type') == 'item' and 'content' in obj:
chunk_idx += 1
full_content += obj['content']
elif obj.get('type') == 'end':
is_final = True
@@ -159,28 +156,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
preview = buffer[:200]
self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}')
# n8n 返回普通 JSON 格式(无任何流式 type:item 内容)
if chunk_idx == 0:
output_content = ''
try:
response_data = json.loads(full_text.strip())
if isinstance(response_data, dict):
if self.output_key in response_data:
output_content = response_data[self.output_key]
else:
output_content = json.dumps(response_data, ensure_ascii=False)
else:
output_content = full_text
except json.JSONDecodeError:
output_content = full_text
self.ap.logger.debug(f'n8n webhook response (non-stream): {full_text[:200]}')
yield provider_message.MessageChunk(
role='assistant',
content=output_content,
is_final=True,
msg_sequence=message_idx + 1,
)
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用n8n webhook"""
# 生成会话ID如果不存在
@@ -245,22 +220,49 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 调用webhook
session = httpclient.get_session()
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
if is_stream:
# 流式请求
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
async for chunk in self._process_response(response):
if is_stream:
# 处理流式响应
async for chunk in self._process_stream_response(response):
yield chunk
elif chunk.is_final:
yield provider_message.Message(
role='assistant',
content=chunk.content,
)
else:
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
try:
async for chunk in self._process_stream_response(response):
output_content = chunk.content if chunk.is_final else ''
except:
# 非流式请求(保持原有逻辑)
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 解析响应
response_data = await response.json()
self.ap.logger.debug(f'n8n webhook response: {response_data}')
# 从响应中提取输出
if self.output_key in response_data:
output_content = response_data[self.output_key]
else:
# 如果没有指定的输出键,则使用整个响应
output_content = json.dumps(response_data, ensure_ascii=False)
# 返回消息
yield provider_message.Message(
role='assistant',
content=output_content,
)
except Exception as e:
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')

View File

@@ -60,16 +60,7 @@ class TelemetryManager:
except Exception:
sanitized['query_id'] = str(sanitized.get('query_id', ''))
for sfield in (
'adapter',
'runner',
'runner_category',
'model_name',
'version',
'edition',
'error',
'timestamp',
):
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
v = sanitized.get(sfield)
sanitized[sfield] = '' if v is None else str(v)

View File

@@ -38,31 +38,28 @@ def get_frontend_path() -> str:
"""
Get the path to the frontend build files.
Returns the path to web/dist directory (Vite build output), handling both:
Returns the path to web/out directory, handling both:
- Development mode: running from source directory
- Package mode: installed via pip/uvx
- Legacy mode: web/out (Next.js, for backward compatibility)
"""
# Check both dist (Vite) and out (legacy Next.js) paths
for dirname in ('dist', 'out'):
web_dir = f'web/{dirname}'
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists('web/out'):
return 'web/out'
# First, check if we're running from source directory
if _check_if_source_install() and os.path.exists(web_dir):
return web_dir
# Second, check current directory for web/out (in case user is in source dir)
if os.path.exists('web/out'):
return 'web/out'
# Second, check current directory
if os.path.exists(web_dir):
return web_dir
# Third, find it relative to the package installation
pkg_dir = Path(__file__).parent.parent.parent
frontend_path = pkg_dir / 'web' / dirname
if frontend_path.exists():
return str(frontend_path)
# 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/dist'
return 'web/out'
def get_resource_path(resource: str) -> str:

View File

@@ -2,6 +2,11 @@ from __future__ import annotations
from ..core import app
from .vdb import VectorDatabase, SearchType
from .vdbs.chroma import ChromaVectorDatabase
from .vdbs.qdrant import QdrantVectorDatabase
from .vdbs.seekdb import SeekDBVectorDatabase
from .vdbs.milvus import MilvusVectorDatabase
from .vdbs.pgvector_db import PgVectorDatabase
class VectorDBManager:
@@ -17,25 +22,17 @@ class VectorDBManager:
vdb_type = kb_config.get('use')
if vdb_type == 'chroma':
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.info('Initialized Chroma vector database backend.')
elif vdb_type == 'qdrant':
from .vdbs.qdrant import QdrantVectorDatabase
self.vector_db = QdrantVectorDatabase(self.ap)
self.ap.logger.info('Initialized Qdrant vector database backend.')
elif vdb_type == 'seekdb':
from .vdbs.seekdb import SeekDBVectorDatabase
self.vector_db = SeekDBVectorDatabase(self.ap)
self.ap.logger.info('Initialized SeekDB vector database backend.')
elif vdb_type == 'milvus':
from .vdbs.milvus import MilvusVectorDatabase
# Get Milvus configuration
milvus_config = kb_config.get('milvus', {})
uri = milvus_config.get('uri', './data/milvus.db')
@@ -45,8 +42,6 @@ class VectorDBManager:
self.ap.logger.info('Initialized Milvus vector database backend.')
elif vdb_type == 'pgvector':
from .vdbs.pgvector_db import PgVectorDatabase
# Get pgvector configuration
pgvector_config = kb_config.get('pgvector', {})
connection_string = pgvector_config.get('connection_string')
@@ -65,13 +60,9 @@ class VectorDBManager:
self.ap.logger.info('Initialized pgvector database backend.')
else:
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')
else:
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.')

View File

@@ -1 +1,7 @@
"""Vector database implementations for LangBot."""
from .chroma import ChromaVectorDatabase
from .qdrant import QdrantVectorDatabase
from .seekdb import SeekDBVectorDatabase
__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']

View File

@@ -20,7 +20,6 @@ system:
edition: community
recovery_key: ''
allow_modify_login_info: true
disabled_adapters: []
limitation:
max_bots: -1
max_pipelines: -1

View File

@@ -1,328 +0,0 @@
"""
Unit tests for N8nServiceAPIRunner._process_response
Tests cover four scenarios:
- Stream adapter + n8n stream format (type:item/end)
- Stream adapter + n8n plain JSON
- Non-stream adapter + n8n stream format
- Non-stream adapter + n8n plain JSON
"""
from __future__ import annotations
import json
import sys
from unittest.mock import AsyncMock, MagicMock, Mock, patch
# Break the circular import chain before importing n8nsvapi:
# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init)
_mock_runner = MagicMock()
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
_mock_runner.RequestRunner = object
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner)
sys.modules.setdefault('langbot.pkg.core.app', MagicMock())
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock())
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
ap = Mock()
ap.logger = Mock()
pipeline_config = {
'ai': {
'n8n-service-api': {
'webhook-url': 'http://test-n8n/webhook',
'output-key': output_key,
'auth-type': 'none',
}
}
}
return N8nServiceAPIRunner(ap, pipeline_config)
def make_mock_response(chunks: list[bytes | str], status: int = 200):
"""Build a minimal aiohttp.ClientResponse mock with iter_chunked support."""
response = Mock()
response.status = status
async def iter_chunked(size):
for chunk in chunks:
yield chunk
response.content = Mock()
response.content.iter_chunked = iter_chunked
return response
async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]):
"""Run _process_response and collect all yielded MessageChunks."""
response = make_mock_response(chunks)
result = []
async for chunk in runner._process_response(response):
result.append(chunk)
return result
# ---------------------------------------------------------------------------
# _process_response: stream format (type:item/end)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_format_single_item():
"""Single item + end in one chunk yields final chunk with full content."""
runner = make_runner()
data = b'{"type":"item","content":"hello"}{"type":"end"}'
chunks = await collect_chunks(runner, [data])
assert len(chunks) >= 1
final = chunks[-1]
assert final.is_final is True
assert final.content == 'hello'
@pytest.mark.asyncio
async def test_stream_format_multi_item_accumulates():
"""Multiple items accumulate into full_content."""
runner = make_runner()
chunks_data = [
b'{"type":"item","content":"foo"}',
b'{"type":"item","content":"bar"}',
b'{"type":"end"}',
]
chunks = await collect_chunks(runner, chunks_data)
final = chunks[-1]
assert final.is_final is True
assert final.content == 'foobar'
@pytest.mark.asyncio
async def test_stream_format_batches_every_8_items():
"""Every 8th item triggers an intermediate yield before the final."""
runner = make_runner()
items = [f'{{"type":"item","content":"{i}"}}' for i in range(8)]
items.append('{"type":"end"}')
data = ''.join(items).encode()
chunks = await collect_chunks(runner, [data])
# At least the batch yield at chunk_idx==8 + final yield
assert len(chunks) >= 2
assert chunks[-1].is_final is True
@pytest.mark.asyncio
async def test_stream_format_split_across_network_chunks():
"""JSON split across multiple network chunks is reassembled correctly."""
runner = make_runner()
part1 = b'{"type":"item","con'
part2 = b'tent":"world"}{"type":"end"}'
chunks = await collect_chunks(runner, [part1, part2])
final = chunks[-1]
assert final.is_final is True
assert final.content == 'world'
@pytest.mark.asyncio
async def test_stream_format_no_spurious_empty_yield():
"""chunk_idx==0 guard prevents spurious empty yield before any item is received."""
runner = make_runner()
# Send some non-stream JSON first, then stream
data = b'{"type":"item","content":"x"}{"type":"end"}'
chunks = await collect_chunks(runner, [data])
# No chunk should have empty content before the real content arrives
non_final = [c for c in chunks if not c.is_final]
for c in non_final:
assert c.content # must be non-empty
# ---------------------------------------------------------------------------
# _process_response: plain JSON fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_plain_json_with_output_key():
"""Plain JSON with matching output_key extracts value via output_key."""
runner = make_runner(output_key='response')
data = json.dumps({'response': 'hello world'}).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == 'hello world'
@pytest.mark.asyncio
async def test_plain_json_output_key_not_found():
"""Plain JSON without output_key falls back to entire JSON string."""
runner = make_runner(output_key='response')
payload = {'other_key': 'hello'}
data = json.dumps(payload).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert json.loads(chunks[0].content) == payload
@pytest.mark.asyncio
async def test_plain_json_output_key_empty_string():
"""output_key present but value is empty string — returns empty string, not whole JSON."""
runner = make_runner(output_key='response')
data = json.dumps({'response': ''}).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == ''
@pytest.mark.asyncio
async def test_plain_json_non_dict_response():
"""Plain JSON array falls back to raw text."""
runner = make_runner()
data = b'["a", "b"]'
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == '["a", "b"]'
@pytest.mark.asyncio
async def test_invalid_json_returns_raw_text():
"""Non-JSON response returns raw text as-is."""
runner = make_runner()
data = b'plain text response'
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == 'plain text response'
# ---------------------------------------------------------------------------
# _call_webhook: output type depends on is_stream
# ---------------------------------------------------------------------------
def make_query(is_stream: bool):
"""Build a minimal Query mock."""
query = Mock()
query.adapter = AsyncMock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=is_stream)
session = Mock()
session.using_conversation = Mock()
session.using_conversation.uuid = 'test-uuid'
session.launcher_type = Mock()
session.launcher_type.value = 'person'
session.launcher_id = '12345'
query.session = session
query.user_message = Mock()
query.user_message.content = 'hi'
query.variables = {}
return query
def make_http_session_mock(response_bytes: bytes, status: int = 200):
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
mock_response = make_mock_response([response_bytes], status=status)
mock_response.status = status
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
mock_cm.__aexit__ = AsyncMock(return_value=False)
mock_session = Mock()
mock_session.post = Mock(return_value=mock_cm)
return mock_session
@pytest.mark.asyncio
async def test_call_webhook_nonstream_adapter_plain_json():
"""Non-stream adapter + plain JSON → single Message with output_key value."""
runner = make_runner(output_key='response')
query = make_query(is_stream=False)
http_session = make_http_session_mock(json.dumps({'response': 'result text'}).encode())
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert len(results) == 1
assert isinstance(results[0], provider_message.Message)
assert results[0].content == 'result text'
@pytest.mark.asyncio
async def test_call_webhook_stream_adapter_stream_format():
"""Stream adapter + stream format → MessageChunks, last is_final."""
runner = make_runner()
query = make_query(is_stream=True)
data = b'{"type":"item","content":"hi"}{"type":"end"}'
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
assert results[-1].is_final is True
assert results[-1].content == 'hi'
@pytest.mark.asyncio
async def test_call_webhook_stream_adapter_plain_json():
"""Stream adapter + plain JSON → single MessageChunk with is_final=True."""
runner = make_runner(output_key='response')
query = make_query(is_stream=True)
data = json.dumps({'response': 'fallback'}).encode()
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
assert results[-1].is_final is True
assert results[-1].content == 'fallback'
@pytest.mark.asyncio
async def test_call_webhook_nonstream_adapter_stream_format():
"""Non-stream adapter + stream format → single Message with accumulated content."""
runner = make_runner()
query = make_query(is_stream=False)
data = b'{"type":"item","content":"foo"}{"type":"item","content":"bar"}{"type":"end"}'
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert len(results) == 1
assert isinstance(results[0], provider_message.Message)
assert results[0].content == 'foobar'

View File

@@ -1,280 +0,0 @@
"""
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
"""
from unittest.mock import Mock
class TestMatchOperator:
"""Test the _match_operator static method."""
@staticmethod
def _get_class():
from langbot.pkg.platform.botmgr import RuntimeBot
return RuntimeBot
def test_eq(self):
cls = self._get_class()
assert cls._match_operator('hello', 'eq', 'hello') is True
assert cls._match_operator('hello', 'eq', 'world') is False
def test_neq(self):
cls = self._get_class()
assert cls._match_operator('hello', 'neq', 'world') is True
assert cls._match_operator('hello', 'neq', 'hello') is False
def test_contains(self):
cls = self._get_class()
assert cls._match_operator('hello world', 'contains', 'world') is True
assert cls._match_operator('hello world', 'contains', 'xyz') is False
def test_not_contains(self):
cls = self._get_class()
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
assert cls._match_operator('hello world', 'not_contains', 'world') is False
def test_starts_with(self):
cls = self._get_class()
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
assert cls._match_operator('hello world', 'starts_with', 'world') is False
def test_regex(self):
cls = self._get_class()
assert cls._match_operator('hello123', 'regex', r'\d+') is True
assert cls._match_operator('hello', 'regex', r'\d+') is False
def test_regex_invalid_pattern(self):
cls = self._get_class()
assert cls._match_operator('hello', 'regex', r'[invalid') is False
def test_unknown_operator(self):
cls = self._get_class()
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
class TestResolvePipelineUuid:
"""Test the resolve_pipeline_uuid method."""
@staticmethod
def _make_bot(default_pipeline: str, rules: list):
from langbot.pkg.platform.botmgr import RuntimeBot
bot_entity = Mock()
bot_entity.use_pipeline_uuid = default_pipeline
bot_entity.pipeline_routing_rules = rules
bot = object.__new__(RuntimeBot)
bot.bot_entity = bot_entity
return bot
def test_no_rules_returns_default(self):
bot = self._make_bot('default-uuid', [])
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'default-uuid'
assert routed is False
def test_none_rules_returns_default(self):
bot = self._make_bot('default-uuid', None)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'default-uuid'
assert routed is False
def test_launcher_type_match(self):
rules = [
{
'type': 'launcher_type',
'operator': 'eq',
'value': 'group',
'pipeline_uuid': 'group-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
assert uuid == 'group-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'default-uuid'
assert routed is False
def test_launcher_id_match(self):
rules = [
{
'type': 'launcher_id',
'operator': 'eq',
'value': '12345',
'pipeline_uuid': 'vip-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
assert uuid == 'vip-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
assert uuid == 'default-uuid'
assert routed is False
def test_message_content_contains(self):
rules = [
{
'type': 'message_content',
'operator': 'contains',
'value': '紧急',
'pipeline_uuid': 'urgent-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
assert uuid == 'urgent-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
assert uuid == 'default-uuid'
assert routed is False
def test_message_content_regex(self):
rules = [
{
'type': 'message_content',
'operator': 'regex',
'value': r'^/admin\b',
'pipeline_uuid': 'admin-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
assert uuid == 'admin-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
assert uuid == 'default-uuid'
assert routed is False
def test_message_has_element_eq(self):
rules = [
{
'type': 'message_has_element',
'operator': 'eq',
'value': 'Image',
'pipeline_uuid': 'image-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
assert uuid == 'image-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
assert uuid == 'default-uuid'
assert routed is False
def test_message_has_element_neq(self):
rules = [
{
'type': 'message_has_element',
'operator': 'neq',
'value': 'Image',
'pipeline_uuid': 'text-only-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
assert uuid == 'text-only-pipeline'
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
assert uuid == 'default-uuid'
assert routed is False
def test_message_has_element_no_types_provided(self):
"""When element types are not provided, should not match."""
rules = [
{
'type': 'message_has_element',
'operator': 'eq',
'value': 'Image',
'pipeline_uuid': 'image-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'default-uuid'
assert routed is False
def test_first_match_wins(self):
rules = [
{
'type': 'launcher_type',
'operator': 'eq',
'value': 'group',
'pipeline_uuid': 'first-pipeline',
},
{
'type': 'launcher_type',
'operator': 'eq',
'value': 'group',
'pipeline_uuid': 'second-pipeline',
},
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
assert uuid == 'first-pipeline'
assert routed is True
def test_skip_invalid_rules(self):
rules = [
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'valid'
assert routed is True
def test_default_operator_is_eq(self):
rules = [
{
'type': 'launcher_type',
'value': 'person',
'pipeline_uuid': 'person-pipeline',
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
assert uuid == 'person-pipeline'
assert routed is True
def test_discard_pipeline(self):
"""When pipeline_uuid is __discard__, the message should be discarded."""
from langbot.pkg.platform.botmgr import RuntimeBot
rules = [
{
'type': 'message_content',
'operator': 'contains',
'value': 'spam',
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
}
]
bot = self._make_bot('default-uuid', rules)
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
assert uuid == RuntimeBot.PIPELINE_DISCARD
assert routed is True
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid'
assert routed is False

564
uv.lock generated
View File

@@ -55,7 +55,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.5"
version = "3.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -66,93 +66,93 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
{ url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
{ url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
{ url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
{ url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
{ url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
{ url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
]
[[package]]
@@ -186,20 +186,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -921,61 +907,61 @@ toml = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
]
[[package]]
@@ -1846,7 +1832,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.6"
version = "4.9.5"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1854,7 +1840,6 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aioshutil" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "argon2-cffi" },
{ name = "async-lru" },
@@ -1875,12 +1860,9 @@ dependencies = [
{ name = "html2text" },
{ name = "langbot-plugin" },
{ name = "langchain" },
{ name = "langchain-core" },
{ name = "langchain-text-splitters" },
{ name = "langsmith" },
{ name = "lark-oapi" },
{ name = "line-bot-sdk" },
{ name = "mako" },
{ name = "markdown" },
{ name = "mcp" },
{ name = "mypy" },
@@ -1901,7 +1883,6 @@ dependencies = [
{ name = "pypdf2" },
{ name = "pyseekdb" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "python-socks" },
{ name = "python-telegram-bot" },
{ name = "pyyaml" },
@@ -1935,10 +1916,9 @@ dev = [
requires-dist = [
{ name = "aiocqhttp", specifier = ">=1.4.4" },
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "aiohttp", specifier = ">=3.13.4" },
{ name = "aiohttp", specifier = ">=3.11.18" },
{ name = "aioshutil", specifier = ">=1.5" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "alembic", specifier = ">=1.15.0" },
{ name = "anthropic", specifier = ">=0.51.0" },
{ name = "argon2-cffi", specifier = ">=23.1.0" },
{ name = "async-lru", specifier = ">=2.0.5" },
@@ -1950,21 +1930,18 @@ requires-dist = [
{ name = "chardet", specifier = ">=5.2.0" },
{ name = "chromadb", specifier = ">=1.0.0,<2.0.0" },
{ name = "colorlog", specifier = "~=6.6.0" },
{ name = "cryptography", specifier = ">=46.0.7" },
{ name = "cryptography", specifier = ">=44.0.3" },
{ name = "dashscope", specifier = ">=1.25.10" },
{ name = "dingtalk-stream", specifier = ">=0.24.0" },
{ name = "discord-py", specifier = ">=2.5.2" },
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.8" },
{ name = "langbot-plugin", specifier = "==0.3.6" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-core", specifier = ">=1.2.28" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
{ name = "langsmith", specifier = ">=0.7.31" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
{ name = "line-bot-sdk", specifier = ">=3.19.0" },
{ name = "mako", specifier = ">=1.3.11" },
{ name = "markdown", specifier = ">=3.6" },
{ name = "mcp", specifier = ">=1.25.0" },
{ name = "mypy", specifier = ">=1.16.0" },
@@ -1973,7 +1950,7 @@ requires-dist = [
{ name = "openai", specifier = ">1.0.0" },
{ name = "pandas", specifier = ">=2.2.2" },
{ name = "pgvector", specifier = ">=0.4.1" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pillow", specifier = ">=11.2.1" },
{ name = "pip", specifier = ">=25.1.1" },
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "psutil", specifier = ">=7.0.0" },
@@ -1985,7 +1962,6 @@ requires-dist = [
{ name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.26" },
{ name = "python-socks", specifier = ">=2.7.1" },
{ name = "python-telegram-bot", specifier = ">=22.0" },
{ name = "pyyaml", specifier = ">=6.0.2" },
@@ -2002,14 +1978,14 @@ requires-dist = [
{ name = "telegramify-markdown", specifier = ">=0.5.1" },
{ name = "tiktoken", specifier = ">=0.9.0" },
{ name = "urllib3", specifier = ">=2.4.0" },
{ name = "uv", specifier = ">=0.11.6" },
{ name = "uv", specifier = ">=0.7.11" },
{ name = "websockets", specifier = ">=15.0.1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.11.9" },
@@ -2017,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.8"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2035,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/d8/7c8ac9516e35d69ead3e934b408e48541f5772eb88fbed19cd216af4b6c2/langbot_plugin-0.3.8.tar.gz", hash = "sha256:e8e420c3b2f167c9635e3e0af46fb452895be9d68ec05bf112ac5f221c3316f3", size = 179803, upload-time = "2026-04-10T11:05:42.791Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/f0/e5561bd1ebda0b9345ad6b98718b5f002bb3ca79b5ec294dc77cc10957b9/langbot_plugin-0.3.6.tar.gz", hash = "sha256:20db981e416a640f22246e54517abc2a095d8ccf5e69e06c2674fb8a443f5dbe", size = 179266, upload-time = "2026-03-30T15:58:58.523Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/63/4a61b67d4886522647e0b60063da155279b943a6b2e6cd004e29aedf67d1/langbot_plugin-0.3.8-py3-none-any.whl", hash = "sha256:2246f343b4735cb4004cf44462ffb47531222c21efeef163a4acd758ebbec2cd", size = 157354, upload-time = "2026-04-10T11:05:41.525Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f5/ac424c2620e1be98a54a0b8ec0ed256a9c06cea7cd32a30732a1aea5fdc5/langbot_plugin-0.3.6-py3-none-any.whl", hash = "sha256:3238448436c41d50a0a0cf37438d845f0a1371159d440af3411a984e3d4e9eb7", size = 156752, upload-time = "2026-03-30T15:59:00.229Z" },
]
[[package]]
@@ -2056,7 +2032,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.0"
version = "1.2.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -2068,21 +2044,21 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/b7/8bbd0d99a6441b35d891e4b79e7d24c67722cdd363893ae650f24808cf5a/langchain_core-1.2.18.tar.gz", hash = "sha256:ffe53eec44636d092895b9fe25d28af3aaf79060e293fa7cda2a5aaa50c80d21", size = 836725, upload-time = "2026-03-09T20:40:07.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d8/9418564ed4ab4f150668b25cf8c188266267d829362e9c9106946afa628b/langchain_core-1.2.18-py3-none-any.whl", hash = "sha256:cccb79523e0045174ab826054e555fddc973266770e427588c8f1ec9d9d6212b", size = 503048, upload-time = "2026-03-09T20:40:06.115Z" },
]
[[package]]
name = "langchain-text-splitters"
version = "1.1.2"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" }
sdist = { url = "https://files.pythonhosted.org/packages/41/42/c178dcdc157b473330eb7cc30883ea69b8ec60078c7b85e2d521054c4831/langchain_text_splitters-1.1.0.tar.gz", hash = "sha256:75e58acb7585dc9508f3cd9d9809cb14751283226c2d6e21fb3a9ae57582ca22", size = 272230, upload-time = "2025-12-14T01:15:38.659Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl", hash = "sha256:f00341fe883358786104a5f881375ac830a4dd40253ecd42b4c10536c6e4693f", size = 34182, upload-time = "2025-12-14T01:15:37.382Z" },
]
[[package]]
@@ -2143,7 +2119,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.32"
version = "0.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -2156,9 +2132,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
sdist = { url = "https://files.pythonhosted.org/packages/66/bb/b8a196c9b9a7ca8b8845eaec7dbae35bcfcb0da3068794e76b29211eae2b/langsmith-0.6.7.tar.gz", hash = "sha256:d89c604a18fc606b7835d8e7924f7cdbe130ca2207bdff8f989590e50d65b802", size = 963940, upload-time = "2026-01-31T02:10:34.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9a/5bc17ea3c746363e73c11df5e89068fea1ed175ca9c00fdb6886efc35b21/langsmith-0.6.7-py3-none-any.whl", hash = "sha256:4bd4372b8bf724b86314f64644562b5598407614e04e74b536c09490d153bd61", size = 309369, upload-time = "2026-01-31T02:10:32.6Z" },
]
[[package]]
@@ -2433,18 +2409,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "mako"
version = "1.3.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
]
[[package]]
name = "markdown"
version = "3.10.1"
@@ -3488,89 +3452,89 @@ wheels = [
[[package]]
name = "pillow"
version = "12.2.0"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
]
[[package]]
@@ -4278,7 +4242,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -4287,9 +4251,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -4355,11 +4319,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
@@ -5624,28 +5588,28 @@ wheels = [
[[package]]
name = "uv"
version = "0.11.7"
version = "0.9.28"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/7d/005ab1cab03ca928cef75b424284d14d62c5f18775cf8114a63f210a0c9c/uv-0.9.28.tar.gz", hash = "sha256:253c04b26fb40f74c56ead12ce83db3c018bdefde1fcd1a542bcb88fdca4189c", size = 3834456, upload-time = "2026-01-29T20:15:49.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" },
{ url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" },
{ url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" },
{ url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" },
{ url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" },
{ url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" },
{ url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" },
{ url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" },
{ url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" },
{ url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" },
{ url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" },
{ url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" },
{ url = "https://files.pythonhosted.org/packages/77/dc/e70698756f1bb74c88bf1eaea63a114a580a38f296ea1567a01db9007490/uv-0.9.28-py3-none-linux_armv6l.whl", hash = "sha256:aede961243bb2c0ca09d0e04ea0bf580d7128dd3b14661b79d133be9a5b69894", size = 22040477, upload-time = "2026-01-29T20:16:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ed/77294752bf722e1d6b666bd6592b6ac975dabcf1fde49e98a75cac23d45c/uv-0.9.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fe9aa2822d24f6ecec035a06dfdd1fbed570ed40b83a864e71714bad37ddfd3", size = 21025194, upload-time = "2026-01-29T20:15:36.504Z" },
{ url = "https://files.pythonhosted.org/packages/b1/a9/78f2da6217c1bbae3371d68515fe747e1160bab049d6898a03e517802573/uv-0.9.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:58a36bf623c6d36b3d60d3c76eeb7275199d607938786e927d40ce213980059d", size = 19783994, upload-time = "2026-01-29T20:16:19.451Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/55639c444e91b96c81c326d39a0a06551d2e611be0cc917b89010ba9ba88/uv-0.9.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4d479a1d387b1464ad2c1f960b0b26a9ac1dfba67ea2c6789e9643fe6d1e7b9a", size = 21568230, upload-time = "2026-01-29T20:15:39.35Z" },
{ url = "https://files.pythonhosted.org/packages/14/2e/95d7992c0a39981cfbcf56ff8f069c09e0567feb0e70cb8b52bc8a2947a0/uv-0.9.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:29eefd4642f55954a2b9a40619cde3d02856300f59b8cf63ed1a161ca0ca9b77", size = 21633679, upload-time = "2026-01-29T20:15:52.363Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/b6778e03714b1f9da095c8bf0f8e5007f4867d9196c1ae8053504ddf2877/uv-0.9.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4155496f624deb753f5ddd80fbe3797587c8480d1250e83c9fd816b4b02e3a41", size = 21632238, upload-time = "2026-01-29T20:15:55.003Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f8/0db6ea9fd8f2752a8723a637e3ed881eb212516665ccb2e8066bbea62a52/uv-0.9.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dc98e2d6db0dc9a2f65ce4cda6a34283fa80f3fbfff129befdf40ad7a3d1615", size = 22779474, upload-time = "2026-01-29T20:15:33.513Z" },
{ url = "https://files.pythonhosted.org/packages/54/88/ef70e04113393f4e19e67281cae9f83c82030d14eb4eb811bda83fcd8f44/uv-0.9.28-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d267280b3878aa6ef8e00bff1f11bf61580d0a8bbb69fa95b5d3526d00f77485", size = 24124596, upload-time = "2026-01-29T20:16:05.062Z" },
{ url = "https://files.pythonhosted.org/packages/81/07/9fda9149bc57e79bde5f00cabcef323a68817c1cca9d44e2aa08d18c6b52/uv-0.9.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba2a320ff77996468789f4b2c573fd766f9330717c440335af8790043b2b3703", size = 23655701, upload-time = "2026-01-29T20:16:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/18/b5/1f1e910ca1a0aca0d0ede3ba0eaca867fd3c575f44b2fe103a5c9511f071/uv-0.9.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c8fd93c5bee89ed88908215f81a3baa0d2a98e35caf995b97e9c226c1c29340", size = 22856456, upload-time = "2026-01-29T20:16:16.582Z" },
{ url = "https://files.pythonhosted.org/packages/9a/fd/82561751105ed232f1781747bc336b20e8d57ee07b4d2ed3fa6cf2718d71/uv-0.9.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b8460a2b624d8ab27cb293a2c9f2393f9efc4e36e0fb886a6c2360e23fb48be", size = 22685296, upload-time = "2026-01-29T20:16:13.857Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e4/b905daff0bfde347c49b9c9ba31d09d504c4b84f2749a07db77a9da16dba/uv-0.9.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3798c486ec627bbd7ca41fa219e997ad403b1f803371edf5c8e75893e46161ba", size = 21669854, upload-time = "2026-01-29T20:15:30.277Z" },
{ url = "https://files.pythonhosted.org/packages/9a/01/9a90574fe7290c775332e54f163cba58c767445b655e97646708f9c66050/uv-0.9.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e479cc5cbfd72ebdbea3c909d0ab997162e0dfa1ee622b50e2f9dc8d07d4eee3", size = 22388944, upload-time = "2026-01-29T20:15:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/cc35014bab3c17b4fe8f6bae84e640ce64d9bb4c8a24694a935e0c0af538/uv-0.9.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:97d61cdf2436e83a0f188d55d1974e46679d9a787c3a54cb0a40de717c6bf435", size = 22073327, upload-time = "2026-01-29T20:15:58.119Z" },
{ url = "https://files.pythonhosted.org/packages/26/cd/e848570be5c5be4e139b90237cc64f68d5d51e8e92c40a5ac7cf0c34ad4a/uv-0.9.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbfa56c833caa37b1f14166327fcaf8aa87290451406921eb07296ffef17fef1", size = 22915580, upload-time = "2026-01-29T20:15:42.468Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2a/6c3d839ea289bf8509da32f703a47accd63ab409b33627728aebcd2a1b65/uv-0.9.28-py3-none-win32.whl", hash = "sha256:d5cb780d5b821f837f63e7fd14e2bf75f01824b4575a1e89639888771bfd9efd", size = 20856809, upload-time = "2026-01-29T20:15:45.141Z" },
{ url = "https://files.pythonhosted.org/packages/06/a8/d72229dd90d1e5a3c8368d51a70219018d579380945e67c8dcffbe8e53c0/uv-0.9.28-py3-none-win_amd64.whl", hash = "sha256:203ab59710c0c1b3c5ecc684f9cfc9264340a69c8706aaa8aea75415779f0d74", size = 23447461, upload-time = "2026-01-29T20:16:22.563Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/5852eb0c59e5224f4cb0323906efae348f782f8a7f1069197e7cf6ec9b74/uv-0.9.28-py3-none-win_arm64.whl", hash = "sha256:c29406e1dc6b1b312c478c76b42b9f94b684855a4c001901b5488bab6ccf4ec7", size = 21860859, upload-time = "2026-01-29T20:16:00.764Z" },
]
[[package]]

View File

@@ -1 +1 @@
VITE_API_BASE_URL=http://localhost:5300
NEXT_PUBLIC_API_BASE_URL=http://192.168.1.97:5300

1
web/.gitignore vendored
View File

@@ -14,7 +14,6 @@
/coverage
# next.js
/dist/
/.next/
/out/

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",

View File

@@ -1,27 +1,18 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
},
},
...compat.extends('next/core-web-vitals', 'next/typescript'),
eslintPluginPrettierRecommended,
{
ignores: ['dist/**', 'node_modules/**'],
},
];
export default eslintConfig;

View File

@@ -1,2 +0,0 @@
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
# well it's easier to recreate router.tsx

View File

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangBot</title>
<meta
name="description"
content="Production-grade platform for building agentic IM bots"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,29 +0,0 @@
#!/bin/bash
cd /root/.openclaw/workspace/coding/projects/LangBot/web
# Find and replace next/navigation
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
-e "s/import {.*useRouter.*} from 'next\/navigation'/import { useNavigate } from 'react-router-dom'/g" \
-e "s/import {.*usePathname.*} from 'next\/navigation'/import { useLocation } from 'react-router-dom'/g" \
-e "s/import {.*useSearchParams.*} from 'next\/navigation'/import { useSearchParams } from 'react-router-dom'/g" \
-e "s/const router = useRouter()/const navigate = useNavigate()/g" \
-e "s/router\.push(/navigate(/g" \
-e "s/router\.replace(/navigate(/g" \
-e "s/router\.back()/navigate(-1)/g" \
-e "s/router\.refresh()/navigate(0)/g" \
-e "s/const pathname = usePathname()/const location = useLocation();\n const pathname = location.pathname;/g" \
-e "s/usePathname()/useLocation().pathname/g" \
{} +
# Note: useSearchParams returns a tuple in react-router-dom. This might need manual fix depending on usage.
# Replace next/link
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
-e "s/import Link from 'next\/link'/import { Link } from 'react-router-dom'/g" \
-e "s/<Link href=/<Link to=/g" \
{} +
# Remove 'use client'
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i "s/'use client';//g" {} +
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/"use client";//g' {} +

8
web/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
output: 'export',
};
export default nextConfig;

4346
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"next lint --fix",
"prettier --write"
]
},
@@ -45,16 +46,17 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
"axios": "^1.15.0",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.18.0",
"lodash": "^4.17.23",
"lucide-react": "^0.507.0",
"next": "~16.1.5",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "19.2.1",
@@ -63,7 +65,6 @@
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-router-dom": "^7.14.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "2.15.4",
"rehype-autolink-headings": "^7.1.0",
@@ -76,10 +77,10 @@
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"uuidjs": "^5.1.0",
"vite": "^8.0.5",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
@@ -94,10 +95,9 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"tw-animate-css": "^1.2.9",

5601
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,145 +0,0 @@
#!/usr/bin/env node
/**
* Check that all i18n locale files have the same keys as en-US.ts (the reference).
* Reports missing keys (present in en-US but absent in the locale) and
* extra keys (present in the locale but absent in en-US).
* Exits with code 1 if any mismatch is found.
*
* Keys are extracted using a line-by-line parser that handles the known format
* of the locale files (no eval or dynamic code execution is used).
*/
import { readFileSync, readdirSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = resolve(__dirname, '../src/i18n/locales');
const REFERENCE = 'en-US.ts';
/**
* Extract all dot-notation leaf keys from a TypeScript locale file.
*
* The expected file format is:
* const <varName> = {
* key: 'value',
* nested: {
* subKey: 'value',
* },
* };
* export default <varName>;
*
* The parser tracks indentation depth to build dot-separated key paths and
* never executes the file content.
*/
function extractKeys(filePath) {
let src = readFileSync(filePath, 'utf8');
// Remove UTF-8 BOM if present
if (src.charCodeAt(0) === 0xfeff) {
src = src.slice(1);
}
const lines = src.split('\n');
const keys = [];
// Stack of { key, indent } pairs representing the current nesting path
const stack = [];
// Matches an object key at the start of a line (identifier or quoted string)
// Captures: [indent, keyName, hasOpenBrace]
const KEY_RE = /^(\s+)([\w]+)\s*:/;
const OPEN_BRACE_RE = /\{\s*$/;
const CLOSE_BRACE_RE = /^\s*\},?\s*$/;
for (const line of lines) {
if (CLOSE_BRACE_RE.test(line)) {
// Pop the stack when we encounter a closing brace line
const lineIndent = line.match(/^(\s*)/)[1].length;
while (stack.length > 0 && stack[stack.length - 1].indent >= lineIndent) {
stack.pop();
}
continue;
}
const m = line.match(KEY_RE);
if (!m) continue;
const indent = m[1].length;
const keyName = m[2];
// Pop stack entries that are at the same or deeper indent level
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
const prefix = stack.map((e) => e.key).join('.');
const fullKey = prefix ? `${prefix}.${keyName}` : keyName;
if (OPEN_BRACE_RE.test(line)) {
// This is a parent (nested object) key — push onto stack, don't record as leaf
stack.push({ key: keyName, indent });
} else {
// This is a leaf key
keys.push(fullKey);
}
}
return keys;
}
function main() {
const files = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.ts'));
if (!files.includes(REFERENCE)) {
console.error(`Reference file ${REFERENCE} not found in ${LOCALES_DIR}`);
process.exit(1);
}
const refKeys = new Set(extractKeys(join(LOCALES_DIR, REFERENCE)));
let hasError = false;
for (const file of files) {
if (file === REFERENCE) continue;
const locale = file.replace('.ts', '');
let localeKeys;
try {
localeKeys = new Set(extractKeys(join(LOCALES_DIR, file)));
} catch (e) {
console.error(`[${locale}] Failed to parse file: ${e.message}`);
hasError = true;
continue;
}
const missing = [...refKeys].filter((k) => !localeKeys.has(k));
const extra = [...localeKeys].filter((k) => !refKeys.has(k));
if (missing.length === 0 && extra.length === 0) {
console.log(`[${locale}] ✅ All keys match.`);
} else {
hasError = true;
console.log(`\n[${locale}] ❌ Key mismatch detected:`);
if (missing.length > 0) {
console.log(` Missing keys (in en-US but not in ${locale}):`);
for (const k of missing) {
console.log(` - ${k}`);
}
}
if (extra.length > 0) {
console.log(` Extra keys (in ${locale} but not in en-US):`);
for (const k of extra) {
console.log(` + ${k}`);
}
}
}
}
if (hasError) {
console.log('\n❌ i18n key check failed. Please fix the mismatches above.');
process.exit(1);
} else {
console.log('\n✅ All i18n locale files have matching keys.');
}
}
main();

View File

@@ -1,5 +1,7 @@
'use client';
import { useEffect, useState, useCallback, Suspense } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useRouter, useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -21,8 +23,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const [status, setStatus] = useState<
@@ -49,7 +51,7 @@ function SpaceOAuthCallbackContent() {
const wizardState = localStorage.getItem('langbot_wizard_state');
const redirectTo = wizardState ? '/wizard' : '/home';
setTimeout(() => {
navigate(redirectTo);
router.push(redirectTo);
}, 1000);
} catch (err) {
setStatus('error');
@@ -62,7 +64,7 @@ function SpaceOAuthCallbackContent() {
}
}
},
[navigate, t],
[router, t],
);
const [bindState, setBindState] = useState<string | null>(null);
@@ -79,7 +81,7 @@ function SpaceOAuthCallbackContent() {
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
setTimeout(() => {
navigate('/home');
router.push('/home');
}, 1000);
} catch (err) {
setStatus('error');
@@ -94,7 +96,7 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(false);
}
},
[navigate, t],
[router, t],
);
useEffect(() => {
@@ -144,7 +146,7 @@ function SpaceOAuthCallbackContent() {
};
const handleCancelBind = () => {
navigate('/home');
router.push('/home');
};
return (
@@ -152,7 +154,7 @@ function SpaceOAuthCallbackContent() {
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardHeader className="text-center">
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
@@ -215,7 +217,7 @@ function SpaceOAuthCallbackContent() {
<>
<AlertCircle className="h-12 w-12 text-red-500" />
<Button
onClick={() => navigate(isBindMode ? '/home' : '/login')}
onClick={() => router.push(isBindMode ? '/home' : '/login')}
className="w-full mt-4"
>
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}

View File

@@ -1,5 +1,3 @@
@import 'tailwindcss';
@import 'tw-animate-css';
:root {
/* 适用于 Firefox 的滚动条 */
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
@@ -74,6 +72,10 @@
}
}
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {

View File

@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -32,7 +34,7 @@ import { toast } from 'sonner';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
@@ -103,12 +105,12 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleBotDeleted() {
refreshBots();
navigate('/home/bots');
router.push('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
function confirmDelete() {
@@ -174,11 +176,9 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
{activeTab === 'config' && (
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
)}
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}

View File

@@ -34,6 +34,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -68,12 +69,7 @@ const getFormSchema = (t: (key: string) => string) =>
pipeline_routing_rules: z
.array(
z.object({
type: z.enum([
'launcher_type',
'launcher_id',
'message_content',
'message_has_element',
]),
type: z.enum(['launcher_type', 'launcher_id', 'message_content']),
operator: z.enum([
'eq',
'neq',

View File

@@ -6,7 +6,7 @@ import {
PipelineRoutingRule,
RoutingRuleOperator,
} from '@/app/infra/entities/api';
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FormLabel } from '@/components/ui/form';
@@ -14,32 +14,9 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useRef, useMemo, useState } from 'react';
export const PIPELINE_DISCARD = '__discard__';
interface PipelineOption {
value: string;
@@ -76,298 +53,26 @@ const OPERATORS_BY_TYPE: Record<
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
{ value: 'regex', labelKey: 'bots.operatorRegex' },
],
message_has_element: [
{ value: 'eq', labelKey: 'bots.operatorHas' },
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
],
};
function getValuePlaceholder(
t: (key: string) => string,
rule: PipelineRoutingRule,
): string {
if (rule.type === 'launcher_id')
return t('bots.ruleValueLauncherIdPlaceholder');
if (rule.type === 'message_has_element')
return t('bots.ruleValueElementPlaceholder');
if (rule.type === 'launcher_id') return t('bots.ruleValueLauncherIdPlaceholder');
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
return t('bots.ruleValueMessagePlaceholder');
}
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
interface RuleRowContentProps {
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
dragHandleProps?: Record<string, unknown>;
isOverlay?: boolean;
}
function RuleRowContent({
rule,
index,
pipelineNameList,
updateRule,
removeRule,
dragHandleProps,
isOverlay,
}: RuleRowContentProps) {
const { t } = useTranslation();
const operatorsForType =
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
return (
<div
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
}`}
>
{/* Drag handle */}
<button
type="button"
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Field selector */}
<Select
value={rule.type}
onValueChange={(val) => {
updateRule(index, {
type: val as PipelineRoutingRule['type'],
operator: 'eq',
value: '',
});
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="launcher_type">
{t('bots.ruleTypeLauncherType')}
</SelectItem>
<SelectItem value="launcher_id">
{t('bots.ruleTypeLauncherId')}
</SelectItem>
<SelectItem value="message_content">
{t('bots.ruleTypeMessageContent')}
</SelectItem>
<SelectItem value="message_has_element">
{t('bots.ruleTypeMessageHasElement')}
</SelectItem>
</SelectContent>
</Select>
{/* Operator selector */}
<Select
value={rule.operator || 'eq'}
onValueChange={(val) => {
updateRule(index, { operator: val as RoutingRuleOperator });
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operatorsForType.map((op) => (
<SelectItem key={op.value} value={op.value}>
{t(op.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input */}
{rule.type === 'launcher_type' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.sessionTypePerson')}
</SelectItem>
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
</SelectContent>
</Select>
) : rule.type === 'message_has_element' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="flex-1"
placeholder={getValuePlaceholder(t, rule)}
value={rule.value}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
<span className="text-sm text-muted-foreground shrink-0"></span>
{/* Pipeline selector */}
<Select
value={rule.pipeline_uuid}
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
>
<SelectTrigger className="w-[200px]">
{rule.pipeline_uuid ? (
isDiscard ? (
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
) : (
(() => {
const p = pipelineNameList.find(
(p) => p.value === rule.pipeline_uuid,
);
return (
<div className="flex items-center gap-2">
{p?.emoji && (
<span className="text-sm shrink-0">{p.emoji}</span>
)}
<span>{p?.label ?? rule.pipeline_uuid}</span>
</div>
);
})()
)
) : (
<SelectValue placeholder={t('bots.selectPipeline')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={PIPELINE_DISCARD}>
<div className="flex items-center gap-2 text-destructive">
<Ban className="h-3.5 w-3.5 shrink-0" />
<span>{t('bots.pipelineDiscard')}</span>
</div>
</SelectItem>
<SelectSeparator />
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">{item.emoji}</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRule(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
);
}
/* ── Sortable rule row ─────────────────────────────────────────────── */
interface SortableRuleRowProps {
id: string;
rule: PipelineRoutingRule;
index: number;
pipelineNameList: PipelineOption[];
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
removeRule: (index: number) => void;
}
function SortableRuleRow({
id,
rule,
index,
pipelineNameList,
updateRule,
removeRule,
}: SortableRuleRowProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
// No transition — items reorder visually during drag via transform;
// on drop the data updates and transform resets, so animating would
// cause a redundant "swap" flicker.
opacity: isDragging ? 0.3 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<RuleRowContent
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
}
/* ── Main editor ───────────────────────────────────────────────────── */
export default function RoutingRulesEditor({
form,
pipelineNameList,
}: RoutingRulesEditorProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const rules: PipelineRoutingRule[] =
form.watch('pipeline_routing_rules') || [];
// Stable unique ids for sortable items.
// We keep a running counter so newly added rules always get fresh ids.
const nextId = useRef(0);
const idsRef = useRef<string[]>([]);
const sortableIds = useMemo(() => {
// Grow the id list to match rules length (newly added items get new ids).
while (idsRef.current.length < rules.length) {
idsRef.current.push(`rule-${nextId.current++}`);
}
// Shrink if rules were removed from the end.
if (idsRef.current.length > rules.length) {
idsRef.current = idsRef.current.slice(0, rules.length);
}
return idsRef.current;
}, [rules.length]);
const updateRules = (newRules: PipelineRoutingRule[]) => {
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
};
@@ -393,38 +98,9 @@ export default function RoutingRulesEditor({
const removeRule = (index: number) => {
const updated = [...rules];
updated.splice(index, 1);
// Also remove the corresponding sortable id so indices stay in sync.
idsRef.current.splice(index, 1);
updateRules(updated);
};
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
updateRules(arrayMove(rules, oldIndex, newIndex));
};
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
@@ -440,41 +116,143 @@ export default function RoutingRulesEditor({
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
{rules.map((rule, index) => (
<SortableRuleRow
key={sortableIds[index]}
id={sortableIds[index]}
rule={rule}
index={index}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeRule ? (
<RuleRowContent
rule={activeRule}
index={activeIndex}
pipelineNameList={pipelineNameList}
updateRule={updateRule}
removeRule={removeRule}
isOverlay
/>
) : null}
</DragOverlay>
</DndContext>
{rules.map((rule, index) => {
const operatorsForType = OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
return (
<div
key={index}
className="flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30"
>
{/* Field selector */}
<Select
value={rule.type}
onValueChange={(val) => {
updateRule(index, {
type: val as PipelineRoutingRule['type'],
operator: 'eq',
value: '',
});
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="launcher_type">
{t('bots.ruleTypeLauncherType')}
</SelectItem>
<SelectItem value="launcher_id">
{t('bots.ruleTypeLauncherId')}
</SelectItem>
<SelectItem value="message_content">
{t('bots.ruleTypeMessageContent')}
</SelectItem>
</SelectContent>
</Select>
{/* Operator selector */}
<Select
value={rule.operator || 'eq'}
onValueChange={(val) => {
updateRule(index, { operator: val as RoutingRuleOperator });
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operatorsForType.map((op) => (
<SelectItem key={op.value} value={op.value}>
{t(op.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input */}
{rule.type === 'launcher_type' ? (
<Select
value={rule.value}
onValueChange={(val) => updateRule(index, { value: val })}
>
<SelectTrigger className="w-[100px]">
<SelectValue
placeholder={t('bots.ruleValuePlaceholder')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.sessionTypePerson')}
</SelectItem>
<SelectItem value="group">
{t('bots.sessionTypeGroup')}
</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="flex-1"
placeholder={getValuePlaceholder(t, rule)}
value={rule.value}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
<span className="text-sm text-muted-foreground shrink-0"></span>
{/* Pipeline selector */}
<Select
value={rule.pipeline_uuid}
onValueChange={(val) =>
updateRule(index, { pipeline_uuid: val })
}
>
<SelectTrigger className="w-[200px]">
{rule.pipeline_uuid ? (
(() => {
const p = pipelineNameList.find(
(p) => p.value === rule.pipeline_uuid,
);
return (
<div className="flex items-center gap-2">
{p?.emoji && (
<span className="text-sm shrink-0">{p.emoji}</span>
)}
<span>{p?.label ?? rule.pipeline_uuid}</span>
</div>
);
})()
) : (
<SelectValue placeholder={t('bots.selectPipeline')} />
)}
</SelectTrigger>
<SelectContent>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">{item.emoji}</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRule(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
);
})}
</div>
);
}

View File

@@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { httpClient } from '@/app/infra/http/HttpClient';

View File

@@ -1,3 +1,5 @@
'use client';
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -13,7 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
export function BotLogListComponent({
botId,
@@ -30,7 +32,7 @@ export function BotLogListComponent({
hideToolbar?: boolean;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const router = useRouter();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
@@ -229,7 +231,7 @@ export function BotLogListComponent({
variant="outline"
size="sm"
className="gap-1"
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
>
<ExternalLink className="size-3.5" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>

View File

@@ -1,3 +1,5 @@
'use client';
import React, {
useState,
useEffect,
@@ -10,15 +12,7 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import {
Ban,
Bot,
Copy,
Check,
Workflow,
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import { Copy, Check } from 'lucide-react';
import {
MessageChainComponent,
Plain,
@@ -27,7 +21,6 @@ import {
Quote,
Voice,
} from '@/app/infra/entities/message';
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
interface SessionInfo {
session_id: string;
@@ -62,12 +55,6 @@ interface SessionMessage {
role?: string | null;
}
interface SessionFeedback {
feedback_type: number; // 1=like, 2=dislike
feedback_content?: string | null;
stream_id?: string | null;
}
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
@@ -89,9 +76,6 @@ const BotSessionMonitor = forwardRef<
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const parseSessionType = (sessionId: string): string | null => {
@@ -134,50 +118,21 @@ const BotSessionMonitor = forwardRef<
[loadSessions],
);
const loadMessages = useCallback(
async (sessionId: string) => {
setLoadingMessages(true);
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
// Collect user message IDs for feedback matching
const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
);
if (userMsgIds.size > 0) {
// Fetch feedback for this bot, then match by stream_id locally
const feedbackRes = await httpClient.get<{
feedback: SessionFeedback[];
}>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
);
const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) {
for (const fb of feedbackRes.feedback) {
if (fb.stream_id && userMsgIds.has(fb.stream_id)) {
map[fb.stream_id] = fb;
}
}
}
setFeedbackMap(map);
} else {
setFeedbackMap({});
}
} catch (error) {
console.error('Failed to load session messages:', error);
} finally {
setLoadingMessages(false);
}
},
[botId],
);
const loadMessages = useCallback(async (sessionId: string) => {
setLoadingMessages(true);
try {
const response = await httpClient.getSessionMessages(sessionId);
const sorted = (response.messages ?? []).sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
} catch (error) {
console.error('Failed to load session messages:', error);
} finally {
setLoadingMessages(false);
}
}, []);
useEffect(() => {
loadSessions();
@@ -192,18 +147,14 @@ const BotSessionMonitor = forwardRef<
}, [selectedSessionId, loadMessages]);
useEffect(() => {
if (messages.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
const container = messagesContainerRef.current;
if (container) {
const viewport = container.querySelector(
'[data-radix-scroll-area-viewport]',
);
const scrollTarget = viewport || container;
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, [messages]);
const parseMessageChain = (content: string): MessageChainComponent[] => {
@@ -442,6 +393,7 @@ const BotSessionMonitor = forwardRef<
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
<span className="truncate">{session.pipeline_name}</span>
</div>
</button>
);
@@ -497,6 +449,12 @@ const BotSessionMonitor = forwardRef<
</button>
</>
)}
{selectedSession?.pipeline_name && (
<>
<span>·</span>
<span>{selectedSession.pipeline_name}</span>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
@@ -525,21 +483,8 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg, msgIndex) => {
messages.map((msg) => {
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
}
}
return (
<div
key={msg.id}
@@ -555,11 +500,10 @@ const BotSessionMonitor = forwardRef<
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)}
>
{renderMessageContent(msg)}
{/* Role label + pipeline + timestamp */}
{/* Role label + timestamp */}
<div
className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
@@ -577,52 +521,14 @@ const BotSessionMonitor = forwardRef<
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
) : msg.pipeline_name ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
<span className="opacity-70">
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.dislike')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
))}
</div>
</div>
</div>

View File

@@ -1,10 +1,12 @@
import { useSearchParams } from 'react-router-dom';
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import BotDetailContent from './BotDetailContent';
export default function BotConfigPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';

View File

@@ -1,9 +1,11 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import {
Dialog,
DialogContent,
@@ -65,10 +67,9 @@ export default function ApiIntegrationDialog({
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -93,9 +94,7 @@ export default function ApiIntegrationDialog({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}
}, [open]);
@@ -109,7 +108,7 @@ export default function ApiIntegrationDialog({
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
onOpenChange(newOpen);
};

View File

@@ -249,9 +249,6 @@ export default function DynamicFormComponent({
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),

View File

@@ -23,7 +23,6 @@ import {
Bot,
KnowledgeBase,
EmbeddingModel,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -76,14 +75,9 @@ export default function DynamicFormItemComponent({
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
[],
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
@@ -215,21 +209,6 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
.getTools()
.then((resp) => {
setTools(resp.tools);
})
.catch((err) => {
toast.error(
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
);
});
}
}, [config.type]);
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -1182,139 +1161,6 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
);
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newValue = field.value.filter(
(name: string) => name !== toolName,
);
field.onChange(newValue);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('tools.noToolSelected', 'No tools selected')}
</p>
</div>
)}
</div>
<Button
type="button"
onClick={() => {
setTempSelectedToolNames(field.value || []);
setToolsDialogOpen(true);
}}
variant="outline"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
{t('tools.addTool', 'Add Tool')}
</Button>
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('tools.selectTools', 'Select Tools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{tools.map((tool) => {
const isSelected = tempSelectedToolNames.includes(tool.name);
return (
<div
key={tool.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
setTempSelectedToolNames((prev) =>
prev.includes(tool.name)
? prev.filter((name) => name !== tool.name)
: [...prev, tool.name],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${tool.name}`}
/>
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{tool.name}</div>
{tool.human_desc && (
<div className="text-sm text-muted-foreground">
{tool.human_desc}
</div>
)}
</div>
</div>
);
})}
{tools.length === 0 && (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('tools.noToolsAvailable', 'No tools available')}
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setToolsDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
field.onChange(tempSelectedToolNames);
setToolsDialogOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
@@ -27,7 +29,7 @@ import {
Github,
Zap,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
import { useTheme } from 'next-themes';
import {
DropdownMenu,
@@ -242,10 +244,9 @@ function NavItems({
sectionOpenState: Record<string, boolean>;
onSectionToggle: (id: string, open: boolean) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const sidebarData = useSidebarData();
const { setPendingPluginInstallAction } = sidebarData;
const { state: sidebarState, isMobile } = useSidebar();
@@ -412,7 +413,7 @@ function NavItems({
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
navigate(itemRoute);
router.push(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -470,7 +471,7 @@ function NavItems({
)}
onClick={(e) => {
e.preventDefault();
navigate(itemRoute);
router.push(itemRoute);
}}
>
{item.emoji ? (
@@ -622,7 +623,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
router.push('/home/market');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -637,7 +638,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -651,7 +652,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -668,7 +669,7 @@ function NavItems({
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
router.push(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -719,7 +720,7 @@ function NavItems({
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
@@ -730,7 +731,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
router.push('/home/market');
}}
>
<Store className="size-4" />
@@ -741,7 +742,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
router.push('/home/plugins');
}}
>
<Upload className="size-4" />
@@ -751,7 +752,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
router.push('/home/plugins');
}}
>
<Github className="size-4" />
@@ -762,10 +763,10 @@ function NavItems({
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
router.push(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
@@ -1028,10 +1029,9 @@ export default function HomeSidebar({
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { isMobile } = useSidebar();
useEffect(() => {
@@ -1071,16 +1071,14 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
}
@@ -1089,16 +1087,14 @@ export default function HomeSidebar({
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
router.replace(newUrl, { scroll: false });
}
}
@@ -1169,7 +1165,7 @@ export default function HomeSidebar({
// User click: update state AND navigate
function handleChildClick(child: SidebarChildVO) {
selectChild(child);
navigate(child.route);
router.push(child.route);
}
function initSelect() {
@@ -1230,7 +1226,7 @@ export default function HomeSidebar({
tooltip="LangBot"
>
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="size-8 rounded-lg"
/>
@@ -1410,7 +1406,7 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
navigate('/wizard');
router.push('/wizard');
}}
>
<Zap className="text-blue-500" />

View File

@@ -1,3 +1,5 @@
'use client';
import React, {
createContext,
useContext,

View File

@@ -1,3 +1,5 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Boxes } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
@@ -16,8 +18,6 @@ import { ProviderCard } from './components';
import {
ExtraArg,
ModelType,
ScanModelsResult,
SelectedScannedModel,
TestResult,
ProviderModels,
LANGBOT_MODELS_PROVIDER_REQUESTER,
@@ -264,60 +264,6 @@ export default function ModelsDialog({
}
}
async function handleScanModels(
providerUuid: string,
modelType: ModelType,
): Promise<ScanModelsResult> {
try {
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
return {
models: resp.models,
debug: resp.debug,
};
} catch (err) {
toast.error(t('models.getModelListError') + (err as CustomApiError).msg);
return { models: [] };
}
}
async function handleAddScannedModels(
providerUuid: string,
modelType: ModelType,
models: SelectedScannedModel[],
) {
if (models.length === 0) return;
setIsSubmitting(true);
try {
for (const item of models) {
if (modelType === 'llm') {
await httpClient.createProviderLLMModel({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
extra_args: {},
} as never);
} else {
await httpClient.createProviderEmbeddingModel({
name: item.model.name,
provider_uuid: providerUuid,
extra_args: {},
} as never);
}
}
setAddModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
loadProviders();
toast.success(
t('models.addSelectedModelsSuccess', { count: models.length }),
);
} catch (err) {
toast.error(t('models.createError') + (err as CustomApiError).msg);
} finally {
setIsSubmitting(false);
}
}
async function handleUpdateModel(
providerUuid: string,
modelId: string,
@@ -460,10 +406,6 @@ export default function ModelsDialog({
onAddModel={(modelType, name, abilities, extraArgs) =>
handleAddModel(provider.uuid, modelType, name, abilities, extraArgs)
}
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
onAddScannedModels={(modelType, models) =>
handleAddScannedModels(provider.uuid, modelType, models)
}
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
onCloseEditModel={() => setEditModelPopoverOpen(null)}
onUpdateModel={(modelId, modelType, name, abilities, extraArgs) =>

View File

@@ -169,6 +169,8 @@ export default function ProviderForm({
onValueChange={(v) => {
field.onChange(v);
const req = requesterList.find((r) => r.value === v);
// Auto-fill default URL when creating new provider
// or when base_url is empty in edit mode
if (req && (!providerId || !form.getValues('base_url'))) {
form.setValue('base_url', req.defaultUrl);
}

View File

@@ -1,14 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import {
Plus,
MessageSquareText,
Cpu,
Eye,
Wrench,
Check,
RefreshCw,
Search,
} from 'lucide-react';
'use client';
import { useState, useEffect } from 'react';
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -20,14 +13,7 @@ import {
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { ScannedProviderModel } from '@/app/infra/entities/api';
import {
ExtraArg,
ModelType,
ScanModelsResult,
SelectedScannedModel,
TestResult,
} from '../types';
import { ExtraArg, ModelType, TestResult } from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
interface AddModelPopoverProps {
@@ -40,11 +26,6 @@ interface AddModelPopoverProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
) => Promise<void>;
onTestModel: (
name: string,
modelType: ModelType,
@@ -62,8 +43,6 @@ export default function AddModelPopover({
onOpen,
onClose,
onAddModel,
onScanModels,
onAddScannedModels,
onTestModel,
isSubmitting,
isTesting,
@@ -71,44 +50,22 @@ export default function AddModelPopover({
onResetTestResult,
}: AddModelPopoverProps) {
const { t } = useTranslation();
const prevIsOpenRef = useRef(false);
const [tab, setTab] = useState<ModelType>('llm');
const [mode, setMode] = useState<'manual' | 'scan'>('manual');
const [name, setName] = useState('');
const [abilities, setAbilities] = useState<string[]>([]);
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
const [scanLoading, setScanLoading] = useState(false);
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
[],
);
const [selectedScannedModels, setSelectedScannedModels] = useState<
Record<string, SelectedScannedModel>
>({});
const [scanQuery, setScanQuery] = useState('');
// Reset form when popover opens
useEffect(() => {
const wasOpen = prevIsOpenRef.current;
if (isOpen && !wasOpen) {
if (isOpen) {
setTab('llm');
setMode('manual');
setName('');
setAbilities([]);
setExtraArgs([]);
setScanLoading(false);
setScannedModels([]);
setSelectedScannedModels({});
setScanQuery('');
onResetTestResult();
}
prevIsOpenRef.current = isOpen;
}, [isOpen, onResetTestResult]);
useEffect(() => {
setScannedModels([]);
setSelectedScannedModels({});
setScanQuery('');
}, [tab, mode]);
}, [isOpen]);
const handleAdd = async () => {
await onAddModel(tab, name, abilities, extraArgs);
@@ -118,50 +75,6 @@ export default function AddModelPopover({
await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);
};
const handleScan = async () => {
setScanLoading(true);
try {
const result = await onScanModels(tab);
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
const debugData = (
result.debug?.response as { data?: Record<string, unknown>[] }
)?.data;
if (Array.isArray(debugData)) {
const debugMap = new Map<string, Record<string, unknown>>();
for (const item of debugData) {
if (typeof item?.id === 'string') {
debugMap.set(item.id, item);
}
}
for (const model of result.models) {
const debugItem = debugMap.get(model.id);
if (!debugItem) continue;
const features = debugItem.features as
| Record<string, unknown>
| undefined;
const tools = features?.tools as Record<string, unknown> | undefined;
if (tools?.function_calling === true) {
const abilities = new Set(model.abilities || []);
abilities.add('func_call');
model.abilities = [...abilities];
}
}
}
setScannedModels(result.models);
setSelectedScannedModels({});
} finally {
setScanLoading(false);
}
};
const handleAddScanned = async () => {
const selectedModels = Object.values(selectedScannedModels);
if (selectedModels.length === 0) return;
await onAddScannedModels(tab, selectedModels);
};
const toggleAbility = (ability: string, checked: boolean) => {
if (checked) {
setAbilities([...abilities, ability]);
@@ -170,76 +83,6 @@ export default function AddModelPopover({
}
};
const toggleScannedModel = (
model: ScannedProviderModel,
checked: boolean,
) => {
setSelectedScannedModels((prev) => {
const next = { ...prev };
if (checked) {
next[model.id] = {
model,
abilities:
model.type === 'llm'
? prev[model.id]?.abilities || model.abilities || []
: [],
};
} else {
delete next[model.id];
}
return next;
});
};
const toggleScannedModelAbility = (
modelId: string,
ability: string,
checked: boolean,
) => {
setSelectedScannedModels((prev) => {
const current = prev[modelId];
if (!current) return prev;
const nextAbilities = checked
? [...current.abilities, ability]
: current.abilities.filter((item) => item !== ability);
return {
...prev,
[modelId]: {
...current,
abilities: nextAbilities,
},
};
});
};
const filteredScannedModels = scannedModels.filter((model) =>
model.name.toLowerCase().includes(scanQuery.trim().toLowerCase()),
);
const selectableModels = filteredScannedModels.filter(
(m) => !m.already_added,
);
const allSelected =
selectableModels.length > 0 &&
selectableModels.every((m) => Boolean(selectedScannedModels[m.id]));
const toggleSelectAll = () => {
if (allSelected) {
setSelectedScannedModels({});
} else {
const next: Record<string, SelectedScannedModel> = {};
for (const model of selectableModels) {
next[model.id] = {
model,
abilities: model.type === 'llm' ? model.abilities || [] : [],
};
}
setSelectedScannedModels(next);
}
};
return (
<Popover
open={isOpen}
@@ -257,11 +100,8 @@ export default function AddModelPopover({
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
className="w-80"
align="end"
side="left"
sideOffset={8}
collisionPadding={16}
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
@@ -276,260 +116,116 @@ export default function AddModelPopover({
</TabsTrigger>
</TabsList>
<Tabs
value={mode}
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
>
<TabsList className="grid w-full grid-cols-2 mt-3">
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
</TabsList>
<TabsContent value="manual" className="mt-3">
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={name}
onChange={(e) => setName(e.target.value)}
<TabsContent value="llm" className="space-y-3 mt-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="add-vision"
checked={abilities.includes('vision')}
onCheckedChange={(checked) =>
toggleAbility('vision', checked as boolean)
}
/>
<Label htmlFor="add-vision" className="text-sm">
<Eye className="h-3 w-3 inline mr-1" />
{t('models.visionAbility')}
</Label>
</div>
{tab === 'llm' && (
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="add-vision"
checked={abilities.includes('vision')}
onCheckedChange={(checked) =>
toggleAbility('vision', checked as boolean)
}
/>
<Label htmlFor="add-vision" className="text-sm">
<Eye className="h-3 w-3 inline mr-1" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="add-func-call"
checked={abilities.includes('func_call')}
onCheckedChange={(checked) =>
toggleAbility('func_call', checked as boolean)
}
/>
<Label htmlFor="add-func-call" className="text-sm">
<Wrench className="h-3 w-3 inline mr-1" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
)}
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAdd}
disabled={isSubmitting || isTesting}
>
{isSubmitting ? t('common.saving') : t('common.add')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
<div className="flex items-center gap-2">
<Checkbox
id="add-func-call"
checked={abilities.includes('func_call')}
onCheckedChange={(checked) =>
toggleAbility('func_call', checked as boolean)
}
/>
<Label htmlFor="add-func-call" className="text-sm">
<Wrench className="h-3 w-3 inline mr-1" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</TabsContent>
<TabsContent value="scan" className="space-y-3 mt-3">
<div className="text-xs text-muted-foreground">
{t('models.scanModelsHint')}
</div>
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleScan}
disabled={scanLoading || isSubmitting}
>
{scanLoading ? (
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
) : (
<Search className="h-4 w-4 mr-1" />
)}
{t('models.scanModels')}
</Button>
<Button
className="flex-1"
size="sm"
onClick={handleAddScanned}
disabled={
isSubmitting ||
scanLoading ||
Object.keys(selectedScannedModels).length === 0
}
>
{isSubmitting
? t('common.saving')
: t('models.addSelectedModels')}
</Button>
</div>
<div className="space-y-2">
<Label>{t('models.scannedModels')}</Label>
<Input
placeholder={t('models.searchScannedModels')}
value={scanQuery}
onChange={(e) => setScanQuery(e.target.value)}
disabled={scannedModels.length === 0}
/>
{selectableModels.length > 0 && (
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="scan-select-all"
checked={allSelected}
onCheckedChange={toggleSelectAll}
/>
<Label
htmlFor="scan-select-all"
className="text-sm font-medium"
>
{t('models.selectAll')}
<span className="text-muted-foreground ml-1">
({Object.keys(selectedScannedModels).length}/
{selectableModels.length})
</span>
</Label>
</div>
)}
</div>
<div
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
onWheel={(e) => e.stopPropagation()}
</div>
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAdd}
disabled={isSubmitting || isTesting}
>
<div className="p-3 space-y-2">
{filteredScannedModels.length === 0 ? (
<p className="text-sm text-muted-foreground">
{scannedModels.length === 0
? t('models.noScannedModels')
: t('models.noScannedModelsMatch')}
</p>
) : (
filteredScannedModels.map((model) => {
const isSelected = Boolean(
selectedScannedModels[model.id],
);
const selectedAbilities =
selectedScannedModels[model.id]?.abilities || [];
return (
<div
key={model.id}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected || model.already_added}
disabled={model.already_added}
onCheckedChange={(checked) =>
toggleScannedModel(model, checked as boolean)
}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium break-all">
{model.name}
</div>
<div className="text-xs text-muted-foreground">
{model.already_added
? t('models.alreadyAdded')
: model.type === 'llm'
? t('models.chat')
: t('models.embedding')}
</div>
</div>
</div>
{isSubmitting ? t('common.saving') : t('common.add')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
</div>
</TabsContent>
{tab === 'llm' &&
isSelected &&
!model.already_added && (
<div className="flex gap-4 pl-7">
<div className="flex items-center gap-2">
<Checkbox
id={`scan-vision-${model.id}`}
checked={selectedAbilities.includes(
'vision',
)}
onCheckedChange={(checked) =>
toggleScannedModelAbility(
model.id,
'vision',
checked as boolean,
)
}
/>
<Label
htmlFor={`scan-vision-${model.id}`}
className="text-sm"
>
<Eye className="h-3 w-3 inline mr-1" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`scan-func-${model.id}`}
checked={selectedAbilities.includes(
'func_call',
)}
onCheckedChange={(checked) =>
toggleScannedModelAbility(
model.id,
'func_call',
checked as boolean,
)
}
/>
<Label
htmlFor={`scan-func-${model.id}`}
className="text-sm"
>
<Wrench className="h-3 w-3 inline mr-1" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
</TabsContent>
</Tabs>
<TabsContent value="embedding" className="space-y-3 mt-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAdd}
disabled={isSubmitting || isTesting}
>
{isSubmitting ? t('common.saving') : t('common.add')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
</div>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>

View File

@@ -1,3 +1,5 @@
'use client';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@@ -1,3 +1,5 @@
'use client';
import { useState, useEffect } from 'react';
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import {
Plus,
@@ -24,14 +26,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import {
ExtraArg,
ModelType,
ScanModelsResult,
SelectedScannedModel,
TestResult,
ProviderModels,
} from '../types';
import { ExtraArg, ModelType, TestResult, ProviderModels } from '../types';
import ModelItem from './ModelItem';
import AddModelPopover from './AddModelPopover';
@@ -60,11 +55,6 @@ interface ProviderCardProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
) => Promise<void>;
onOpenEditModel: (modelId: string) => void;
onCloseEditModel: () => void;
onUpdateModel: (
@@ -113,8 +103,6 @@ export default function ProviderCard({
onOpenAddModel,
onCloseAddModel,
onAddModel,
onScanModels,
onAddScannedModels,
onOpenEditModel,
onCloseEditModel,
onUpdateModel,
@@ -147,7 +135,7 @@ export default function ProviderCard({
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon}
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
@@ -312,8 +300,6 @@ export default function ProviderCard({
onOpen={onOpenAddModel}
onClose={onCloseAddModel}
onAddModel={onAddModel}
onScanModels={onScanModels}
onAddScannedModels={onAddScannedModels}
onTestModel={onTestModel}
isSubmitting={isSubmitting}
isTesting={isTesting}

View File

@@ -2,8 +2,6 @@ import {
LLMModel,
EmbeddingModel,
ModelProvider,
ProviderScanDebugInfo,
ScannedProviderModel,
} from '@/app/infra/entities/api';
export type ExtraArg = {
@@ -24,16 +22,6 @@ export interface TestResult {
duration: number;
}
export type SelectedScannedModel = {
model: ScannedProviderModel;
abilities: string[];
};
export type ScanModelsResult = {
models: ScannedProviderModel[];
debug?: ProviderScanDebugInfo;
};
export interface ModelItemProps {
model: LLMModel | EmbeddingModel;
modelType: ModelType;
@@ -87,11 +75,6 @@ export interface ProviderCardProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
) => Promise<void>;
onOpenEditModel: (modelId: string) => void;
onCloseEditModel: () => void;
onUpdateModel: (

View File

@@ -1,3 +1,5 @@
'use client';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';

View File

@@ -1,3 +1,5 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type {

View File

@@ -1,5 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
@@ -30,7 +32,7 @@ import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const router = useRouter();
const { t } = useTranslation();
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
@@ -82,12 +84,12 @@ export default function KBDetailContent({ id }: { id: string }) {
function handleKbDeleted() {
refreshKnowledgeBases();
navigate('/home/knowledge');
router.push('/home/knowledge');
}
function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases();
navigate(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
}
function handleKbUpdated() {

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
@@ -220,12 +219,6 @@ export default function FileUploadZone({
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}
</Link>
</div>
) : (
<div className="space-y-2">

View File

@@ -1,3 +1,5 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -1,3 +1,5 @@
'use client';
import {
ColumnDef,
flexRender,

Some files were not shown because too many files have changed in this diff Show More