mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
49 Commits
feat/human
...
feat/litel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd16aac51 | ||
|
|
d170bdd343 | ||
|
|
b33d05f99a | ||
|
|
de61b5d368 | ||
|
|
58c1916712 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b | ||
|
|
5a5c4295b1 | ||
|
|
88111d87ac | ||
|
|
4e5a6ee79a | ||
|
|
05c684d757 | ||
|
|
2838020580 | ||
|
|
9b34ae2db4 | ||
|
|
f8010a20eb | ||
|
|
917edb3413 | ||
|
|
10425ede34 | ||
|
|
e4b40a8fa0 | ||
|
|
0b8ab4b54b | ||
|
|
49239e0e08 | ||
|
|
aec2a30445 | ||
|
|
c8915ca964 | ||
|
|
a715eddd06 | ||
|
|
2f9c235b41 | ||
|
|
cc4d8838eb | ||
|
|
fa0a77f09f | ||
|
|
fd6a7b73d4 | ||
|
|
bf0848d60b | ||
|
|
e06fac2bb7 | ||
|
|
bec61427a0 | ||
|
|
5fae7b2eb0 | ||
|
|
2eebdfe16a | ||
|
|
9cd3544d59 | ||
|
|
de4d14fee3 | ||
|
|
f29c568381 | ||
|
|
af3f557055 | ||
|
|
b894842736 | ||
|
|
e190029e1f | ||
|
|
e4940a8050 | ||
|
|
617c95ebc4 | ||
|
|
1cdd428bcc | ||
|
|
71ac719aee | ||
|
|
4621e6cc9f | ||
|
|
66087f83e1 | ||
|
|
25f9330491 | ||
|
|
14b1e0d33b | ||
|
|
83ccb33fd3 | ||
|
|
05bcf543ba | ||
|
|
7cd063bb5d |
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
171
.github/workflows/test-migrations.yml
vendored
Normal file
171
.github/workflows/test-migrations.yml
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
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())
|
||||
"
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Some Principles
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.9.5"
|
||||
version = "4.9.6"
|
||||
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.11.18",
|
||||
"aiohttp>=3.13.4",
|
||||
"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>=44.0.3",
|
||||
"cryptography>=46.0.7",
|
||||
"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>=11.2.1",
|
||||
"pillow>=12.2.0",
|
||||
"psutil>=7.0.0",
|
||||
"pycryptodome>=3.22.0",
|
||||
"pydantic>2.0",
|
||||
@@ -39,6 +39,7 @@ 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",
|
||||
@@ -49,7 +50,7 @@ dependencies = [
|
||||
"pip>=25.1.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
"uv>=0.7.11",
|
||||
"uv>=0.11.6",
|
||||
"mypy>=1.16.0",
|
||||
"PyPDF2>=3.0.1",
|
||||
"python-docx>=1.1.0",
|
||||
@@ -60,11 +61,15 @@ dependencies = [
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"langchain-core>=1.2.28",
|
||||
"langsmith>=0.7.31",
|
||||
"python-multipart>=0.0.26",
|
||||
"Mako>=1.3.11",
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.7",
|
||||
"langbot-plugin==0.3.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
@@ -72,6 +77,7 @@ dependencies = [
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
@@ -111,12 +117,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/**"] }
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.2.0",
|
||||
"pytest>=8.4.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.11.9",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.5'
|
||||
__version__ = '4.9.6'
|
||||
|
||||
@@ -182,6 +182,88 @@ 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))
|
||||
@@ -193,6 +275,15 @@ 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()
|
||||
|
||||
@@ -268,7 +359,25 @@ class DingTalkClient:
|
||||
|
||||
message_data['Type'] = 'image'
|
||||
elif incoming_message.message_type == 'audio':
|
||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
||||
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['Type'] = 'audio'
|
||||
elif incoming_message.message_type == 'file':
|
||||
|
||||
@@ -47,6 +47,22 @@ 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]:
|
||||
"""
|
||||
允许通过属性访问数据中的任意字段。
|
||||
|
||||
@@ -71,6 +71,11 @@ 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
|
||||
|
||||
@@ -214,11 +219,17 @@ 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():
|
||||
if now - session.last_access > self.ttl:
|
||||
# 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:
|
||||
expired.append(stream_id)
|
||||
|
||||
for stream_id in expired:
|
||||
@@ -228,6 +239,9 @@ 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:
|
||||
@@ -592,6 +606,120 @@ 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
|
||||
|
||||
|
||||
@@ -903,35 +1031,38 @@ 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} 对应的会话')
|
||||
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())
|
||||
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
|
||||
流式消息 ID
|
||||
"""
|
||||
return self.get('stream_id', '')
|
||||
|
||||
@property
|
||||
def quote(self):
|
||||
"""
|
||||
引用消息信息(群聊中用户引用其他消息时返回)
|
||||
"""
|
||||
return self.get('quote', {})
|
||||
|
||||
@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
|
||||
|
||||
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
|
||||
class RerankModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={
|
||||
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
|
||||
}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
|
||||
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
|
||||
return self.success(data={'model': model})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
|
||||
@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
provider['rerank_count'] = counts['rerank_count']
|
||||
return self.success(data={'providers': providers})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
provider['rerank_count'] = counts['rerank_count']
|
||||
return self.success(data={'provider': provider})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
@@ -43,3 +45,12 @@ 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))
|
||||
|
||||
@@ -105,23 +105,24 @@ class HTTPController:
|
||||
):
|
||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||
path += '.html'
|
||||
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('/')
|
||||
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
|
||||
|
||||
# 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
|
||||
# Fallback to index.html for SPA client-side routing
|
||||
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'
|
||||
|
||||
@@ -367,3 +367,162 @@ class EmbeddingModelsService:
|
||||
input_text=['Hello, world!'],
|
||||
extra_args={},
|
||||
)
|
||||
|
||||
|
||||
class RerankModelsService:
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_rerank_models(self) -> list[dict]:
|
||||
"""Get all rerank models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||
models = result.all()
|
||||
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
models_list = []
|
||||
for model in models:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
models_list.append(model_dict)
|
||||
|
||||
return models_list
|
||||
|
||||
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
"""Get rerank models by provider UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||
|
||||
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||
"""Create a new rerank model"""
|
||||
if not preserve_uuid:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
|
||||
)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||
persistence_model.RerankModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_rerank_model(self, model_uuid: str) -> dict | None:
|
||||
"""Get a single rerank model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
)
|
||||
model = result.first()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Update an existing rerank model"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
if provider_data.get('uuid'):
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
.values(**model_data)
|
||||
)
|
||||
|
||||
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||
persistence_model.RerankModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||
|
||||
async def delete_rerank_model(self, model_uuid: str) -> None:
|
||||
"""Delete a rerank model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
)
|
||||
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||
|
||||
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test a rerank model"""
|
||||
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
for model in self.ap.model_mgr.rerank_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_rerank_model = model
|
||||
break
|
||||
if runtime_rerank_model is None:
|
||||
raise Exception('model not found')
|
||||
else:
|
||||
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
|
||||
|
||||
await runtime_rerank_model.provider.invoke_rerank(
|
||||
model=runtime_rerank_model,
|
||||
query='What is artificial intelligence?',
|
||||
documents=[
|
||||
'Artificial intelligence is a branch of computer science.',
|
||||
'The weather is nice today.',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1224,30 +1224,83 @@ class MonitoringService:
|
||||
"""
|
||||
import json
|
||||
|
||||
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,
|
||||
}
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
||||
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)
|
||||
)
|
||||
existing_row = existing_result.first()
|
||||
|
||||
return record_id
|
||||
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
|
||||
|
||||
async def get_feedback_stats(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
@@ -97,6 +98,14 @@ class ModelProviderService:
|
||||
if embedding_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
if rerank_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
@@ -121,7 +130,14 @@ class ModelProviderService:
|
||||
)
|
||||
embedding_count = embedding_result.scalar() or 0
|
||||
|
||||
return {'llm_count': llm_count, 'embedding_count': embedding_count}
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
|
||||
)
|
||||
rerank_count = rerank_result.scalar() or 0
|
||||
|
||||
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
|
||||
|
||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||
"""Find existing provider or create new one"""
|
||||
@@ -164,3 +180,66 @@ 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}
|
||||
|
||||
@@ -179,7 +179,7 @@ class SpaceService:
|
||||
space_url = space_config['url']
|
||||
|
||||
session = httpclient.get_session()
|
||||
async with session.get(f'{space_url}/api/v1/models') as response:
|
||||
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to get models: {await response.text()}')
|
||||
data = await response.json()
|
||||
|
||||
@@ -65,8 +65,8 @@ class UserService:
|
||||
|
||||
user_obj = result_list[0]
|
||||
|
||||
# Check if this is a Space account
|
||||
if user_obj.account_type == 'space':
|
||||
# Check if this user has a local password set
|
||||
if not user_obj.password:
|
||||
raise ValueError('请使用 Space 账户登录')
|
||||
|
||||
ph = argon2.PasswordHasher()
|
||||
@@ -108,9 +108,8 @@ class UserService:
|
||||
if user_obj is None:
|
||||
raise ValueError('User not found')
|
||||
|
||||
# Space accounts cannot change password locally
|
||||
if user_obj.account_type == 'space':
|
||||
raise ValueError('Space account cannot change password locally')
|
||||
if not user_obj.password:
|
||||
raise ValueError('No local password set, please set a password first')
|
||||
|
||||
ph.verify(user_obj.password, current_password)
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ class Application:
|
||||
|
||||
embedding_models_service: model_service.EmbeddingModelsService = None
|
||||
|
||||
rerank_models_service: model_service.RerankModelsService = None
|
||||
|
||||
provider_service: provider_service.ModelProviderService = None
|
||||
|
||||
pipeline_service: pipeline_service.PipelineService = None
|
||||
|
||||
@@ -61,6 +61,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
|
||||
ap.embedding_models_service = embedding_models_service_inst
|
||||
|
||||
rerank_models_service_inst = model_service.RerankModelsService(ap)
|
||||
ap.rerank_models_service = rerank_models_service_inst
|
||||
|
||||
provider_service_inst = provider_service.ModelProviderService(ap)
|
||||
ap.provider_service = provider_service_inst
|
||||
|
||||
|
||||
@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
|
||||
class RerankModel(Base):
|
||||
"""Rerank model"""
|
||||
|
||||
__tablename__ = 'rerank_models'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
0
src/langbot/pkg/persistence/alembic/__init__.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
51
src/langbot/pkg/persistence/alembic/env.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""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()
|
||||
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
24
src/langbot/pkg/persistence/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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
|
||||
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
62
src/langbot/pkg/persistence/alembic/versions/0002_sample.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add rerank_models table
|
||||
|
||||
Revision ID: 0003_add_rerank_models
|
||||
Revises: 0002_sample
|
||||
Create Date: 2026-04-19
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0003_add_rerank_models'
|
||||
down_revision = '0002_sample'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if table already exists (may have been created by create_all())
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'rerank_models' not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
'rerank_models',
|
||||
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
|
||||
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('rerank_models')
|
||||
150
src/langbot/pkg/persistence/alembic_runner.py
Normal file
150
src/langbot/pkg/persistence/alembic_runner.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
@@ -76,6 +76,9 @@ 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):
|
||||
@@ -135,6 +138,28 @@ 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)
|
||||
|
||||
@@ -297,6 +297,9 @@ 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}')
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ 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:
|
||||
@@ -172,6 +171,15 @@ 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
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ 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(),
|
||||
|
||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||
else:
|
||||
# 回退到原有简单逻辑
|
||||
if event.content:
|
||||
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||
if event.content and event.type != 'audio':
|
||||
text_content = event.content.replace('@' + bot_name, '')
|
||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||
if event.picture:
|
||||
@@ -81,7 +82,38 @@ 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:
|
||||
yiri_msg_list.append(platform_message.Voice(base64=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"]}'))
|
||||
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
|
||||
@@ -709,21 +709,29 @@ 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_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,
|
||||
)
|
||||
)
|
||||
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}'))
|
||||
|
||||
if event.event.message.chat_type == 'p2p':
|
||||
return platform_events.FriendMessage(
|
||||
@@ -779,6 +787,13 @@ 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 # 商店应用用到
|
||||
@@ -825,6 +840,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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,
|
||||
@@ -832,6 +852,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
stream_id=monitoring_msg_id,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@@ -870,6 +891,8 @@ 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,
|
||||
@@ -1010,6 +1033,22 @@ 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输出,创建卡片......')
|
||||
@@ -1249,6 +1288,18 @@ 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(
|
||||
@@ -1559,6 +1610,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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,
|
||||
@@ -1566,6 +1622,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
stream_id=monitoring_msg_id,
|
||||
source_platform_object=data,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
@@ -126,6 +127,107 @@ 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
|
||||
)
|
||||
@@ -192,6 +294,8 @@ 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)
|
||||
@@ -228,8 +332,9 @@ 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,
|
||||
@@ -321,6 +426,23 @@ 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:
|
||||
@@ -328,6 +450,9 @@ 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
|
||||
@@ -343,6 +468,11 @@ 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}'
|
||||
@@ -356,7 +486,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
stream_id=stream_id,
|
||||
stream_id=monitoring_msg_id or stream_id,
|
||||
source_platform_object=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import sqlalchemy
|
||||
import traceback
|
||||
|
||||
from . import requester
|
||||
from .requesters import litellmchat
|
||||
from ...core import app
|
||||
from ...discover import engine
|
||||
from . import token
|
||||
from ...entity.persistence import model as persistence_model
|
||||
from ...entity.errors import provider as provider_errors
|
||||
from async_lru import alru_cache
|
||||
|
||||
|
||||
class ModelManager:
|
||||
@@ -24,6 +24,8 @@ class ModelManager:
|
||||
|
||||
embedding_models: list[requester.RuntimeEmbeddingModel]
|
||||
|
||||
rerank_models: list[requester.RuntimeRerankModel]
|
||||
|
||||
requester_components: list[engine.Component]
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
|
||||
@@ -32,6 +34,7 @@ class ModelManager:
|
||||
self.ap = ap
|
||||
self.llm_models = []
|
||||
self.embedding_models = []
|
||||
self.rerank_models = []
|
||||
self.requester_components = []
|
||||
self.requester_dict = {}
|
||||
|
||||
@@ -40,6 +43,13 @@ class ModelManager:
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
||||
for component in self.requester_components:
|
||||
# Skip components that use litellm_provider (they will use litellmchat.py instead)
|
||||
if component.spec.get('litellm_provider'):
|
||||
self.ap.logger.debug(
|
||||
f'Skipping Python class loading for {component.metadata.name} '
|
||||
f'(uses litellm_provider={component.spec.get("litellm_provider")})'
|
||||
)
|
||||
continue
|
||||
requester_dict[component.metadata.name] = component.get_python_component_class()
|
||||
|
||||
self.requester_dict = requester_dict
|
||||
@@ -64,8 +74,7 @@ class ModelManager:
|
||||
|
||||
self.llm_models = []
|
||||
self.embedding_models = []
|
||||
|
||||
# Load all providers first
|
||||
self.rerank_models = []
|
||||
self.provider_dict = {}
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
@@ -110,6 +119,22 @@ class ModelManager:
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
# Load rerank models
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||
rerank_models = result.all()
|
||||
for rerank_model in rerank_models:
|
||||
try:
|
||||
provider = self.provider_dict.get(rerank_model.provider_uuid)
|
||||
if provider is None:
|
||||
self.ap.logger.warning(
|
||||
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
|
||||
)
|
||||
continue
|
||||
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
|
||||
self.rerank_models.append(runtime_rerank_model)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
async def sync_new_models_from_space(self):
|
||||
"""Sync models from Space"""
|
||||
space_model_provider = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -212,6 +237,26 @@ class ModelManager:
|
||||
|
||||
return runtime_embedding_model
|
||||
|
||||
async def init_temporary_runtime_rerank_model(
|
||||
self,
|
||||
model_info: dict,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
"""Initialize runtime rerank model from dict (for testing)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
runtime_provider = await self.load_provider(provider_info)
|
||||
|
||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
||||
model_entity=persistence_model.RerankModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid='',
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
),
|
||||
provider=runtime_provider,
|
||||
)
|
||||
|
||||
return runtime_rerank_model
|
||||
|
||||
async def load_provider(
|
||||
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
|
||||
) -> requester.RuntimeProvider:
|
||||
@@ -223,12 +268,34 @@ class ModelManager:
|
||||
else:
|
||||
provider_entity = provider_info
|
||||
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
# Get requester manifest to check for litellm_provider
|
||||
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||
|
||||
# Build config from base_url
|
||||
config = {'base_url': provider_entity.base_url}
|
||||
|
||||
# Check if requester manifest specifies litellm_provider
|
||||
if requester_manifest and requester_manifest.spec.get('litellm_provider'):
|
||||
# Use unified LiteLLMRequester with provider prefix
|
||||
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
|
||||
config['custom_llm_provider'] = requester_manifest.spec['litellm_provider']
|
||||
requester_inst = litellmchat.LiteLLMRequester(
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Using LiteLLMRequester for {provider_entity.requester} '
|
||||
f'with custom_llm_provider={config["custom_llm_provider"]}'
|
||||
)
|
||||
else:
|
||||
# Use original requester class (for backward compatibility)
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap, config={'base_url': provider_entity.base_url}
|
||||
)
|
||||
await requester_inst.initialize()
|
||||
|
||||
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
||||
@@ -268,6 +335,9 @@ class ModelManager:
|
||||
for model in self.embedding_models:
|
||||
if model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_runtime_provider
|
||||
for model in self.rerank_models:
|
||||
if model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_runtime_provider
|
||||
|
||||
# update ref in provider dict
|
||||
self.provider_dict[provider_uuid] = new_runtime_provider
|
||||
@@ -304,6 +374,22 @@ class ModelManager:
|
||||
|
||||
return runtime_embedding_model
|
||||
|
||||
async def load_rerank_model_with_provider(
|
||||
self,
|
||||
model_info: persistence_model.RerankModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
"""Load rerank model with provider info"""
|
||||
if isinstance(model_info, sqlalchemy.Row):
|
||||
model_info = persistence_model.RerankModel(**model_info._mapping)
|
||||
|
||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
||||
model_entity=model_info,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return runtime_rerank_model
|
||||
|
||||
async def load_llm_model(self, model_info: dict):
|
||||
"""Load LLM model from dict (with provider info)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
@@ -351,7 +437,6 @@ class ModelManager:
|
||||
|
||||
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
||||
|
||||
@alru_cache(ttl=60 * 5)
|
||||
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
||||
"""Get LLM model by uuid"""
|
||||
for model in self.llm_models:
|
||||
@@ -359,7 +444,6 @@ class ModelManager:
|
||||
return model
|
||||
raise ValueError(f'LLM model {uuid} not found')
|
||||
|
||||
@alru_cache(ttl=60 * 5)
|
||||
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
||||
"""Get embedding model by uuid"""
|
||||
for model in self.embedding_models:
|
||||
@@ -367,6 +451,13 @@ class ModelManager:
|
||||
return model
|
||||
raise ValueError(f'Embedding model {uuid} not found')
|
||||
|
||||
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
|
||||
"""Get rerank model by uuid"""
|
||||
for model in self.rerank_models:
|
||||
if model.model_entity.uuid == uuid:
|
||||
return model
|
||||
raise ValueError(f'Rerank model {uuid} not found')
|
||||
|
||||
async def remove_llm_model(self, model_uuid: str):
|
||||
"""Remove LLM model"""
|
||||
for model in self.llm_models:
|
||||
@@ -381,6 +472,13 @@ class ModelManager:
|
||||
self.embedding_models.remove(model)
|
||||
return
|
||||
|
||||
async def remove_rerank_model(self, model_uuid: str):
|
||||
"""Remove rerank model"""
|
||||
for model in self.rerank_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
self.rerank_models.remove(model)
|
||||
return
|
||||
|
||||
def get_available_requesters_info(self, model_type: str) -> list[dict]:
|
||||
"""Get all available requesters"""
|
||||
if model_type != '':
|
||||
|
||||
@@ -67,8 +67,8 @@ class RuntimeProvider:
|
||||
if isinstance(result, tuple):
|
||||
msg, usage_info = result
|
||||
if usage_info:
|
||||
input_tokens = usage_info.get('input_tokens', 0)
|
||||
output_tokens = usage_info.get('output_tokens', 0)
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
return msg
|
||||
else:
|
||||
return result
|
||||
@@ -128,7 +128,6 @@ class RuntimeProvider:
|
||||
start_time = time.time()
|
||||
status = 'success'
|
||||
error_message = None
|
||||
# Note: Stream doesn't easily provide token counts, set to 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
|
||||
@@ -143,6 +142,15 @@ class RuntimeProvider:
|
||||
remove_think=remove_think,
|
||||
):
|
||||
yield chunk
|
||||
# Extract usage from stream if available (stored by LiteLLM requester)
|
||||
if query:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
if '_stream_usage' in query.variables:
|
||||
usage_info = query.variables['_stream_usage']
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
del query.variables['_stream_usage']
|
||||
except Exception as e:
|
||||
status = 'error'
|
||||
error_message = str(e)
|
||||
@@ -247,6 +255,40 @@ class RuntimeProvider:
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
||||
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: RuntimeRerankModel,
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[dict]:
|
||||
"""Bridge method for invoking rerank with monitoring"""
|
||||
start_time = time.time()
|
||||
status = 'success'
|
||||
|
||||
try:
|
||||
result = await self.requester.invoke_rerank(
|
||||
model=model,
|
||||
query=query,
|
||||
documents=documents,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
status = 'error'
|
||||
raise
|
||||
finally:
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
self.requester.ap.logger.debug(
|
||||
f'[Rerank] model={model.model_entity.name} docs={len(documents)} '
|
||||
f'duration={duration_ms}ms status={status}'
|
||||
)
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record rerank call: {monitor_err}')
|
||||
|
||||
|
||||
class RuntimeLLMModel:
|
||||
"""运行时模型"""
|
||||
@@ -284,6 +326,24 @@ class RuntimeEmbeddingModel:
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class RuntimeRerankModel:
|
||||
"""运行时 Rerank 模型"""
|
||||
|
||||
model_entity: persistence_model.RerankModel
|
||||
"""模型数据"""
|
||||
|
||||
provider: RuntimeProvider
|
||||
"""提供商实例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_entity: persistence_model.RerankModel,
|
||||
provider: RuntimeProvider,
|
||||
):
|
||||
self.model_entity = model_entity
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
||||
"""Provider API请求器"""
|
||||
|
||||
@@ -303,6 +363,14 @@ 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,
|
||||
@@ -368,3 +436,23 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
||||
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
|
||||
"""
|
||||
pass
|
||||
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: RuntimeRerankModel,
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[dict]:
|
||||
"""调用 Rerank API
|
||||
|
||||
Args:
|
||||
model (RuntimeRerankModel): 使用的模型信息
|
||||
query (str): 查询文本
|
||||
documents (typing.List[str]): 待重排序的文档列表
|
||||
extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.
|
||||
|
||||
Returns:
|
||||
typing.List[dict]: [{"index": int, "relevance_score": float}, ...]
|
||||
"""
|
||||
raise NotImplementedError('This requester does not support rerank')
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -24,6 +24,7 @@ spec:
|
||||
default: 120
|
||||
support_type:
|
||||
- llm
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -31,6 +31,192 @@ 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,
|
||||
@@ -429,3 +615,88 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
|
||||
except openai.APIError as e:
|
||||
raise errors.RequesterError(f'请求错误: {e.message}')
|
||||
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: requester.RuntimeRerankModel,
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[dict]:
|
||||
"""Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible)
|
||||
|
||||
Supports extra_args from model.extra_args:
|
||||
- rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
|
||||
- rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank")
|
||||
- Any other fields are merged into the request payload.
|
||||
"""
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
|
||||
timeout = self.requester_cfg.get('timeout', 120)
|
||||
|
||||
merged_args = {}
|
||||
if model.model_entity.extra_args:
|
||||
merged_args.update(model.model_entity.extra_args)
|
||||
if extra_args:
|
||||
merged_args.update(extra_args)
|
||||
|
||||
rerank_url = merged_args.pop('rerank_url', None)
|
||||
rerank_path = merged_args.pop('rerank_path', 'rerank')
|
||||
if not rerank_url:
|
||||
rerank_url = f'{base_url}/{rerank_path}'
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
}
|
||||
|
||||
payload = {
|
||||
'model': model.model_entity.name,
|
||||
'query': query,
|
||||
'documents': documents[:64],
|
||||
'top_n': min(len(documents), 64),
|
||||
}
|
||||
|
||||
if merged_args:
|
||||
payload.update(merged_args)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
|
||||
resp = await client.post(rerank_url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = self._parse_rerank_response(data)
|
||||
|
||||
if results:
|
||||
scores = [r.get('relevance_score', 0.0) for r in results]
|
||||
min_score = min(scores)
|
||||
max_score = max(scores)
|
||||
if max_score - min_score > 1e-6:
|
||||
for r in results:
|
||||
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
|
||||
|
||||
return results
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}')
|
||||
except httpx.TimeoutException:
|
||||
raise errors.RequesterError('Rerank request timed out')
|
||||
except Exception as e:
|
||||
raise errors.RequesterError(f'Rerank request error: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def _parse_rerank_response(data: dict) -> typing.List[dict]:
|
||||
"""Parse rerank response from various providers.
|
||||
|
||||
Handles:
|
||||
- Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]}
|
||||
- Voyage AI: {"data": [{"index", "relevance_score"}]}
|
||||
- DashScope: {"output": {"results": [{"index", "relevance_score"}]}}
|
||||
"""
|
||||
if 'results' in data:
|
||||
return data['results']
|
||||
if 'data' in data:
|
||||
return data['data']
|
||||
if 'output' in data and isinstance(data['output'], dict):
|
||||
return data['output'].get('results', [])
|
||||
return []
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
|
||||
8
src/langbot/pkg/provider/modelmgr/requesters/chroma.svg
Normal file
8
src/langbot/pkg/provider/modelmgr/requesters/chroma.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128" id="Chroma--Streamline-Svg-Logos" height="128" width="128">
|
||||
<desc>
|
||||
Chroma Streamline Icon: https://streamlinehq.com
|
||||
</desc>
|
||||
<path fill="#ffde2d" d="M84.88839999999999 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333 -23.0732 0 -41.77773333333333 17.956266666666664 -41.77773333333333 40.10653333333333 0 22.150266666666667 18.70453333333333 40.10653333333333 41.77773333333333 40.10653333333333Z" stroke-width="1.3333"></path>
|
||||
<path fill="#327eff" d="M43.111066666666666 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333C20.037866666666666 23.8936 1.3333333333333333 41.849866666666664 1.3333333333333333 64.00013333333334 1.3333333333333333 86.15039999999999 20.037866666666666 104.10666666666665 43.111066666666666 104.10666666666665Z" stroke-width="1.3333"></path>
|
||||
<path fill="#ff6446" d="M84.88866666666667 64.00013333333334c0 22.150399999999998 -18.704666666666665 40.10626666666666 -41.778 40.10626666666666V64.00013333333334h41.778Zm-41.778 0c0 -22.150266666666667 18.70453333333333 -40.10653333333333 41.778 -40.10653333333333v40.10653333333333H43.11066666666666Z" stroke-width="1.3333"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
61
src/langbot/pkg/provider/modelmgr/requesters/chromaembed.py
Normal file
61
src/langbot/pkg/provider/modelmgr/requesters/chromaembed.py
Normal file
@@ -0,0 +1,61 @@
|
||||
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)}')
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
1
src/langbot/pkg/provider/modelmgr/requesters/cohere.svg
Normal file
1
src/langbot/pkg/provider/modelmgr/requesters/cohere.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>
|
||||
|
After Width: | Height: | Size: 769 B |
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: cohere-rerank
|
||||
label:
|
||||
en_US: Cohere
|
||||
zh_Hans: Cohere
|
||||
icon: cohere.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.cohere.com/v2
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
support_type:
|
||||
- rerank
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
path: ./chatcmpl.py
|
||||
attr: OpenAIChatCompletions
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import httpx
|
||||
|
||||
from . import chatcmpl
|
||||
|
||||
@@ -20,6 +21,68 @@ 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,
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
1
src/langbot/pkg/provider/modelmgr/requesters/jina.svg
Normal file
1
src/langbot/pkg/provider/modelmgr/requesters/jina.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Jina</title><path d="M6.608 21.416a4.608 4.608 0 100-9.217 4.608 4.608 0 000 9.217zM20.894 2.015c.614 0 1.106.492 1.106 1.106v9.002c0 5.13-4.148 9.309-9.217 9.37v-9.355l-.03-9.032c0-.614.491-1.106 1.106-1.106h7.158l-.123.015z"></path></svg>
|
||||
|
After Width: | Height: | Size: 404 B |
31
src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml
Normal file
31
src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: jina-rerank
|
||||
label:
|
||||
en_US: Jina
|
||||
zh_Hans: Jina
|
||||
icon: jina.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.jina.ai/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
support_type:
|
||||
- rerank
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
path: ./chatcmpl.py
|
||||
attr: OpenAIChatCompletions
|
||||
397
src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
Normal file
397
src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""LiteLLM unified requester for chat, embedding, and rerank."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import litellm
|
||||
from litellm import acompletion, aembedding, arerank
|
||||
|
||||
from .. import errors, requester
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': '',
|
||||
'timeout': 120,
|
||||
'custom_llm_provider': '',
|
||||
'drop_params': False,
|
||||
'num_retries': 0,
|
||||
'api_version': '',
|
||||
}
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize LiteLLM client settings."""
|
||||
# LiteLLM doesn't require explicit client initialization
|
||||
# Configuration is passed per-request via litellm params
|
||||
pass
|
||||
|
||||
def _build_litellm_model_name(self, model_name: str, custom_llm_provider: str | None = None) -> str:
|
||||
"""Build LiteLLM model name with provider prefix if needed."""
|
||||
provider = custom_llm_provider or self.requester_cfg.get('custom_llm_provider', '')
|
||||
if provider:
|
||||
# LiteLLM format: provider/model_name
|
||||
return f'{provider}/{model_name}'
|
||||
# If no custom provider, assume model_name already includes prefix or is OpenAI-compatible
|
||||
return model_name
|
||||
|
||||
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]:
|
||||
"""Convert LangBot messages to LiteLLM/OpenAI format."""
|
||||
req_messages = []
|
||||
for m in messages:
|
||||
msg_dict = m.dict(exclude_none=True)
|
||||
content = msg_dict.get('content')
|
||||
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
|
||||
req_messages.append(msg_dict)
|
||||
|
||||
return req_messages
|
||||
|
||||
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
|
||||
"""Process thinking/reasoning content.
|
||||
|
||||
Args:
|
||||
content: The main content from response
|
||||
reasoning_content: Separate reasoning content from model
|
||||
remove_think: If True, remove thinking markers; if False, preserve them
|
||||
|
||||
Returns:
|
||||
Processed content string
|
||||
"""
|
||||
# Extract and handle thinking tags
|
||||
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
|
||||
import re
|
||||
|
||||
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
|
||||
|
||||
if remove_think:
|
||||
# Remove thinking tags and their content from output
|
||||
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||
# else: preserve thinking content as-is
|
||||
|
||||
# Handle separate reasoning_content field
|
||||
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
|
||||
# because it's typically internal model reasoning, not user-visible thinking
|
||||
return content or ''
|
||||
|
||||
def _extract_usage(self, response) -> dict:
|
||||
"""Extract usage info from LiteLLM response."""
|
||||
usage = response.usage
|
||||
return {
|
||||
'prompt_tokens': usage.prompt_tokens or 0,
|
||||
'completion_tokens': usage.completion_tokens or 0,
|
||||
'total_tokens': usage.total_tokens or 0,
|
||||
}
|
||||
|
||||
def _build_common_args(self, args: dict, include_retry_params: bool = True) -> dict:
|
||||
"""Apply common requester config to args dict."""
|
||||
if self.requester_cfg.get('base_url'):
|
||||
args['api_base'] = self.requester_cfg['base_url']
|
||||
if self.requester_cfg.get('timeout'):
|
||||
args['timeout'] = self.requester_cfg['timeout']
|
||||
if include_retry_params:
|
||||
if self.requester_cfg.get('drop_params'):
|
||||
args['drop_params'] = self.requester_cfg['drop_params']
|
||||
if self.requester_cfg.get('num_retries'):
|
||||
args['num_retries'] = self.requester_cfg['num_retries']
|
||||
if self.requester_cfg.get('api_version'):
|
||||
args['api_version'] = self.requester_cfg['api_version']
|
||||
return args
|
||||
|
||||
def _handle_litellm_error(self, e: Exception) -> None:
|
||||
"""Convert LiteLLM exceptions to RequesterError. Never returns, always raises."""
|
||||
# Check more specific exceptions first (they inherit from base exceptions)
|
||||
if isinstance(e, litellm.ContextWindowExceededError):
|
||||
raise errors.RequesterError(f'上下文长度超限: {str(e)}')
|
||||
if isinstance(e, litellm.BadRequestError):
|
||||
raise errors.RequesterError(f'请求参数错误: {str(e)}')
|
||||
if isinstance(e, litellm.AuthenticationError):
|
||||
raise errors.RequesterError(f'API key 无效: {str(e)}')
|
||||
if isinstance(e, litellm.NotFoundError):
|
||||
raise errors.RequesterError(f'模型或路径无效: {str(e)}')
|
||||
if isinstance(e, litellm.RateLimitError):
|
||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}')
|
||||
if isinstance(e, litellm.Timeout):
|
||||
raise errors.RequesterError(f'请求超时: {str(e)}')
|
||||
if isinstance(e, litellm.APIConnectionError):
|
||||
raise errors.RequesterError(f'连接错误: {str(e)}')
|
||||
if isinstance(e, litellm.APIError):
|
||||
raise errors.RequesterError(f'API 错误: {str(e)}')
|
||||
raise errors.RequesterError(f'未知错误: {str(e)}')
|
||||
|
||||
async def _build_completion_args(
|
||||
self,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
|
||||
req_messages = self._convert_messages(messages)
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'messages': req_messages,
|
||||
'api_key': api_key,
|
||||
}
|
||||
if stream:
|
||||
args['stream'] = True
|
||||
args['stream_options'] = {'include_usage': True}
|
||||
self._build_common_args(args)
|
||||
args.update(extra_args)
|
||||
|
||||
if funcs:
|
||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
||||
if tools:
|
||||
args['tools'] = tools
|
||||
|
||||
return args
|
||||
|
||||
async def invoke_llm(
|
||||
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,
|
||||
) -> tuple[provider_message.Message, dict]:
|
||||
"""Invoke LLM and return message with usage info."""
|
||||
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
|
||||
message_data = response.choices[0].message.model_dump()
|
||||
if 'role' not in message_data or message_data['role'] is None:
|
||||
message_data['role'] = 'assistant'
|
||||
|
||||
content = message_data.get('content', '')
|
||||
reasoning_content = message_data.get('reasoning_content', None)
|
||||
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
|
||||
|
||||
if 'reasoning_content' in message_data:
|
||||
del message_data['reasoning_content']
|
||||
|
||||
message = provider_message.Message(**message_data)
|
||||
usage_info = self._extract_usage(response)
|
||||
|
||||
return message, usage_info
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
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:
|
||||
"""Invoke LLM streaming and yield chunks."""
|
||||
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=True)
|
||||
|
||||
chunk_idx = 0
|
||||
role = 'assistant'
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
async for chunk in response:
|
||||
# Check for usage chunk (final chunk with stream_options include_usage)
|
||||
if hasattr(chunk, 'usage') and chunk.usage and (not hasattr(chunk, 'choices') or not chunk.choices):
|
||||
usage_info = {
|
||||
'prompt_tokens': chunk.usage.prompt_tokens or 0,
|
||||
'completion_tokens': chunk.usage.completion_tokens or 0,
|
||||
'total_tokens': chunk.usage.total_tokens or 0,
|
||||
}
|
||||
if query:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
query.variables['_stream_usage'] = usage_info
|
||||
continue
|
||||
|
||||
if not hasattr(chunk, 'choices') or not chunk.choices:
|
||||
continue
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
|
||||
finish_reason = getattr(choice, 'finish_reason', None)
|
||||
|
||||
if 'role' in delta and delta['role']:
|
||||
role = delta['role']
|
||||
|
||||
delta_content = delta.get('content', '')
|
||||
reasoning_content = delta.get('reasoning_content', '')
|
||||
|
||||
if reasoning_content:
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
chunk_data = {
|
||||
'role': role,
|
||||
'content': delta_content if delta_content else None,
|
||||
'tool_calls': delta.get('tool_calls'),
|
||||
'is_final': bool(finish_reason),
|
||||
}
|
||||
|
||||
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 Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
input_text: list[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> tuple[list[list[float]], dict]:
|
||||
"""Invoke embedding and return vectors with usage info."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'input': input_text,
|
||||
'api_key': api_key,
|
||||
}
|
||||
self._build_common_args(args, include_retry_params=False)
|
||||
|
||||
if model.model_entity.extra_args:
|
||||
args.update(model.model_entity.extra_args)
|
||||
|
||||
args.update(extra_args)
|
||||
|
||||
try:
|
||||
response = await aembedding(**args)
|
||||
|
||||
embeddings = [d.embedding for d in response.data]
|
||||
usage_info = self._extract_usage(response)
|
||||
|
||||
return embeddings, usage_info
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: requester.RuntimeRerankModel,
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[dict]:
|
||||
"""Invoke rerank and return relevance scores."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'query': query,
|
||||
'documents': documents,
|
||||
'api_key': api_key,
|
||||
'top_n': min(len(documents), 64),
|
||||
}
|
||||
self._build_common_args(args, include_retry_params=False)
|
||||
|
||||
if model.model_entity.extra_args:
|
||||
args.update(model.model_entity.extra_args)
|
||||
|
||||
args.update(extra_args)
|
||||
|
||||
try:
|
||||
response = await arerank(**args)
|
||||
|
||||
results = []
|
||||
for r in response.results:
|
||||
results.append(
|
||||
{
|
||||
'index': r.get('index', 0),
|
||||
'relevance_score': r.get('relevance_score', 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
if results:
|
||||
scores = [r['relevance_score'] for r in results]
|
||||
min_score = min(scores)
|
||||
max_score = max(scores)
|
||||
if max_score - min_score > 1e-6:
|
||||
for r in results:
|
||||
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
"""Scan models supported by the provider."""
|
||||
import httpx
|
||||
|
||||
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
|
||||
timeout = self.requester_cfg.get('timeout', 120)
|
||||
|
||||
if not base_url:
|
||||
raise errors.RequesterError('Base URL required for model scanning')
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
models_url = f'{base_url}/models'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=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
|
||||
|
||||
# Infer model type
|
||||
normalized_id = (model_id or '').lower()
|
||||
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
||||
model_type = 'embedding' if any(kw in normalized_id for kw in embedding_keywords) else 'llm'
|
||||
|
||||
models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': model_id,
|
||||
'type': model_type,
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
|
||||
|
||||
return {'models': models}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise errors.RequesterError(f'Model scan failed: {e.response.status_code}')
|
||||
except httpx.TimeoutException:
|
||||
raise errors.RequesterError('Model scan timeout')
|
||||
except Exception as e:
|
||||
raise errors.RequesterError(f'Model scan error: {str(e)}')
|
||||
@@ -0,0 +1,64 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: litellm-chat
|
||||
label:
|
||||
en_US: LiteLLM (Unified)
|
||||
zh_Hans: LiteLLM (统一请求器)
|
||||
icon: litellm.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
- name: custom_llm_provider
|
||||
label:
|
||||
en_US: Custom Provider
|
||||
zh_Hans: 自定义 Provider
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
en_US: Force provider type (e.g., anthropic, openai, gemini)
|
||||
zh_Hans: 强制指定 provider 类型(如 anthropic, openai, gemini)
|
||||
- name: drop_params
|
||||
label:
|
||||
en_US: Drop Unsupported Params
|
||||
zh_Hans: 丢弃不支持参数
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: num_retries
|
||||
label:
|
||||
en_US: Number of Retries
|
||||
zh_Hans: 重试次数
|
||||
type: integer
|
||||
required: false
|
||||
default: 0
|
||||
- name: api_version
|
||||
label:
|
||||
en_US: API Version
|
||||
zh_Hans: API 版本
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: unified
|
||||
execution:
|
||||
python:
|
||||
path: ./litellmchat.py
|
||||
attr: LiteLLMRequester
|
||||
@@ -31,6 +31,175 @@ 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,
|
||||
|
||||
@@ -8,6 +8,7 @@ import uuid
|
||||
import json
|
||||
|
||||
import ollama
|
||||
import httpx
|
||||
|
||||
from .. import errors, requester
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
@@ -31,6 +32,60 @@ 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,
|
||||
@@ -104,6 +159,21 @@ 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,
|
||||
@@ -113,14 +183,7 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
) -> provider_message.Message:
|
||||
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)
|
||||
req_messages = await self._prepare_messages(messages)
|
||||
try:
|
||||
return await self._closure(
|
||||
query=query,
|
||||
@@ -133,6 +196,109 @@ 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,
|
||||
|
||||
@@ -15,3 +15,11 @@ 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
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<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="#1E3A5F"/>
|
||||
<path d="M6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12" stroke="#4FC3F7" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12" stroke="#81D4FA" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="12" r="2" fill="#4FC3F7"/>
|
||||
<circle cx="6" cy="12" r="1.5" fill="#81D4FA"/>
|
||||
<circle cx="18" cy="12" r="1.5" fill="#4FC3F7"/>
|
||||
</svg>
|
||||
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 334.84 76.22">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M308.56,23.63c-5.04,0-9.73,1.43-13.73,3.88V1.08l-12.56,4.61v70h12.56v-3.35c4,2.46,8.71,3.88,13.73,3.88,14.49,0,26.29-11.79,26.29-26.29s-11.79-26.29-26.29-26.29h0ZM308.56,63.88c-6.87,0-12.57-4.98-13.73-11.51v-4.91c1.16-6.54,6.88-11.51,13.73-11.51,7.7,0,13.96,6.26,13.96,13.96s-6.26,13.96-13.96,13.96Z"></path>
|
||||
<path class="cls-1" d="M255.54,5.69v21.83c-4-2.46-8.71-3.88-13.73-3.88-14.49,0-26.29,11.79-26.29,26.29s11.79,26.29,26.29,26.29c5.04,0,9.73-1.43,13.73-3.88v3.35h12.56V1.08l-12.56,4.61ZM241.81,63.88c-7.7,0-13.96-6.26-13.96-13.96s6.26-13.96,13.96-13.96c6.87,0,12.57,4.98,13.73,11.51v4.91c-1.16,6.54-6.88,11.51-13.73,11.51Z"></path>
|
||||
<polygon class="cls-1" points="195.35 52.2 186.65 61.17 200.64 75.62 209.32 75.62 218.01 75.62 195.35 52.2"></polygon>
|
||||
<path class="cls-1" d="M167.14,4.59c.65,3.99.68,8.04.03,12.15-.03.17.16.3.31.21,3.82-2.21,7.82-3.69,12.01-4.33.12-.02.19-.13.17-.23-.68-4.13-.61-8.18-.03-12.16.02-.17-.16-.3-.31-.2-4.01,2.31-8.01,3.81-12.01,4.34-.12.01-.19.12-.17.23h0Z"></path>
|
||||
<path class="cls-1" d="M198.75,24.09l-19.07,19.72v-25.57c-4.49.67-8.7,2.11-12.56,4.57v52.83h12.56v-13.87l3.78-3.9.02.02,8.68-8.97-.02-.02,23.98-24.8h-17.37Z"></path>
|
||||
<path class="cls-1" d="M145.03,57.86c-2.56,4.45-7.17,7.2-12.13,7.2-5.96,0-11.3-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49-11.1-4.08h-.01ZM132.88,35.19h.03c5.96,0,11.3,3.96,13.32,9.85h-26.67c2.02-5.89,7.36-9.85,13.32-9.85Z"></path>
|
||||
<path class="cls-1" d="M75.92,65.07c-5.96,0-11.29-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49h0l-11.1-4.08c-2.56,4.45-7.17,7.2-12.13,7.2h-.01ZM75.92,35.19h.03c5.96,0,11.29,3.96,13.32,9.85h-26.67c2.03-5.89,7.36-9.85,13.32-9.85Z"></path>
|
||||
<path class="cls-1" d="M30.43,45.58l-10.2-1.91c-3.03-.56-4.98-2.25-4.98-4.33,0-1.5,1.61-4.35,7.68-4.35,5.53,0,9.36,3.5,10.25,6.26l10.9-4-.14-.42c-1.17-3.54-3.5-6.58-6.94-9.04-3.49-2.49-8.04-3.69-13.88-3.69s-10.98,1.5-14.78,4.34c-3.88,2.91-5.84,6.76-5.84,11.46,0,7.98,4.72,12.77,14.42,14.64l9.9,1.81c3.05.61,4.94,2.27,4.94,4.33,0,2.61-3.58,4.44-8.7,4.44-5.79,0-9.9-3.72-11.85-7.14L0,62.1l.14.39c1.3,3.8,3.89,7.07,7.7,9.71,3.78,2.6,8.65,3.95,14.51,3.98l.25.03c6.87,0,12.55-1.57,16.43-4.53,3.98-3.05,6-6.99,6-11.74,0-3.73-1.14-6.7-3.6-9.33-2.27-2.42-5.98-4.11-10.98-5.02h-.02Z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 569 B After Width: | Height: | Size: 2.7 KiB |
@@ -46,14 +46,15 @@ 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:
|
||||
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)
|
||||
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
|
||||
except Exception as e:
|
||||
from .. import errors
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Voyage</title><path d="M5.407 0v.066a.974.974 0 00-.048.245c-.011.11-.016.208-.016.295 0 .339.043.715.128 1.13.097.405.274.912.531 1.524l7.125 16.366L20.011 3.39c.161-.404.333-.846.515-1.327.182-.48.273-.966.273-1.458a1.406 1.406 0 00-.096-.54V0H24v.066c-.204.207-.45.578-.74 1.114-.29.535-.606 1.195-.949 1.982L13.095 24h-1.287L3.075 3.965c-.204-.47-.418-.923-.644-1.36-.214-.437-.418-.83-.61-1.18-.194-.36-.365-.66-.515-.9A5.666 5.666 0 001 .064V0h4.407z" fill="#012E33"></path></svg>
|
||||
|
After Width: | Height: | Size: 610 B |
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: voyageai-rerank
|
||||
label:
|
||||
en_US: Voyage AI
|
||||
zh_Hans: Voyage AI
|
||||
icon: voyageai.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.voyageai.com/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
support_type:
|
||||
- rerank
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
path: ./chatcmpl.py
|
||||
attr: OpenAIChatCompletions
|
||||
@@ -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', [])
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
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 (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
elif think_start and (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', [])
|
||||
if stream_think[0].get('thought'):
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
if stream_think and 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 stream_think[0].get('thought') == '' and not think_end:
|
||||
elif think_start and (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:
|
||||
|
||||
@@ -172,6 +172,45 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
if result:
|
||||
all_results.extend(result)
|
||||
|
||||
# Rerank step: re-score results using a rerank model if configured
|
||||
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||
rerank_model_uuid = local_agent_config.get('rerank-model', '')
|
||||
if rerank_model_uuid == '__none__':
|
||||
rerank_model_uuid = ''
|
||||
self.ap.logger.info(
|
||||
f'Rerank config: model_uuid={rerank_model_uuid!r}, '
|
||||
f'results={len(all_results)}, '
|
||||
f'local_agent_keys={list(local_agent_config.keys())}'
|
||||
)
|
||||
if all_results and rerank_model_uuid:
|
||||
try:
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
|
||||
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
|
||||
|
||||
doc_texts = []
|
||||
for entry in all_results:
|
||||
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
|
||||
doc_texts.append(text)
|
||||
|
||||
doc_texts_capped = doc_texts[:64]
|
||||
scores = await rerank_model.provider.invoke_rerank(
|
||||
model=rerank_model,
|
||||
query=user_message_text,
|
||||
documents=doc_texts_capped,
|
||||
)
|
||||
|
||||
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
|
||||
all_results = [all_results[i] for i in top_indices]
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})'
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Rerank failed, using original order: {e}')
|
||||
|
||||
final_user_message_text = ''
|
||||
|
||||
if all_results:
|
||||
|
||||
@@ -70,11 +70,12 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _process_stream_response(
|
||||
async def _process_response(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
|
||||
"""处理响应——支持流式格式和普通 JSON 格式"""
|
||||
full_content = ''
|
||||
full_text = ''
|
||||
chunk_idx = 0
|
||||
is_final = False
|
||||
message_idx = 0
|
||||
@@ -93,6 +94,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
else:
|
||||
chunk_str = str(raw_chunk)
|
||||
|
||||
full_text += chunk_str
|
||||
buffer += chunk_str
|
||||
|
||||
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
|
||||
@@ -115,7 +117,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
|
||||
if is_final or chunk_idx % 8 == 0:
|
||||
if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0):
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
@@ -142,6 +144,7 @@ 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
|
||||
@@ -156,6 +159,28 @@ 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(如果不存在)
|
||||
@@ -220,49 +245,22 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 调用webhook
|
||||
session = httpclient.get_session()
|
||||
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 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_stream_response(response):
|
||||
async for chunk in self._process_response(response):
|
||||
if is_stream:
|
||||
yield chunk
|
||||
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,
|
||||
)
|
||||
elif chunk.is_final:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=chunk.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)}')
|
||||
|
||||
@@ -57,41 +57,6 @@ class ToolManager:
|
||||
|
||||
return tools
|
||||
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""为anthropic生成函数列表
|
||||
|
||||
e.g.
|
||||
|
||||
[
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a given ticker symbol.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "The stock ticker symbol, e.g. AAPL for Apple Inc."
|
||||
}
|
||||
},
|
||||
"required": ["ticker"]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
function_schema = {
|
||||
'name': function.name,
|
||||
'description': function.description,
|
||||
'input_schema': function.parameters,
|
||||
}
|
||||
tools.append(function_schema)
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
|
||||
@@ -60,7 +60,16 @@ class TelemetryManager:
|
||||
except Exception:
|
||||
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
||||
|
||||
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
|
||||
for sfield in (
|
||||
'adapter',
|
||||
'runner',
|
||||
'runner_category',
|
||||
'model_name',
|
||||
'version',
|
||||
'edition',
|
||||
'error',
|
||||
'timestamp',
|
||||
):
|
||||
v = sanitized.get(sfield)
|
||||
sanitized[sfield] = '' if v is None else str(v)
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@ 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:
|
||||
@@ -22,17 +17,25 @@ 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')
|
||||
@@ -42,6 +45,8 @@ 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')
|
||||
@@ -60,9 +65,13 @@ 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.')
|
||||
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
"""Vector database implementations for LangBot."""
|
||||
|
||||
from .chroma import ChromaVectorDatabase
|
||||
from .qdrant import QdrantVectorDatabase
|
||||
from .seekdb import SeekDBVectorDatabase
|
||||
|
||||
__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
"content": "You are a helpful assistant."
|
||||
}
|
||||
],
|
||||
"knowledge-bases": []
|
||||
"knowledge-bases": [],
|
||||
"rerank-model": "",
|
||||
"rerank-top-k": 5
|
||||
},
|
||||
"dify-service-api": {
|
||||
"base-url": "https://api.dify.ai/v1",
|
||||
|
||||
@@ -104,6 +104,34 @@ stages:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: rerank-model
|
||||
label:
|
||||
en_US: Rerank Model
|
||||
zh_Hans: 重排序模型
|
||||
description:
|
||||
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
|
||||
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
|
||||
type: rerank-model-selector
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: knowledge-bases
|
||||
operator: neq
|
||||
value: []
|
||||
- name: rerank-top-k
|
||||
label:
|
||||
en_US: Rerank Top K
|
||||
zh_Hans: 重排序保留数量
|
||||
description:
|
||||
en_US: Number of top results to keep after reranking
|
||||
zh_Hans: 重排序后保留的最相关结果数量
|
||||
type: integer
|
||||
required: false
|
||||
default: 5
|
||||
show_if:
|
||||
field: rerank-model
|
||||
operator: neq
|
||||
value: ''
|
||||
- name: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
|
||||
328
tests/unit_tests/pipeline/test_n8nsvapi.py
Normal file
328
tests/unit_tests/pipeline/test_n8nsvapi.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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'
|
||||
1
tests/unit_tests/provider/__init__.py
Normal file
1
tests/unit_tests/provider/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Provider requester tests"""
|
||||
633
tests/unit_tests/provider/test_litellmchat.py
Normal file
633
tests/unit_tests/provider/test_litellmchat.py
Normal file
@@ -0,0 +1,633 @@
|
||||
"""
|
||||
Tests for LiteLLMRequester - unified requester for chat, embedding, and rerank.
|
||||
|
||||
These tests verify:
|
||||
- Parameter building and LiteLLM API calls
|
||||
- Response processing and usage extraction
|
||||
- Error handling and exception translation
|
||||
- Model name building with provider prefix
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
|
||||
import litellm
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters import litellmchat
|
||||
from langbot.pkg.provider.modelmgr import errors
|
||||
|
||||
|
||||
class MockRuntimeModel:
|
||||
"""Mock RuntimeLLMModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'gpt-4o', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class MockRuntimeEmbeddingModel:
|
||||
"""Mock RuntimeEmbeddingModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'text-embedding-3-small', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class MockRuntimeRerankModel:
|
||||
"""Mock RuntimeRerankModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'cohere/rerank-english-v3.0', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class TestBuildLiteLLMModelName:
|
||||
"""Test _build_litellm_model_name method"""
|
||||
|
||||
def test_no_provider_prefix(self):
|
||||
"""Test model name without provider prefix"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': ''})
|
||||
result = requester._build_litellm_model_name('gpt-4o')
|
||||
assert result == 'gpt-4o'
|
||||
|
||||
def test_with_provider_prefix(self):
|
||||
"""Test model name with provider prefix"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
|
||||
result = requester._build_litellm_model_name('gpt-4o')
|
||||
assert result == 'openai/gpt-4o'
|
||||
|
||||
def test_override_provider(self):
|
||||
"""Test override provider via parameter"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
|
||||
result = requester._build_litellm_model_name('claude-3', custom_llm_provider='anthropic')
|
||||
assert result == 'anthropic/claude-3'
|
||||
|
||||
|
||||
class TestExtractUsage:
|
||||
"""Test _extract_usage method"""
|
||||
|
||||
def test_extract_usage_with_data(self):
|
||||
"""Test extraction with valid usage data"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
response = Mock()
|
||||
response.usage = Mock()
|
||||
response.usage.prompt_tokens = 100
|
||||
response.usage.completion_tokens = 50
|
||||
response.usage.total_tokens = 150
|
||||
|
||||
result = requester._extract_usage(response)
|
||||
|
||||
assert result['prompt_tokens'] == 100
|
||||
assert result['completion_tokens'] == 50
|
||||
assert result['total_tokens'] == 150
|
||||
|
||||
def test_extract_usage_with_zero_values(self):
|
||||
"""Test extraction when values are 0"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
response = Mock()
|
||||
response.usage = Mock()
|
||||
response.usage.prompt_tokens = 0
|
||||
response.usage.completion_tokens = 0
|
||||
response.usage.total_tokens = 0
|
||||
|
||||
result = requester._extract_usage(response)
|
||||
|
||||
assert result['prompt_tokens'] == 0
|
||||
assert result['completion_tokens'] == 0
|
||||
|
||||
|
||||
class TestProcessThinkingContent:
|
||||
"""Test _process_thinking_content method"""
|
||||
|
||||
def test_no_thinking_markers(self):
|
||||
"""Test content without thinking markers"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('Hello world', None, remove_think=True)
|
||||
assert result == 'Hello world'
|
||||
|
||||
def test_remove_thinking_markers(self):
|
||||
"""Test removing thinking markers when remove_think=True"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'The answer is 42.'
|
||||
|
||||
def test_preserve_thinking_markers(self):
|
||||
"""Test preserving thinking markers when remove_think=False"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=False)
|
||||
assert 'CRETIRE_REASONING_BEGINk' in result
|
||||
assert 'The answer is 42.' in result
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test empty content"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('', None, remove_think=True)
|
||||
assert result == ''
|
||||
|
||||
|
||||
class TestBuildCommonArgs:
|
||||
"""Test _build_common_args method"""
|
||||
|
||||
def test_build_args_with_all_params(self):
|
||||
"""Test building args with all config params"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
'drop_params': True,
|
||||
'num_retries': 3,
|
||||
'api_version': '2024-01-01',
|
||||
},
|
||||
)
|
||||
|
||||
args = {}
|
||||
requester._build_common_args(args)
|
||||
|
||||
assert args['api_base'] == 'https://api.openai.com/v1'
|
||||
assert args['timeout'] == 60
|
||||
assert args['drop_params'] == True
|
||||
assert args['num_retries'] == 3
|
||||
assert args['api_version'] == '2024-01-01'
|
||||
|
||||
def test_build_args_without_retry_params(self):
|
||||
"""Test building args without retry params for embedding/rerank"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
'num_retries': 3,
|
||||
},
|
||||
)
|
||||
|
||||
args = {}
|
||||
requester._build_common_args(args, include_retry_params=False)
|
||||
|
||||
assert args['api_base'] == 'https://api.openai.com/v1'
|
||||
assert args['timeout'] == 60
|
||||
assert 'num_retries' not in args
|
||||
|
||||
|
||||
class TestHandleLiteLLMError:
|
||||
"""Test _handle_litellm_error method"""
|
||||
|
||||
def test_bad_request_error(self):
|
||||
"""Test BadRequestError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
# Create proper LiteLLM exception with required args
|
||||
error = litellm.BadRequestError(message='test error', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求参数错误' in str(exc_info.value)
|
||||
|
||||
def test_authentication_error(self):
|
||||
"""Test AuthenticationError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert 'API key 无效' in str(exc_info.value)
|
||||
|
||||
def test_rate_limit_error(self):
|
||||
"""Test RateLimitError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.RateLimitError(message='rate limited', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求过于频繁' in str(exc_info.value)
|
||||
|
||||
def test_timeout_error(self):
|
||||
"""Test Timeout translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.Timeout(message='timeout', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求超时' in str(exc_info.value)
|
||||
|
||||
def test_context_window_error(self):
|
||||
"""Test ContextWindowExceededError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.ContextWindowExceededError(message='context too long', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '上下文长度超限' in str(exc_info.value)
|
||||
|
||||
def test_unknown_error(self):
|
||||
"""Test unknown error translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(Exception('unknown'))
|
||||
|
||||
assert '未知错误' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestInvokeLLM:
|
||||
"""Test invoke_llm method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_basic(self):
|
||||
"""Test basic LLM invocation"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=mock_ap,
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM response
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.model_dump = Mock(
|
||||
return_value={
|
||||
'role': 'assistant',
|
||||
'content': 'Hello! How can I help you?',
|
||||
}
|
||||
)
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 10
|
||||
mock_response.usage.completion_tokens = 20
|
||||
mock_response.usage.total_tokens = 30
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
|
||||
# Patch acompletion at the import location
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
|
||||
result_msg, usage = await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert result_msg.role == 'assistant'
|
||||
assert result_msg.content == 'Hello! How can I help you?'
|
||||
assert usage['prompt_tokens'] == 10
|
||||
assert usage['completion_tokens'] == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_with_tools(self):
|
||||
"""Test LLM invocation with function calling"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(
|
||||
return_value=[{'type': 'function', 'function': {'name': 'get_weather'}}]
|
||||
)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.model_dump = Mock(
|
||||
return_value={
|
||||
'role': 'assistant',
|
||||
'content': None,
|
||||
'tool_calls': [
|
||||
{'id': 'call_123', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{}'}}
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 15
|
||||
mock_response.usage.completion_tokens = 10
|
||||
mock_response.usage.total_tokens = 25
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='What is the weather?')]
|
||||
# Create proper LLMTool with all required fields
|
||||
funcs = [Mock(spec=resource_tool.LLMTool)]
|
||||
funcs[0].name = 'get_weather'
|
||||
funcs[0].description = 'Get weather'
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
|
||||
result_msg, usage = await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
funcs=funcs,
|
||||
)
|
||||
|
||||
assert result_msg.tool_calls is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_error_handling(self):
|
||||
"""Test LLM invocation error handling"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
|
||||
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, side_effect=error):
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert 'API key 无效' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestInvokeEmbedding:
|
||||
"""Test invoke_embedding method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_embedding_basic(self):
|
||||
"""Test basic embedding invocation"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeEmbeddingModel('text-embedding-3-small', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM embedding response
|
||||
mock_response = Mock()
|
||||
mock_response.data = [
|
||||
Mock(embedding=[0.1, 0.2, 0.3]),
|
||||
Mock(embedding=[0.4, 0.5, 0.6]),
|
||||
]
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 20
|
||||
mock_response.usage.completion_tokens = 0
|
||||
mock_response.usage.total_tokens = 20
|
||||
|
||||
with patch.object(litellmchat, 'aembedding', new_callable=AsyncMock, return_value=mock_response):
|
||||
embeddings, usage = await requester.invoke_embedding(
|
||||
model=model,
|
||||
input_text=['Hello', 'World'],
|
||||
)
|
||||
|
||||
assert len(embeddings) == 2
|
||||
assert embeddings[0] == [0.1, 0.2, 0.3]
|
||||
assert embeddings[1] == [0.4, 0.5, 0.6]
|
||||
assert usage['prompt_tokens'] == 20
|
||||
|
||||
|
||||
class TestInvokeRerank:
|
||||
"""Test invoke_rerank method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_basic(self):
|
||||
"""Test basic rerank invocation"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.cohere.ai',
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM rerank response
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.3},
|
||||
{'index': 2, 'relevance_score': 0.8},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='What is the capital of France?',
|
||||
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
# Scores should be normalized
|
||||
assert results[0]['index'] == 0
|
||||
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_normalization(self):
|
||||
"""Test rerank score normalization"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
# Mock response with varying scores
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.9},
|
||||
{'index': 1, 'relevance_score': 0.1},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='test query',
|
||||
documents=['doc1', 'doc2'],
|
||||
)
|
||||
|
||||
# After normalization: 0.9 -> 1.0, 0.1 -> 0.0
|
||||
assert results[0]['relevance_score'] == 1.0
|
||||
assert results[1]['relevance_score'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_single_document(self):
|
||||
"""Test rerank with single document (no normalization needed)"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.5},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='test query',
|
||||
documents=['doc1'],
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
# Single score stays as is (min==max, no normalization)
|
||||
assert results[0]['relevance_score'] == 0.5
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
"""Test _convert_messages method"""
|
||||
|
||||
def test_convert_simple_message(self):
|
||||
"""Test converting simple text message"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['role'] == 'user'
|
||||
assert result[0]['content'] == 'Hello'
|
||||
|
||||
def test_convert_message_with_image_base64(self):
|
||||
"""Test converting message with image_base64 content"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [
|
||||
provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
{'type': 'text', 'text': 'What is in this image?'},
|
||||
{'type': 'image_base64', 'image_base64': 'data:image/png;base64,abc123'},
|
||||
],
|
||||
)
|
||||
]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
content = result[0]['content']
|
||||
assert isinstance(content, list)
|
||||
# Check image_base64 converted to image_url
|
||||
image_part = [p for p in content if p.get('type') == 'image_url'][0]
|
||||
assert 'image_url' in image_part
|
||||
assert image_part['image_url']['url'] == 'data:image/png;base64,abc123'
|
||||
|
||||
def test_convert_message_with_multiple_text_parts(self):
|
||||
"""Test converting message with multiple text parts (LiteLLM handles this)"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [
|
||||
provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
{'type': 'text', 'text': 'Hello'},
|
||||
{'type': 'text', 'text': 'World'},
|
||||
],
|
||||
)
|
||||
]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
# LiteLLM handles multiple text parts, we pass them through
|
||||
assert isinstance(result[0]['content'], list)
|
||||
|
||||
|
||||
class TestScanModels:
|
||||
"""Test scan_models method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_basic(self):
|
||||
"""Test basic model scanning"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock httpx response
|
||||
mock_response = Mock()
|
||||
mock_response.json = Mock(
|
||||
return_value={
|
||||
'data': [
|
||||
{'id': 'gpt-4o'},
|
||||
{'id': 'text-embedding-3-small'},
|
||||
{'id': 'gpt-3.5-turbo'},
|
||||
]
|
||||
}
|
||||
)
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock())
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await requester.scan_models(api_key='test-key')
|
||||
|
||||
assert 'models' in result
|
||||
assert len(result['models']) == 3
|
||||
# Check LLM models are first
|
||||
assert result['models'][0]['type'] == 'llm'
|
||||
# Check embedding model is detected
|
||||
embedding_models = [m for m in result['models'] if m['type'] == 'embedding']
|
||||
assert len(embedding_models) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_no_base_url(self):
|
||||
"""Test scan_models without base_url raises error"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': '',
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
await requester.scan_models()
|
||||
|
||||
assert 'Base URL required' in str(exc_info.value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
38
uv.lock
generated
38
uv.lock
generated
@@ -186,6 +186,20 @@ 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"
|
||||
@@ -1832,7 +1846,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.9.5"
|
||||
version = "4.9.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -1840,6 +1854,7 @@ dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aioshutil" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
{ name = "anthropic" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "async-lru" },
|
||||
@@ -1919,6 +1934,7 @@ requires-dist = [
|
||||
{ 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" },
|
||||
@@ -1937,7 +1953,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.6" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.8" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1993,7 +2009,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.3.6"
|
||||
version = "0.3.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2011,9 +2027,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2409,6 +2425,18 @@ 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.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10.1"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<!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" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Production-grade platform for building agentic IM bots"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -46,14 +46,14 @@
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.15.0",
|
||||
"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.17.23",
|
||||
"lodash": "^4.18.0",
|
||||
"lucide-react": "^0.507.0",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -76,7 +76,7 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"uuidjs": "^5.1.0",
|
||||
"vite": "^8.0.3",
|
||||
"vite": "^8.0.5",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
184
web/pnpm-lock.yaml
generated
184
web/pnpm-lock.yaml
generated
@@ -88,10 +88,10 @@ dependencies:
|
||||
version: 8.21.3(react-dom@19.2.1)(react@19.2.1)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(vite@8.0.3)
|
||||
version: 6.0.1(vite@8.0.8)
|
||||
axios:
|
||||
specifier: ^1.13.5
|
||||
version: 1.13.5
|
||||
specifier: ^1.15.0
|
||||
version: 1.15.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -111,8 +111,8 @@ dependencies:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.2.1)(react@19.2.1)
|
||||
lodash:
|
||||
specifier: ^4.17.23
|
||||
version: 4.17.23
|
||||
specifier: ^4.18.0
|
||||
version: 4.18.1
|
||||
lucide-react:
|
||||
specifier: ^0.507.0
|
||||
version: 0.507.0(react@19.2.1)
|
||||
@@ -180,8 +180,8 @@ dependencies:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
vite:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@20.19.30)
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.8(@types/node@20.19.30)
|
||||
zod:
|
||||
specifier: ^3.24.4
|
||||
version: 3.25.76
|
||||
@@ -312,25 +312,25 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/@emnapi/core@1.8.1:
|
||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||
/@emnapi/core@1.9.2:
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.1.0
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@emnapi/runtime@1.8.1:
|
||||
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||
/@emnapi/runtime@1.9.2:
|
||||
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@emnapi/wasi-threads@1.1.0:
|
||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||
/@emnapi/wasi-threads@1.2.1:
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -502,21 +502,21 @@ packages:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
dev: false
|
||||
|
||||
/@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1):
|
||||
resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==}
|
||||
/@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
'@emnapi/runtime': 1.8.1
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@oxc-project/types@0.122.0:
|
||||
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
|
||||
/@oxc-project/types@0.124.0:
|
||||
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
|
||||
dev: false
|
||||
|
||||
/@pkgr/core@0.2.9:
|
||||
@@ -1562,8 +1562,8 @@ packages:
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
dev: false
|
||||
|
||||
/@rolldown/binding-android-arm64@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
|
||||
/@rolldown/binding-android-arm64@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
@@ -1571,8 +1571,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-darwin-arm64@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
|
||||
/@rolldown/binding-darwin-arm64@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
@@ -1580,8 +1580,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-darwin-x64@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
|
||||
/@rolldown/binding-darwin-x64@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
@@ -1589,8 +1589,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-freebsd-x64@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
|
||||
/@rolldown/binding-freebsd-x64@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
@@ -1598,8 +1598,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
|
||||
/@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
@@ -1607,8 +1607,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
|
||||
/@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -1616,8 +1616,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-arm64-musl@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
|
||||
/@rolldown/binding-linux-arm64-musl@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -1625,8 +1625,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
|
||||
/@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
@@ -1634,8 +1634,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
|
||||
/@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
@@ -1643,8 +1643,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-x64-gnu@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
|
||||
/@rolldown/binding-linux-x64-gnu@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -1652,8 +1652,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-linux-x64-musl@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
|
||||
/@rolldown/binding-linux-x64-musl@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -1661,8 +1661,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-openharmony-arm64@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
|
||||
/@rolldown/binding-openharmony-arm64@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
@@ -1670,21 +1670,20 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1):
|
||||
resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
|
||||
/@rolldown/binding-wasm32-wasi@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
|
||||
/@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
@@ -1692,8 +1691,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/binding-win32-x64-msvc@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
|
||||
/@rolldown/binding-win32-x64-msvc@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1701,8 +1700,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rolldown/pluginutils@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
|
||||
/@rolldown/pluginutils@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
|
||||
dev: false
|
||||
|
||||
/@rolldown/pluginutils@1.0.0-rc.7:
|
||||
@@ -2156,7 +2155,7 @@ packages:
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
dev: false
|
||||
|
||||
/@vitejs/plugin-react@6.0.1(vite@8.0.3):
|
||||
/@vitejs/plugin-react@6.0.1(vite@8.0.8):
|
||||
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
@@ -2170,7 +2169,7 @@ packages:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
vite: 8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@20.19.30)
|
||||
vite: 8.0.8(@types/node@20.19.30)
|
||||
dev: false
|
||||
|
||||
/acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
@@ -2329,12 +2328,12 @@ packages:
|
||||
possible-typed-array-names: 1.1.0
|
||||
dev: true
|
||||
|
||||
/axios@1.13.5:
|
||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
||||
/axios@1.15.0:
|
||||
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
dev: false
|
||||
@@ -4148,8 +4147,8 @@ packages:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
dev: true
|
||||
|
||||
/lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
/lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
dev: false
|
||||
|
||||
/log-update@6.1.0:
|
||||
@@ -4958,8 +4957,9 @@ packages:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
dev: false
|
||||
|
||||
/proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
/proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/punycode@2.3.1:
|
||||
@@ -5200,7 +5200,7 @@ packages:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.17.23
|
||||
lodash: 4.18.1
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
react-is: 18.3.1
|
||||
@@ -5372,32 +5372,29 @@ packages:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
dev: true
|
||||
|
||||
/rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1):
|
||||
resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
|
||||
/rolldown@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.122.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.12
|
||||
'@oxc-project/types': 0.124.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.15
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.15
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
|
||||
dev: false
|
||||
|
||||
/safe-array-concat@1.1.3:
|
||||
@@ -6010,14 +6007,14 @@ packages:
|
||||
d3-timer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/vite@8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@20.19.30):
|
||||
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
|
||||
/vite@8.0.8(@types/node@20.19.30):
|
||||
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.1.0
|
||||
esbuild: ^0.27.0
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
@@ -6057,13 +6054,10 @@ packages:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rolldown: 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
||||
rolldown: 1.0.0-rc.15
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
dev: false
|
||||
|
||||
/void-elements@3.1.0:
|
||||
|
||||
145
web/scripts/check-i18n.mjs
Normal file
145
web/scripts/check-i18n.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/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();
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
:root {
|
||||
/* 适用于 Firefox 的滚动条 */
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||
@@ -74,8 +74,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
|
||||
@@ -10,7 +10,15 @@ 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 } from 'lucide-react';
|
||||
import {
|
||||
Ban,
|
||||
Bot,
|
||||
Copy,
|
||||
Check,
|
||||
Workflow,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -54,6 +62,12 @@ 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>;
|
||||
}
|
||||
@@ -75,6 +89,9 @@ 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 => {
|
||||
@@ -117,21 +134,50 @@ const BotSessionMonitor = forwardRef<
|
||||
[loadSessions],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
@@ -479,11 +525,21 @@ const BotSessionMonitor = forwardRef<
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
messages.map((msg, msgIndex) => {
|
||||
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}
|
||||
@@ -543,6 +599,30 @@ const BotSessionMonitor = forwardRef<
|
||||
{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>
|
||||
|
||||
@@ -240,6 +240,9 @@ export default function DynamicFormComponent({
|
||||
case 'embedding-model-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'rerank-model-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'knowledge-base-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Bot,
|
||||
KnowledgeBase,
|
||||
EmbeddingModel,
|
||||
RerankModel,
|
||||
PluginTool,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -74,6 +75,7 @@ export default function DynamicFormItemComponent({
|
||||
}) {
|
||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||
@@ -180,6 +182,19 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderRerankModels()
|
||||
.then((resp) => {
|
||||
setRerankModels(resp.models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to load rerank models: ' + err.msg);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
|
||||
fetchLlmModels();
|
||||
@@ -585,6 +600,45 @@ export default function DynamicFormItemComponent({
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
|
||||
const groupedRerankModels = rerankModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName = model.provider?.name || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, RerankModel[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
value={field.value || '__none__'}
|
||||
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.rerank')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{t('common.none')}</SelectItem>
|
||||
{Object.entries(groupedRerankModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||
// Separate space models from regular models
|
||||
const fbSpaceModels = llmModels.filter(
|
||||
|
||||
@@ -719,7 +719,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 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 [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
@@ -762,7 +762,7 @@ function NavItems({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
|
||||
@@ -16,6 +16,8 @@ import { ProviderCard } from './components';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
@@ -145,15 +147,17 @@ export default function ModelsDialog({
|
||||
setLoadingProviders((prev) => new Set(prev).add(providerUuid));
|
||||
}
|
||||
try {
|
||||
const [llmResp, embeddingResp] = await Promise.all([
|
||||
const [llmResp, embeddingResp, rerankResp] = await Promise.all([
|
||||
httpClient.getProviderLLMModels(providerUuid),
|
||||
httpClient.getProviderEmbeddingModels(providerUuid),
|
||||
httpClient.getProviderRerankModels(providerUuid),
|
||||
]);
|
||||
setProviderModels((prev) => ({
|
||||
...prev,
|
||||
[providerUuid]: {
|
||||
llm: llmResp.models,
|
||||
embedding: embeddingResp.models,
|
||||
rerank: rerankResp.models,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -245,12 +249,18 @@ export default function ModelsDialog({
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
@@ -262,6 +272,60 @@ 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,
|
||||
@@ -285,12 +349,18 @@ export default function ModelsDialog({
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.updateProviderEmbeddingModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.updateProviderRerankModel(modelId, {
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
setEditModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
@@ -310,8 +380,10 @@ export default function ModelsDialog({
|
||||
try {
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.deleteProviderLLMModel(modelId);
|
||||
} else {
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.deleteProviderEmbeddingModel(modelId);
|
||||
} else {
|
||||
await httpClient.deleteProviderRerankModel(modelId);
|
||||
}
|
||||
toast.success(t('models.deleteSuccess'));
|
||||
loadProviderModels(providerUuid, true);
|
||||
@@ -351,7 +423,7 @@ export default function ModelsDialog({
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
@@ -359,6 +431,14 @@ export default function ModelsDialog({
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
setTestResult({ success: true, duration });
|
||||
@@ -404,6 +484,10 @@ 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) =>
|
||||
|
||||
@@ -169,8 +169,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
MessageSquareText,
|
||||
Cpu,
|
||||
ArrowUpDown,
|
||||
Eye,
|
||||
Wrench,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,7 +21,14 @@ import {
|
||||
} from '@/components/ui/popover';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExtraArg, ModelType, TestResult } from '../types';
|
||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
} from '../types';
|
||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
|
||||
interface AddModelPopoverProps {
|
||||
@@ -24,6 +41,11 @@ 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,
|
||||
@@ -41,6 +63,8 @@ export default function AddModelPopover({
|
||||
onOpen,
|
||||
onClose,
|
||||
onAddModel,
|
||||
onScanModels,
|
||||
onAddScannedModels,
|
||||
onTestModel,
|
||||
isSubmitting,
|
||||
isTesting,
|
||||
@@ -48,22 +72,44 @@ 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(() => {
|
||||
if (isOpen) {
|
||||
const wasOpen = prevIsOpenRef.current;
|
||||
if (isOpen && !wasOpen) {
|
||||
setTab('llm');
|
||||
setMode('manual');
|
||||
setName('');
|
||||
setAbilities([]);
|
||||
setExtraArgs([]);
|
||||
setScanLoading(false);
|
||||
setScannedModels([]);
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
onResetTestResult();
|
||||
}
|
||||
}, [isOpen]);
|
||||
prevIsOpenRef.current = isOpen;
|
||||
}, [isOpen, onResetTestResult]);
|
||||
|
||||
useEffect(() => {
|
||||
setScannedModels([]);
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
}, [tab, mode]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
await onAddModel(tab, name, abilities, extraArgs);
|
||||
@@ -73,6 +119,50 @@ 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]);
|
||||
@@ -81,6 +171,76 @@ 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}
|
||||
@@ -98,12 +258,15 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
|
||||
align="end"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="llm">
|
||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||
{t('models.chat')}
|
||||
@@ -112,118 +275,272 @@ export default function AddModelPopover({
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
{t('models.embedding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rerank">
|
||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||
{t('models.rerank')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<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)
|
||||
}
|
||||
<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)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{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}
|
||||
modelType={tab}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
<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')
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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')
|
||||
: model.type === 'embedding'
|
||||
? t('models.embedding')
|
||||
: t('models.rerank')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Plus, X, HelpCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -9,19 +9,26 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExtraArg } from '../types';
|
||||
import { ExtraArg, ModelType } from '../types';
|
||||
|
||||
interface ExtraArgsEditorProps {
|
||||
args: ExtraArg[];
|
||||
onChange: (args: ExtraArg[]) => void;
|
||||
disabled?: boolean;
|
||||
modelType?: ModelType;
|
||||
}
|
||||
|
||||
export default function ExtraArgsEditor({
|
||||
args,
|
||||
onChange,
|
||||
disabled = false,
|
||||
modelType,
|
||||
}: ExtraArgsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -46,7 +53,27 @@ export default function ExtraArgsEditor({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('models.extraParameters')}</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>{t('models.extraParameters')}</Label>
|
||||
{modelType === 'rerank' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>
|
||||
<strong>rerank_url</strong>: {t('models.rerankUrlTooltip')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>rerank_path</strong>:{' '}
|
||||
{t('models.rerankPathTooltip')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -139,7 +139,11 @@ export default function ModelItem({
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modelType === 'llm' ? t('models.chat') : t('models.embedding')}
|
||||
{modelType === 'llm'
|
||||
? t('models.chat')
|
||||
: modelType === 'embedding'
|
||||
? t('models.embedding')
|
||||
: t('models.rerank')}
|
||||
</Badge>
|
||||
{modelType === 'llm' &&
|
||||
(model as LLMModel).abilities?.includes('vision') && (
|
||||
@@ -263,6 +267,7 @@ export default function ModelItem({
|
||||
args={editExtraArgs}
|
||||
onChange={setEditExtraArgs}
|
||||
disabled={isLangBotModels}
|
||||
modelType={modelType}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -24,7 +24,14 @@ 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, TestResult, ProviderModels } from '../types';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
} from '../types';
|
||||
import ModelItem from './ModelItem';
|
||||
import AddModelPopover from './AddModelPopover';
|
||||
|
||||
@@ -53,6 +60,11 @@ 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: (
|
||||
@@ -101,6 +113,8 @@ export default function ProviderCard({
|
||||
onOpenAddModel,
|
||||
onCloseAddModel,
|
||||
onAddModel,
|
||||
onScanModels,
|
||||
onAddScannedModels,
|
||||
onOpenEditModel,
|
||||
onCloseEditModel,
|
||||
onUpdateModel,
|
||||
@@ -120,9 +134,12 @@ export default function ProviderCard({
|
||||
const canDelete =
|
||||
!isLangBotModels &&
|
||||
(provider.llm_count || 0) === 0 &&
|
||||
(provider.embedding_count || 0) === 0;
|
||||
(provider.embedding_count || 0) === 0 &&
|
||||
(provider.rerank_count || 0) === 0;
|
||||
const totalModels =
|
||||
(provider.llm_count || 0) + (provider.embedding_count || 0);
|
||||
(provider.llm_count || 0) +
|
||||
(provider.embedding_count || 0) +
|
||||
(provider.rerank_count || 0);
|
||||
|
||||
return (
|
||||
<Card className="mb-2">
|
||||
@@ -298,6 +315,8 @@ export default function ProviderCard({
|
||||
onOpen={onOpenAddModel}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
@@ -377,11 +396,44 @@ export default function ProviderCard({
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
))}
|
||||
{models.llm.length === 0 && models.embedding.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('models.noModels')}
|
||||
</p>
|
||||
)}
|
||||
{models.rerank.map((model) => (
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
modelType="rerank"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onOpenEditModel={onOpenEditModel}
|
||||
onCloseEditModel={onCloseEditModel}
|
||||
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
||||
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
||||
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
|
||||
onUpdateModel={(name, abilities, extraArgs) =>
|
||||
onUpdateModel(
|
||||
model.uuid,
|
||||
'rerank',
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
)
|
||||
}
|
||||
onTestModel={(name, abilities, extraArgs) =>
|
||||
onTestModel(name, 'rerank', abilities, extraArgs)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
))}
|
||||
{models.llm.length === 0 &&
|
||||
models.embedding.length === 0 &&
|
||||
models.rerank.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('models.noModels')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
LLMModel,
|
||||
EmbeddingModel,
|
||||
RerankModel,
|
||||
ModelProvider,
|
||||
ProviderScanDebugInfo,
|
||||
ScannedProviderModel,
|
||||
} from '@/app/infra/entities/api';
|
||||
|
||||
export type ExtraArg = {
|
||||
@@ -10,11 +13,12 @@ export type ExtraArg = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelType = 'llm' | 'embedding';
|
||||
export type ModelType = 'llm' | 'embedding' | 'rerank';
|
||||
|
||||
export interface ProviderModels {
|
||||
llm: LLMModel[];
|
||||
embedding: EmbeddingModel[];
|
||||
rerank: RerankModel[];
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -22,6 +26,16 @@ 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;
|
||||
@@ -75,6 +89,11 @@ 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: (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
@@ -219,6 +220,12 @@ 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">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AlertCircle,
|
||||
Users,
|
||||
Layers,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -25,7 +26,8 @@ export type ExportType =
|
||||
| 'llm-calls'
|
||||
| 'embedding-calls'
|
||||
| 'errors'
|
||||
| 'sessions';
|
||||
| 'sessions'
|
||||
| 'feedback';
|
||||
|
||||
interface ExportDropdownProps {
|
||||
filterState: FilterState;
|
||||
@@ -162,6 +164,11 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
label: t('monitoring.export.sessions'),
|
||||
icon: <Users className="w-4 h-4 mr-2" />,
|
||||
},
|
||||
{
|
||||
type: 'feedback',
|
||||
label: t('monitoring.export.feedback'),
|
||||
icon: <ThumbsUp className="w-4 h-4 mr-2" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -127,6 +127,20 @@ export function FeedbackList({
|
||||
{item.platform}
|
||||
</span>
|
||||
)}
|
||||
{item.streamId && onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.streamId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t('monitoring.messageList.viewConversation')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
@@ -221,21 +235,8 @@ export function FeedbackList({
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
|
||||
<span className="truncate">{item.messageId}</span>
|
||||
{onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.messageId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
interface UseFeedbackDataParams {
|
||||
botIds?: string[];
|
||||
@@ -142,7 +143,7 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
||||
(item) => ({
|
||||
id: item.id,
|
||||
timestamp: new Date(item.timestamp),
|
||||
timestamp: parseUTCTimestamp(item.timestamp),
|
||||
feedbackId: item.feedback_id,
|
||||
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
||||
feedbackContent: item.feedback_content,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, useState, useMemo } from 'react';
|
||||
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -69,6 +69,9 @@ function MonitoringPageContent() {
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
|
||||
// Get time range for feedback data
|
||||
const feedbackTimeRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
@@ -106,7 +109,8 @@ function MonitoringPageContent() {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
|
||||
|
||||
// Feedback data hook
|
||||
const {
|
||||
@@ -127,6 +131,12 @@ function MonitoringPageContent() {
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
// Combined refresh handler for both monitoring and feedback data
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetch();
|
||||
setFeedbackRefreshKey((k) => k + 1);
|
||||
}, [refetch]);
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -265,7 +275,7 @@ function MonitoringPageContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
onClick={handleRefresh}
|
||||
className="shadow-sm flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -405,7 +405,10 @@ export default function PluginInstallProgressDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogContent
|
||||
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Download className="size-5" />
|
||||
@@ -422,14 +425,16 @@ export default function PluginInstallProgressDialog() {
|
||||
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
{selectedTask &&
|
||||
(selectedTask.stage === InstallStage.DONE ||
|
||||
selectedTask.stage === InstallStage.ERROR) && (
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t('plugins.installProgress.dismiss')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={
|
||||
selectedTask?.stage === InstallStage.DONE ||
|
||||
selectedTask?.stage === InstallStage.ERROR
|
||||
? handleDismiss
|
||||
: handleClose
|
||||
}
|
||||
>
|
||||
{selectedTask?.stage === InstallStage.DONE ||
|
||||
selectedTask?.stage === InstallStage.ERROR
|
||||
? t('common.close')
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ModelProvider {
|
||||
api_keys: string[];
|
||||
llm_count?: number;
|
||||
embedding_count?: number;
|
||||
rerank_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -61,6 +62,34 @@ export interface ApiRespModelProvider {
|
||||
provider: ModelProvider;
|
||||
}
|
||||
|
||||
export interface ScannedProviderModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'llm' | 'embedding';
|
||||
abilities?: string[];
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
context_length?: number | null;
|
||||
owned_by?: string;
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
already_added: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderScanDebugInfo {
|
||||
request?: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiRespScannedProviderModels {
|
||||
models: ScannedProviderModel[];
|
||||
debug?: ProviderScanDebugInfo;
|
||||
}
|
||||
|
||||
export interface LLMModel {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -86,6 +115,22 @@ export interface EmbeddingModel {
|
||||
extra_args?: object;
|
||||
}
|
||||
|
||||
export interface ApiRespProviderRerankModels {
|
||||
models: RerankModel[];
|
||||
}
|
||||
|
||||
export interface ApiRespProviderRerankModel {
|
||||
model: RerankModel;
|
||||
}
|
||||
|
||||
export interface RerankModel {
|
||||
uuid: string;
|
||||
name: string;
|
||||
provider_uuid: string;
|
||||
provider?: ModelProvider;
|
||||
extra_args?: object;
|
||||
}
|
||||
|
||||
export interface ApiRespPipelines {
|
||||
pipelines: Pipeline[];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface I18nObject {
|
||||
th_TH?: string;
|
||||
vi_VN?: string;
|
||||
es_ES?: string;
|
||||
ru_RU?: string;
|
||||
}
|
||||
|
||||
export interface ComponentManifest {
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
|
||||
SELECT = 'select',
|
||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||
RERANK_MODEL_SELECTOR = 'rerank-model-selector',
|
||||
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
||||
PROMPT_EDITOR = 'prompt-editor',
|
||||
UNKNOWN = 'unknown',
|
||||
|
||||
@@ -31,12 +31,16 @@ import {
|
||||
ApiRespProviderEmbeddingModels,
|
||||
ApiRespProviderEmbeddingModel,
|
||||
EmbeddingModel,
|
||||
ApiRespProviderRerankModels,
|
||||
ApiRespProviderRerankModel,
|
||||
RerankModel,
|
||||
ApiRespPluginSystemStatus,
|
||||
ApiRespMCPServers,
|
||||
ApiRespMCPServer,
|
||||
MCPServer,
|
||||
ApiRespModelProviders,
|
||||
ApiRespModelProvider,
|
||||
ApiRespScannedProviderModels,
|
||||
ModelProvider,
|
||||
ApiRespKnowledgeEngines,
|
||||
ApiRespParsers,
|
||||
@@ -106,6 +110,14 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}`);
|
||||
}
|
||||
|
||||
public scanProviderModels(
|
||||
uuid: string,
|
||||
modelType?: 'llm' | 'embedding',
|
||||
): Promise<ApiRespScannedProviderModels> {
|
||||
const params = modelType ? { type: modelType } : {};
|
||||
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
||||
}
|
||||
|
||||
// ============ Provider Model LLM ============
|
||||
public getProviderLLMModels(
|
||||
providerUuid?: string,
|
||||
@@ -173,6 +185,39 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/provider/models/embedding/${uuid}/test`, model);
|
||||
}
|
||||
|
||||
// ============ Provider Model Rerank ============
|
||||
public getProviderRerankModels(
|
||||
providerUuid?: string,
|
||||
): Promise<ApiRespProviderRerankModels> {
|
||||
const params = providerUuid ? { provider_uuid: providerUuid } : {};
|
||||
return this.get('/api/v1/provider/models/rerank', params);
|
||||
}
|
||||
|
||||
public getProviderRerankModel(
|
||||
uuid: string,
|
||||
): Promise<ApiRespProviderRerankModel> {
|
||||
return this.get(`/api/v1/provider/models/rerank/${uuid}`);
|
||||
}
|
||||
|
||||
public createProviderRerankModel(model: RerankModel): Promise<object> {
|
||||
return this.post('/api/v1/provider/models/rerank', model);
|
||||
}
|
||||
|
||||
public deleteProviderRerankModel(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/provider/models/rerank/${uuid}`);
|
||||
}
|
||||
|
||||
public updateProviderRerankModel(
|
||||
uuid: string,
|
||||
model: RerankModel,
|
||||
): Promise<object> {
|
||||
return this.put(`/api/v1/provider/models/rerank/${uuid}`, model);
|
||||
}
|
||||
|
||||
public testRerankModel(uuid: string, model: RerankModel): Promise<object> {
|
||||
return this.post(`/api/v1/provider/models/rerank/${uuid}/test`, model);
|
||||
}
|
||||
|
||||
// ============ Pipeline API ============
|
||||
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user