mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-10 15:56:03 +00:00
Compare commits
25 Commits
feat/human
...
v4.9.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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())
|
||||||
|
"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -39,6 +39,7 @@ dependencies = [
|
|||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -64,7 +65,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.7",
|
"langbot-plugin==0.3.8",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -111,7 +112,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[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]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""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]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
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):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# 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':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
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':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,7 +359,25 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
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'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
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]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
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:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
@@ -592,6 +595,120 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = 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
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
@@ -903,35 +1020,38 @@ class WecomBotClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
await self.logger.info(
|
await self.logger.info(
|
||||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
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:
|
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:
|
except Exception:
|
||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
|
|||||||
流式消息 ID
|
流式消息 ID
|
||||||
"""
|
"""
|
||||||
return self.get('stream_id', '')
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -105,23 +105,24 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
elif path.startswith('home/'):
|
elif not path.startswith('api/'):
|
||||||
# SPA fallback for /home/* sub-routes.
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
# so the pre-rendered list page is served directly via path + '.html'.
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
# This fallback handles any remaining unmatched sub-paths.
|
if path.startswith('home/'):
|
||||||
segments = path.rstrip('/').split('/')
|
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
|
# Fallback to index.html for SPA client-side routing
|
||||||
for i in range(len(segments) - 1, 0, -1):
|
|
||||||
parent_path = '/'.join(segments[:i]) + '.html'
|
|
||||||
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
|
||||||
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
# Final fallback to index.html for /home/* routes
|
|
||||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
|||||||
@@ -1224,30 +1224,83 @@ class MonitoringService:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_id = str(uuid.uuid4())
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
record_data = {
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
'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,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
|
||||||
|
# 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(
|
async def get_feedback_stats(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
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}.')
|
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()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
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 def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
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))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
@@ -172,6 +171,15 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
):
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
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
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
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)
|
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)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
# Check for quote/reply message
|
# 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)
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
if quote_message_id:
|
if quote_message_id:
|
||||||
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
if quote_chain:
|
if quote_chain:
|
||||||
# Filter out Source component from quoted chain, keep only content
|
# Filter out Source component from quoted chain, keep only content
|
||||||
quote_origin = platform_message.MessageChain(
|
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
[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
|
||||||
if quote_origin:
|
for comp in quote_components:
|
||||||
message_chain.append(
|
if isinstance(comp, platform_message.File):
|
||||||
platform_message.Quote(
|
# Add file as top-level component (same as direct message)
|
||||||
message_id=quote_message_id,
|
message_chain.append(comp)
|
||||||
origin=quote_origin,
|
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':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
|
|||||||
@@ -126,6 +126,107 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if summary:
|
if summary:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=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(
|
has_content_element = any(
|
||||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||||
)
|
)
|
||||||
@@ -328,6 +429,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
feedback_type = kwargs.get('feedback_type', 0)
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
feedback_content = kwargs.get('feedback_content', '') or None
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) 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 = kwargs.get('session')
|
||||||
|
|
||||||
session_id = None
|
session_id = None
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ class TelemetryManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
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)
|
v = sanitized.get(sfield)
|
||||||
sanitized[sfield] = '' if v is None else str(v)
|
sanitized[sfield] = '' if v is None else str(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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1832,7 +1846,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1840,6 +1854,7 @@ dependencies = [
|
|||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aioshutil" },
|
{ name = "aioshutil" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "async-lru" },
|
{ name = "async-lru" },
|
||||||
@@ -1919,6 +1934,7 @@ requires-dist = [
|
|||||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||||
{ name = "aioshutil", specifier = ">=1.5" },
|
{ name = "aioshutil", specifier = ">=1.5" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.15.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.51.0" },
|
{ name = "anthropic", specifier = ">=0.51.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
||||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||||
@@ -1937,7 +1953,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ 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", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +2009,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.6"
|
version = "0.3.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2027,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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 = [
|
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]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.10.1"
|
version = "3.10.1"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh">
|
<html lang="zh">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>LangBot</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4019
web/pnpm-lock.yaml
generated
4019
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
:root {
|
:root {
|
||||||
/* 适用于 Firefox 的滚动条 */
|
/* 适用于 Firefox 的滚动条 */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||||
@@ -74,8 +74,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ function NavItems({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -762,7 +762,7 @@ function NavItems({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -219,6 +220,12 @@ export default function FileUploadZone({
|
|||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
{t('knowledge.documentsTab.noParserAvailable')}
|
{t('knowledge.documentsTab.noParserAvailable')}
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http';
|
import { httpClient } from '@/app/infra/http';
|
||||||
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||||
|
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||||
|
|
||||||
interface UseFeedbackDataParams {
|
interface UseFeedbackDataParams {
|
||||||
botIds?: string[];
|
botIds?: string[];
|
||||||
@@ -142,7 +143,7 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
|||||||
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
||||||
(item) => ({
|
(item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
timestamp: new Date(item.timestamp),
|
timestamp: parseUTCTimestamp(item.timestamp),
|
||||||
feedbackId: item.feedback_id,
|
feedbackId: item.feedback_id,
|
||||||
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
||||||
feedbackContent: item.feedback_content,
|
feedbackContent: item.feedback_content,
|
||||||
|
|||||||
@@ -405,7 +405,10 @@ export default function PluginInstallProgressDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<Download className="size-5" />
|
<Download className="size-5" />
|
||||||
@@ -422,14 +425,16 @@ export default function PluginInstallProgressDialog() {
|
|||||||
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
{selectedTask &&
|
<Button
|
||||||
(selectedTask.stage === InstallStage.DONE ||
|
variant="default"
|
||||||
selectedTask.stage === InstallStage.ERROR) && (
|
size="sm"
|
||||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
onClick={
|
||||||
{t('plugins.installProgress.dismiss')}
|
selectedTask?.stage === InstallStage.DONE ||
|
||||||
</Button>
|
selectedTask?.stage === InstallStage.ERROR
|
||||||
)}
|
? handleDismiss
|
||||||
<Button variant="default" size="sm" onClick={handleClose}>
|
: handleClose
|
||||||
|
}
|
||||||
|
>
|
||||||
{selectedTask?.stage === InstallStage.DONE ||
|
{selectedTask?.stage === InstallStage.DONE ||
|
||||||
selectedTask?.stage === InstallStage.ERROR
|
selectedTask?.stage === InstallStage.ERROR
|
||||||
? t('common.close')
|
? t('common.close')
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ function DialogOverlay({
|
|||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
hideCloseButton = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@@ -123,10 +126,12 @@ function DialogContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
{!hideCloseButton && (
|
||||||
<XIcon />
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
<span className="sr-only">Close</span>
|
<XIcon />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -872,6 +872,7 @@ const enUS = {
|
|||||||
builtInParser: 'Provided by Knowledge engine',
|
builtInParser: 'Provided by Knowledge engine',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'No parser supports this file type. Please install a parser plugin that can handle this format.',
|
'No parser supports this file type. Please install a parser plugin that can handle this format.',
|
||||||
|
installParserHint: 'Browse parser plugins in Marketplace →',
|
||||||
confirmUpload: 'Upload',
|
confirmUpload: 'Upload',
|
||||||
cancelUpload: 'Cancel',
|
cancelUpload: 'Cancel',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -832,6 +832,7 @@ const zhHans = {
|
|||||||
builtInParser: '由知识引擎提供',
|
builtInParser: '由知识引擎提供',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
|
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
|
||||||
|
installParserHint: '前往插件市场安装解析器 →',
|
||||||
confirmUpload: '上传',
|
confirmUpload: '上传',
|
||||||
cancelUpload: '取消',
|
cancelUpload: '取消',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -825,6 +825,7 @@ const zhHant = {
|
|||||||
builtInParser: '由知識引擎提供',
|
builtInParser: '由知識引擎提供',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
|
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
|
||||||
|
installParserHint: '前往插件市場安裝解析器 →',
|
||||||
confirmUpload: '上傳',
|
confirmUpload: '上傳',
|
||||||
cancelUpload: '取消',
|
cancelUpload: '取消',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,9 +17,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user