Compare commits

...

81 Commits

Author SHA1 Message Date
Junyan Qin
558587883b chore: update project version to 4.7.2 2026-01-13 14:02:00 +08:00
Junyan Qin
2e6a1daf4f feat(mcp): extend mode options in MCPCardVO to include 'http' 2026-01-13 13:59:59 +08:00
Tiankai Ma
1fc5e75f93 feat(mcp): add streamable HTTP and stdio (#1911)
* feat(mcp): add streamable HTTP

alongside with frontend UI change, w/ support for stdio

* fix(mcp): address copilot reviews

* Update src/langbot/pkg/provider/tools/loaders/mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: resolve copilot reviews

* fix: Message -> MessageChunk

* feat: upgrade mcp module

* feat: add i18n

* feat(mcp): enhance MCPCardComponent with mode badge and reorder select items in MCPFormDialog

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
2026-01-13 13:50:06 +08:00
fdc310
a332206ba3 fix: When the deletion of the thinking chain is activated, since the "continue" is triggered as soon as the thinking begins, it causes a bug in the subsequent judgment that breaks out of the loop impression. (#1913) 2026-01-12 00:14:39 +08:00
Junyan Qin
8e620dc635 fix: remove unreachable assertion in ChatMessageHandler to improve error handling 2026-01-09 23:46:43 +08:00
Junyan Qin
c9a21ebace fix: improve error handling in ChatMessageHandler 2026-01-09 23:23:53 +08:00
Junyan Qin
a05cdcac50 chore: update project version to 4.7.1 2026-01-09 21:52:08 +08:00
Junyan Qin
ecfb2bfb34 chore: add type hints for ap in telemetry.py 2026-01-09 21:50:43 +08:00
Guanchao Wang
e17dba0a98 fix: testing mcp server (#1912) 2026-01-09 18:39:40 +08:00
Hadong
6b138943ce feat(milvus): milvus related updates (#1908)
- Add Milvus db_name configuration and client parameter support.
- change kb_data uuid for Milvus. 3. add MAX_BATCH_SIZE for openai.
- support more vector_size.
2026-01-09 16:03:43 +08:00
fdc310
eb0e6aff68 feat: add telemetry support for query execution tracking and configur… (#1900)
* feat: add telemetry support for query execution tracking and configuration

* feat: integrate telemetry manager and enable telemetry data sending

* feat: integrate telemetry manager and enhance error handling for telemetry sending

* feat: update telemetry configuration to use 'space' instead of 'telemetry' and adjust related parameters

* feat: integrate telemetry manager and enable telemetry data sending

* feat: integrate telemetry manager and enhance error handling for telemetry sending

* feat: add instance id

* feat: enhance telemetry management with asynchronous task handling and improve model retrieval caching

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-01-09 15:50:44 +08:00
Junyan Qin
4d0095626a fix: update docker-compose command to include --no-sync option for improved runtime behavior 2026-01-08 11:30:25 +08:00
Junyan Qin
aa0a501ade fix: bug in bind space account in models dialog 2026-01-05 20:53:35 +08:00
Junyan Qin
68ef7bd2c4 chore: update project version to 4.7.0 and revise description for clarity 2026-01-05 20:06:01 +08:00
Junyan Qin
61dc5de085 fix: update help links in sidebar configuration to reflect new usage paths and add Japanese translations 2026-01-05 18:45:35 +08:00
Junyan Qin
63bdd71e22 fix: update models_gateway_api_url to include version in cloud service configuration 2026-01-05 17:58:50 +08:00
Junyan Qin
9ea5b50802 refactor: enhance layout and styling of ModelsDialog component for improved usability 2026-01-05 17:58:01 +08:00
Jinzhe Zeng
1cd586634d fix: split Wecom messages exceeding 2048-byte limit (#1901)
Co-authored-by: Oracle Public Cloud User <opc@arm1.subnet.vcn.oraclevcn.com>
2026-01-05 15:04:46 +08:00
Junyan Qin
45bedbe70e fix: update QQ Group link in README to the new group ID 2026-01-05 10:20:42 +08:00
Junyan Qin (Chin)
f7f1dde7b5 Merge pull request #1894 from langbot-app/feat/maas-support
refactor: model config dialog and introduce LangBot Models service integration
2026-01-03 15:47:23 +08:00
Junyan Qin
ba06555078 refactor: remove SQLite compatibility check for column cleanup in DB migration script 2026-01-03 15:43:40 +08:00
Junyan Qin
840fa39979 feat: add informational popover to registration page with tips on using Space for account authentication 2026-01-03 15:26:24 +08:00
Junyan Qin
b295416e6c fix: adjust ModelsDialog component to set a maximum width for better layout consistency 2026-01-03 01:06:17 +08:00
Junyan Qin
914f77ff37 refactor: standardize error handling across components by utilizing CustomApiError for improved error messaging 2026-01-03 00:56:25 +08:00
Junyan Qin
b0b7b914d8 feat: update README files to include new links for API integration, plugin market, and roadmap across multiple languages 2026-01-01 22:11:43 +08:00
Junyan Qin
12713aad45 feat: migrate cloud service URL configuration and update database version to 17 2026-01-01 21:40:55 +08:00
Junyan Qin
02e12cc1e4 feat: implement account email mismatch error handling and improve user feedback in authentication flows 2026-01-01 17:01:32 +08:00
Junyan Qin
61f08f3218 feat: add disable_models_service configuration to manage model service availability and update related components 2026-01-01 15:40:39 +08:00
Junyan Qin
75c2a063cc refactor: remove providerUuid prop from model components and enhance provider deletion confirmation UI 2026-01-01 15:07:37 +08:00
Junyan Qin
b4773c4e48 refactor: update model management components and enhance provider functionality 2026-01-01 14:58:06 +08:00
Junyan Qin (Chin)
fb73da8735 Merge branch 'master' into feat/maas-support 2026-01-01 13:07:45 +08:00
Junyan Qin
679e549b1d feat: implement loading states in SpaceOAuthCallback and HomeSidebar components using Suspense 2026-01-01 13:06:04 +08:00
Junyan Qin
898144e9f4 fix: remove unused HoverCard imports from DynamicFormItemComponent and clean up ModelsDialog constants 2026-01-01 12:53:39 +08:00
Junyan Qin
b99c5561fc fix: update cloud service URL retrieval and enhance model synchronization error handling 2026-01-01 12:50:26 +08:00
Copilot
b2f4b91979 perf: replace copy button toast notifications with checkmark feedback (#1898)
* Initial plan

* Replace copy button toast notifications with checkmark visual feedback

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

* Complete copy button checkmark feedback implementation

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

* revert pnpm-lock.yaml

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-01-01 11:53:13 +08:00
Junyan Qin
4528000fc4 refactor: model management 2026-01-01 02:00:24 +08:00
Junyan Qin
96e40eaf25 feat: enhance model creation with UUID preservation option and implement Space model synchronization in ModelManager 2025-12-31 22:25:07 +08:00
Junyan Qin
197258ae91 feat: add LangBot Space ChatCompletions requester and integrate with ModelsDialog and EmbeddingForm components 2025-12-30 21:52:52 +08:00
Junyan Qin
19f417174c feat: implement SpaceService for OAuth handling and user management, refactor UserService to utilize new service methods 2025-12-29 22:43:19 +08:00
Junyan Qin
9c82eeddeb feat: add endpoint for retrieving user space credits and implement caching mechanism in UserService 2025-12-29 22:23:11 +08:00
Junyan Qin
f11e01b549 refactor: rename 'allow_change_password' to 'allow_modify_login_info' and update related logic across the application 2025-12-29 21:14:05 +08:00
Junyan Qin
863b26c3fa refactor: update column drop logic in DBMigrateModelProviderRefactor for PostgreSQL compatibility 2025-12-29 20:42:06 +08:00
Junyan Qin
b788858f9e fix: handle case of empty token list in TokenManager to prevent errors 2025-12-29 12:18:45 +08:00
Junyan Qin
de8a7df6c2 feat: implement instance ID management and integrate with OAuth token exchange 2025-12-29 00:35:31 +08:00
Junyan Qin
ba5b481617 refactor: simplify theme toggle implementation in HomeSidebar and ThemeToggle components 2025-12-28 22:43:05 +08:00
Junyan Qin
07ad846e96 feat: update dependencies and enhance account settings dialog with password management and improved UI elements 2025-12-28 22:38:11 +08:00
Copilot
30945aafdd feat: support configurable WeCom API base URL for reverse proxy deployment (#1890)
* Initial plan

* Add api_base_url support to WeCom API libraries and adapters

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

* Add api_base_url parameter to OAClient and adapters for Official Account and WeCom APIs

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-28 21:04:55 +08:00
Junyan Qin
24c15b4479 feat: implement account settings dialog for managing user passwords and binding Space accounts 2025-12-26 23:20:51 +08:00
Junyan Qin
1d4c5bbdf1 feat: enhance model abilities display in DynamicFormItem and ModelsDialog components with icons for vision and function call 2025-12-26 20:57:12 +08:00
Junyan Qin
57fcec011d feat: refactor model management to introduce provider structure, enhancing model organization and retrieval 2025-12-26 20:27:33 +08:00
Junyan Qin
455e3db28d feat: add Radix UI collapsible component for enhanced UI interactions 2025-12-26 00:49:35 +08:00
Junyan Qin
8caab43b00 feat: add Space integration for user authentication and model management with OAuth support 2025-12-26 00:35:47 +08:00
Junyan Qin
7479545339 feat: implement models dialog for managing LLM and embedding models with dynamic URL handling 2025-12-25 20:54:00 +08:00
Junyan Qin
10ee30695a feat: add error handling and alert display for model testing in EmbeddingForm and LLMForm 2025-12-24 16:12:41 +08:00
Junyan Qin
a9a262eaae feat: add new version notification dialog and version comparison logic 2025-12-24 12:43:52 +08:00
Junyan Qin
a8594b76cd fix: enable extra_args in LLMModelsService for model testing 2025-12-23 21:03:45 +08:00
Junyan Qin
11ee0fef5d chore: update Python versions in CI workflow 2025-12-23 14:27:09 +08:00
Junyan Qin
9a9ba34717 chore: bump version v4.6.5 2025-12-23 14:26:52 +08:00
Junyan Qin
312e47bf46 chore: bump langbot-plugin to 0.2.4 2025-12-23 14:22:13 +08:00
Junyan Qin
628865fd06 fix: add timeout to image fetching in get_qq_image_bytes function (#1859) 2025-12-23 14:17:16 +08:00
Junyan Qin
806a03cd53 fix: dingtalk adapter lifecycle mgm issues (#1844, #1853) 2025-12-23 14:00:41 +08:00
Junyan Qin
24bd90fcf6 fix: alter_user_message typing issues 2025-12-23 13:24:52 +08:00
Junyan Qin
d2765577c8 chore: provide '--no-sync' arg in dockerfile 2025-12-23 12:39:42 +08:00
fdc310
60ca688bcb Fix/Incomplete JSON data returned by N8N streaming data causes the loss of chunks. (#1880)
* fix: Incomplete JSON data returned by N8N streaming data causes the loss of chunks.
2025-12-23 09:42:26 +08:00
ICE
76d8eea41d fix: group bot at rule (#1882) 2025-12-22 20:20:41 +08:00
Junyan Qin
635c3a04d8 perf: ja-JP translation for New 2025-12-22 18:46:15 +08:00
Junyan Qin
dde97abe38 feat: enhance HomeSidebar with new integration options and updated translations 2025-12-22 18:43:19 +08:00
Copilot
90a22d894d fix: prevent memory overflow from excessive logging in streaming and query processing (#1879)
* Initial plan

* fix: reduce excessive logging to prevent memory overflow

- Add log file rotation (10MB max per file, 5 backups)
- Reduce streaming response logging (every 10th chunk instead of every chunk)
- Remove debug logging from controller tight loop
- Add summary logging after streaming completes

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

* refactor: address code review feedback

- Extract log rotation config to module-level constants
- Keep first streaming chunk at INFO level for connection debugging
- Use DEBUG level for subsequent chunks

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

* style: fix code formatting whitespace

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-22 18:25:24 +08:00
Junyan Qin
88ef9cd6ae chore: remove platform field from docker-compose.yaml 2025-12-21 20:31:09 +08:00
fdc310
e3595b5c57 Feat/lark file and audio (#1874)
* fix: n8n streaming no sequence bug

* feat:add lark file and audio
fix: webhook

* feat:add lark file and audio
fix: webhook

* 更新 n8nsvapi.py

* del : print and log
2025-12-21 01:30:05 +08:00
Junyan Qin (Chin)
ce82f87e43 feat: add SeekDB vector database support for knowledge bases (#1814)
* feat: add SeekDB vector database support for knowledge bases

This commit adds complete integration of OceanBase's SeekDB as a vector
database option for LangBot's knowledge base feature.

## Changes

### Core Implementation
- Add SeekDB adapter implementing VectorDatabase interface
  - Support both embedded and server deployment modes
  - HNSW indexing with cosine similarity
  - Async operations with error handling
  - Comprehensive logging

### System Integration
- Register SeekDB in VectorDBManager
- Add pyseekdb>=0.1.0 dependency
- Add SeekDB configuration template
- Update README with vector database section

### Documentation
- Complete integration guide with platform compatibility warnings
- Configuration examples for all deployment modes
- Troubleshooting guide for common issues
- Code examples demonstrating usage patterns
- Comprehensive test reports and status documentation

## Testing

Architecture validated end-to-end using ChromaDB:
- File upload → parsing → chunking → embedding → storage
- 828 bytes → 3 chunks → 3 vectors stored successfully
- BGE-M3 model (384 dimensions)
- Status: Completed 

## Platform Compatibility

### Embedded Mode
-  Linux: Fully supported
-  macOS: Not supported (pylibseekdb is Linux-only)
-  Windows: Not supported (pylibseekdb is Linux-only)

### Server Mode
-  Linux: Fully supported
- ⚠️ macOS: Known issue (oceanbase/seekdb#36)
- ⚠️ Windows: Untested

### Remote Connection
-  All platforms supported

## Known Issues

macOS Docker server mode affected by upstream bug:
https://github.com/oceanbase/seekdb/issues/36

Workaround: Use ChromaDB/Qdrant or connect to remote SeekDB server.

## Files Added
- src/langbot/pkg/vector/vdbs/seekdb.py
- docs/SEEKDB_INTEGRATION.md
- examples/seekdb_example.py
- SEEKDB_INTEGRATION_SUMMARY.md
- SEEKDB_INTEGRATION_COMPLETE.md
- SEEKDB_TEST_STATUS.md
- SEEKDB_FINAL_SUMMARY.md
- SEEKDB_INTEGRATION_DONE.md
- GITHUB_ISSUE_36_COMMENT.md

## Files Modified
- src/langbot/pkg/vector/mgr.py
- src/langbot/pkg/vector/vdbs/__init__.py
- pyproject.toml
- src/langbot/templates/config.yaml
- README.md
- README_EN.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

* chore: remove unused docs

* feature: minimal seekdb change (#1866)

* feat: add SeekDB embedding requester and configuration

This commit introduces a new SeekDB embedding requester, which utilizes the local embedding function from pyseekdb. It includes the necessary Python implementation and a corresponding YAML configuration file for integration. Additionally, a new SVG icon for SeekDB is added to enhance the visual representation in the UI.

* fix: update EmbeddingForm to conditionally render URL field based on model provider

This commit modifies the EmbeddingForm component to conditionally display the URL input field only when the current model provider is not 'seekdb-embedding'. Additionally, it updates the condition for rendering the API key field to exclude both 'ollama-chat' and 'seekdb-embedding' providers.

* chore: update Python version requirement in pyproject.toml to support Python 3.11

* fix: add config default value, when it makes fronted not show spec

* fix: seekdb.py clean metadata. change api

* fix: enhance error handling in SeekDB embedding initialization

This commit adds improved error handling to the SeekDB embedding function. It ensures that a RuntimeError is raised if the embedding function fails to initialize, and wraps the embedding call in a try-except block to catch and raise a RequesterError with a descriptive message in case of failure.

* refactor: update SeekDB database management to use AdminClient

This commit refactors the SeekDB database management logic to utilize the AdminClient for database operations. It replaces the previous temp_client with admin_client for listing and creating databases, ensuring a more robust interaction with the SeekDB API.

* refactor: update SeekDB embedding model initialization to use task manager

This commit refactors the SeekDB embedding model initialization by replacing the direct asyncio task creation with the task manager's create_task method. This change enhances task management and provides a clearer naming convention for the embedding model initialization task.

* perf: integration

* chore: remove unnecessary files

* fix: linter errors

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
Co-authored-by: 名为a的全局变量 <1051233107@qq.com>
2025-12-20 23:40:30 +08:00
fdc310
854b291c5a fix: n8n streaming no sequence bug (#1873) 2025-12-20 00:03:05 +08:00
Junyan Qin
9780fd059c chore: add back arm64 docker image (#1871) 2025-12-19 23:44:28 +08:00
Junyan Qin
adc65f66eb fix: pipeline duplication bug 2025-12-19 23:27:18 +08:00
Copilot
ae772074a1 feat: Add configurable password change toggle via system.allow_change_password (#1869)
* Initial plan

* Add password change toggle feature with config flag

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

* Feature implementation complete and validated

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

* chore: remove lock

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-18 15:14:03 +08:00
dependabot[bot]
16c1e9edd1 chore(deps): bump next from 15.5.7 to 15.5.9 in /web (#1868)
Bumps [next](https://github.com/vercel/next.js) from 15.5.7 to 15.5.9.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.7...v15.5.9)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 12:21:02 +08:00
sheetung
3ab9ffb7b7 feat(plugins): add plugin new version detection (#1865)
* feat(plugins): 添加插件更新检测功能

* perf: card style

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-18 12:17:25 +08:00
Copilot
82e2123fe7 Fix Dify v1.11.0 conversation_id UUID validation error (#1860)
* Initial plan

* Fix Dify v1.11.0 conversation_id UUID validation error

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-12 18:35:47 +08:00
Junyan Qin
7a65f3d2f4 chore: update AGENTS.md 2025-12-12 17:35:02 +08:00
Junyan Qin
b5b5d499e5 feat: add back streaming switch for web chat 2025-12-11 18:54:16 +08:00
Hadong
173f9e9c30 feat(lark): 支持商店应用机器人 (#1855)
* feat(lark): 支持商店应用机器人

* feat(lark): app_type改成select模式,修复select配置无效,按照copilot建议隐藏log敏感信息

* fix: KeyError for backward compatibility

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-11 16:54:28 +08:00
158 changed files with 8916 additions and 3432 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.github
.venv
.vscode
.data
.temp
web/.next
web/node_modules
web/.env

View File

@@ -3,7 +3,6 @@ on:
## 发布release的时候会自动构建 ## 发布release的时候会自动构建
release: release:
types: [published] types: [published]
workflow_dispatch:
jobs: jobs:
publish-docker-image: publish-docker-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -42,7 +41,7 @@ jobs:
run: docker buildx create --name mybuilder --use run: docker buildx create --name mybuilder --use
- name: Build for Release # only relase, exlude pre-release - name: Build for Release # only relase, exlude pre-release
if: ${{ github.event.release.prerelease == false }} if: ${{ github.event.release.prerelease == false }}
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
- name: Build for Pre-release # no update for latest tag - name: Build for Pre-release # no update for latest tag
if: ${{ github.event.release.prerelease == true }} if: ${{ github.event.release.prerelease == true }}
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push

View File

@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ['3.10', '3.11', '3.12'] python-version: ['3.11', '3.12', '3.13']
fail-fast: false fail-fast: false
steps: steps:

View File

@@ -8,16 +8,17 @@ LangBot is a open-source LLM native instant messaging bot development platform,
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts: LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
- `./pkg`: The core python package of the project backend. - `./src/langbot`: The main python package of the project, below are the main modules in this package:
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc. - `./pkg`: The core python package of the project backend.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc. - `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc. - `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services. - `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system. - `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc. - `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./templates`: Templates of config files, components, etc. - `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**. - `./templates`: Templates of config files, components, etc.
- `./docker`: docker-compose deployment files. - `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
## Backend Development ## Backend Development
@@ -69,6 +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. - 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. - 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. - 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.
## Some Principles ## Some Principles

View File

@@ -20,4 +20,4 @@ RUN apt update \
&& uv sync \ && uv sync \
&& touch /.dockerenv && touch /.dockerenv
CMD [ "uv", "run", "main.py" ] CMD [ "uv", "run", "--no-sync", "main.py" ]

View File

@@ -13,16 +13,18 @@
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">项目主页</a> <a href="https://langbot.app">项目主页</a>
<a href="https://docs.langbot.app/zh/insight/features.html">规格特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a> <a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a> <a href="https://docs.langbot.app/zh/tags/readme.html">API 集成</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a> <a href="https://space.langbot.app">插件市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
</div> </div>

View File

@@ -17,9 +17,11 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Home</a> <a href="https://langbot.app">Home</a>
<a href="https://docs.langbot.app/en/insight/features.html">Features</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a> <a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> <a href="https://docs.langbot.app/en/tags/readme.html">API Integration</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a> <a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Inicio</a> <a href="https://langbot.app">Inicio</a>
<a href="https://docs.langbot.app/en/insight/features.html">Características</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Despliegue</a> <a href="https://docs.langbot.app/en/insight/guide.html">Despliegue</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> <a href="https://docs.langbot.app/en/tags/readme.html">Integración API</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Enviar Plugin</a> <a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Accueil</a> <a href="https://langbot.app">Accueil</a>
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Déploiement</a> <a href="https://docs.langbot.app/en/insight/guide.html">Déploiement</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> <a href="https://docs.langbot.app/en/tags/readme.html">Intégration API</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Soumettre un Plugin</a> <a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">ホーム</a> <a href="https://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a> <a href="https://docs.langbot.app/ja/insight/features.html">機能仕様</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a> <a href="https://docs.langbot.app/ja/insight/guide.html">デプロイ</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a> <a href="https://docs.langbot.app/ja/tags/readme.html">API統合</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">홈</a> <a href="https://langbot.app">홈</a>
<a href="https://docs.langbot.app/en/insight/features.html">기능 사양</a>
<a href="https://docs.langbot.app/en/insight/guide.html">배포</a> <a href="https://docs.langbot.app/en/insight/guide.html">배포</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">플러그인</a> <a href="https://docs.langbot.app/en/tags/readme.html">API 통합</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">플러그인 제출</a> <a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Главная</a> <a href="https://langbot.app">Главная</a>
<a href="https://docs.langbot.app/en/insight/features.html">Характеристики</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Развертывание</a> <a href="https://docs.langbot.app/en/insight/guide.html">Развертывание</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Плагин</a> <a href="https://docs.langbot.app/en/tags/readme.html">Интеграция API</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Отправить плагин</a> <a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">主頁</a> <a href="https://langbot.app">主頁</a>
<a href="https://docs.langbot.app/zh/insight/features.html">規格特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文件</a> <a href="https://docs.langbot.app/zh/insight/guide.html">部署文件</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">外掛介紹</a> <a href="https://docs.langbot.app/zh/tags/readme.html">API 整合</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交外掛</a> <a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
</div> </div>

View File

@@ -17,9 +17,11 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Trang chủ</a> <a href="https://langbot.app">Trang chủ</a>
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Triển khai</a> <a href="https://docs.langbot.app/en/insight/guide.html">Triển khai</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> <a href="https://docs.langbot.app/en/tags/readme.html">Tích hợp API</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Gửi Plugin</a> <a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
</div> </div>

View File

@@ -7,7 +7,6 @@ services:
langbot_plugin_runtime: langbot_plugin_runtime:
image: rockchin/langbot:latest image: rockchin/langbot:latest
container_name: langbot_plugin_runtime container_name: langbot_plugin_runtime
platform: linux/amd64 # For Apple Silicon compatibility
volumes: volumes:
- ./data/plugins:/app/data/plugins - ./data/plugins:/app/data/plugins
ports: ports:
@@ -15,14 +14,13 @@ services:
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"] command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
networks: networks:
- langbot_network - langbot_network
langbot: langbot:
image: rockchin/langbot:latest image: rockchin/langbot:latest
container_name: langbot container_name: langbot
platform: linux/amd64 # For Apple Silicon compatibility
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: on-failure restart: on-failure

259
docs/SEEKDB_INTEGRATION.md Normal file
View File

@@ -0,0 +1,259 @@
# SeekDB Vector Database Integration
This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.
## What is SeekDB?
**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.
### Key Features
- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement
- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine
- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory
- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode
- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility
## Installation
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
If you need to install it manually:
```bash
pip install pyseekdb
```
## ⚠️ Platform Compatibility
### Embedded Mode
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
## Configuration
### Embedded Mode (Recommended for Development)
Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.
Edit your `config.yaml`:
```yaml
vdb:
use: seekdb
seekdb:
mode: embedded
path: './data/seekdb' # Path to store SeekDB data
database: 'langbot' # Database name
```
### Server Mode (For Production)
Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.
#### SeekDB Server
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'localhost'
port: 2881
database: 'langbot'
user: 'root'
password: '' # Can also use SEEKDB_PASSWORD env var
```
#### OceanBase Server
If you're using OceanBase with seekdb capabilities:
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'localhost'
port: 2881
tenant: 'sys' # OceanBase tenant name
database: 'langbot'
user: 'root'
password: ''
```
## Configuration Parameters
| Parameter | Required | Default | Description |
|-----------|----------|--------------|-------------|
| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` |
| `path` | No | `./data/seekdb` | Data directory for embedded mode |
| `database` | No | `langbot` | Database name |
| `host` | No | `localhost` | Server host (server mode only) |
| `port` | No | `2881` | Server port (server mode only) |
| `user` | No | `root` | Username (server mode only) |
| `password` | No | `''` | Password (server mode only) |
| `tenant` | No | None | OceanBase tenant (optional, server mode only) |
## Usage
Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot:
1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections
2. **Adding Documents**: Document embeddings will be indexed in SeekDB
3. **Searching**: Vector similarity search will use SeekDB's efficient indexing
4. **Deleting**: Document removal will delete vectors from SeekDB
No code changes are required - just update your configuration!
## Architecture Details
### Implementation
The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.
Key methods:
- `add_embeddings()`: Add vectors with metadata to a collection
- `search()`: Perform vector similarity search
- `delete_by_file_id()`: Delete vectors by file ID metadata
- `get_or_create_collection()`: Manage collections
- `delete_collection()`: Remove entire collections
### Vector Storage
- Collections are created with HNSW (Hierarchical Navigable Small World) index
- Default distance metric: Cosine similarity
- Default vector dimension: 384 (adjusts automatically based on embeddings)
- Metadata is stored alongside vectors for filtering
## Advantages Over Other Vector Databases
### vs. ChromaDB
- ✅ Better MySQL compatibility
- ✅ Hybrid search capabilities (vector + full-text + SQL)
- ✅ Production-grade distributed mode support
- ✅ Lightweight embedded mode
### vs. Qdrant
- ✅ SQL query support
- ✅ MySQL ecosystem integration
- ✅ Simpler deployment (no Docker required for embedded mode)
- ✅ Multi-model data support (not just vectors)
## Troubleshooting
### Import Error
If you see: `ImportError: pyseekdb is not installed`
Solution:
```bash
pip install pyseekdb
```
### Embedded Mode Error on macOS/Windows
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
**Cause**: `pylibseekdb` is only available on Linux platforms.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
2. Configure LangBot to use server mode:
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'your-seekdb-server-ip'
port: 2881
database: 'langbot'
user: 'root'
password: ''
```
**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:
```yaml
vdb:
use: chroma # or qdrant
```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode)
If SeekDB server is not reachable, check:
1. Server is running: `ps aux | grep observer`
2. Port is accessible: `nc -zv localhost 2881`
3. Credentials are correct in config
4. Firewall allows connections on port 2881
### Performance Issues
For large datasets:
- Use server mode instead of embedded mode
- Ensure adequate memory allocation
- Consider using OceanBase distributed mode for very large scale
- Adjust HNSW index parameters if needed
## Resources
- SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://docs.langbot.app
## License
SeekDB is licensed under Apache License 2.0.

View File

@@ -1,10 +1,10 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.6.4" version = "4.7.2"
description = "Easy-to-use global IM bot platform designed for LLM era" description = "Production-grade platform for building IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
requires-python = ">=3.10.1,<4.0" requires-python = ">=3.11,<4.0"
dependencies = [ dependencies = [
"aiocqhttp>=1.4.4", "aiocqhttp>=1.4.4",
"aiofiles>=24.1.0", "aiofiles>=24.1.0",
@@ -23,7 +23,7 @@ dependencies = [
"pynacl>=1.5.0", # Required for Discord voice support "pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5", "gewechat-client>=0.1.5",
"lark-oapi>=1.4.15", "lark-oapi>=1.4.15",
"mcp>=1.8.1", "mcp>=1.20.0",
"nakuru-project-idk>=0.0.2.1", "nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8", "ollama>=0.4.8",
"openai>1.0.0", "openai>1.0.0",
@@ -63,7 +63,8 @@ dependencies = [
"langchain-text-splitters>=0.0.1", "langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24", "chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.2.3", "pyseekdb>=0.1.0",
"langbot-plugin==0.2.4",
"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",

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era""" """LangBot - Production-grade platform for building IM bots"""
__version__ = '4.6.4' __version__ = '4.7.2'

View File

@@ -1,8 +1,11 @@
import asyncio
import base64 import base64
import json import json
import time import time
import urllib.parse
from typing import Callable from typing import Callable
import dingtalk_stream # type: ignore import dingtalk_stream # type: ignore
import websockets
from .EchoHandler import EchoTextHandler from .EchoHandler import EchoTextHandler
from .dingtalkevent import DingTalkEvent from .dingtalkevent import DingTalkEvent
import httpx import httpx
@@ -36,6 +39,7 @@ class DingTalkClient:
self.access_token_expiry_time = '' self.access_token_expiry_time = ''
self.markdown_card = markdown_card self.markdown_card = markdown_card
self.logger = logger self.logger = logger
self._stopped = False # Flag to control the event loop
async def get_access_token(self): async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
@@ -170,6 +174,9 @@ class DingTalkClient:
""" """
处理消息事件。 处理消息事件。
""" """
# Skip message handling if stopped
if self._stopped:
return
msg_type = event.conversation msg_type = event.conversation
if msg_type in self._message_handlers: if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]: for handler in self._message_handlers[msg_type]:
@@ -378,4 +385,70 @@ class DingTalkClient:
async def start(self): async def start(self):
"""启动 WebSocket 连接,监听消息""" """启动 WebSocket 连接,监听消息"""
await self.client.start() self._stopped = False
self.client.pre_start()
while not self._stopped:
try:
connection = self.client.open_connection()
if not connection:
if self.logger:
await self.logger.error('DingTalk: open connection failed')
await asyncio.sleep(10)
continue
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
async with websockets.connect(uri) as websocket:
self.client.websocket = websocket
keepalive_task = asyncio.create_task(self._keepalive(websocket))
try:
async for raw_message in websocket:
if self._stopped:
break
json_message = json.loads(raw_message)
asyncio.create_task(self.client.background_task(json_message))
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
# Properly exit when task is cancelled
break
except websockets.exceptions.ConnectionClosedError as e:
if self._stopped:
break
if self.logger:
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
await asyncio.sleep(5)
continue
except Exception as e:
if self._stopped:
break
if self.logger:
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
await asyncio.sleep(3)
continue
async def _keepalive(self, ws, ping_interval=60):
"""Keep WebSocket connection alive"""
while not self._stopped:
await asyncio.sleep(ping_interval)
try:
await ws.ping()
except websockets.exceptions.ConnectionClosed:
break
async def stop(self):
"""停止 WebSocket 连接"""
self._stopped = True
# Close WebSocket connection if exists
if self.client.websocket:
try:
await self.client.websocket.close()
except Exception:
pass
# Clear message handlers to prevent stale callbacks
self._message_handlers = {'example': []}

View File

@@ -23,12 +23,21 @@ xml_template = """
class OAClient: class OAClient:
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False): def __init__(
self,
token: str,
EncodingAESKey: str,
AppID: str,
Appsecret: str,
logger: None,
unified_mode: bool = False,
api_base_url: str = 'https://api.weixin.qq.com',
):
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
self.appid = AppID self.appid = AppID
self.appsecret = Appsecret self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com' self.base_url = api_base_url
self.access_token = '' self.access_token = ''
self.unified_mode = unified_mode self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
@@ -208,12 +217,13 @@ class OAClientForLongerResponse:
LoadingMessage: str, LoadingMessage: str,
logger: None, logger: None,
unified_mode: bool = False, unified_mode: bool = False,
api_base_url: str = 'https://api.weixin.qq.com',
): ):
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
self.appid = AppID self.appid = AppID
self.appsecret = Appsecret self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com' self.base_url = api_base_url
self.access_token = '' self.access_token = ''
self.unified_mode = unified_mode self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)

View File

@@ -22,13 +22,14 @@ class WecomClient:
contacts_secret: str, contacts_secret: str,
logger: None, logger: None,
unified_mode: bool = False, unified_mode: bool = False,
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
): ):
self.corpid = corpid self.corpid = corpid
self.secret = secret self.secret = secret
self.access_token_for_contacts = '' self.access_token_for_contacts = ''
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.base_url = api_base_url
self.access_token = '' self.access_token = ''
self.secret_for_contacts = contacts_secret self.secret_for_contacts = contacts_secret
self.logger = logger self.logger = logger
@@ -56,7 +57,7 @@ class WecomClient:
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
async def get_access_token(self, secret): async def get_access_token(self, secret):
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}' url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url) response = await client.get(url)
data = response.json() data = response.json()
@@ -196,7 +197,7 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client: async with httpx.AsyncClient(timeout=None) as client:
params = { params = {
'touser': user_id, 'touser': user_id,
'msgtype': 'text', 'msgtype': 'text',

View File

@@ -13,13 +13,22 @@ import aiofiles
class WecomCSClient: class WecomCSClient:
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False): def __init__(
self,
corpid: str,
secret: str,
token: str,
EncodingAESKey: str,
logger: None,
unified_mode: bool = False,
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
):
self.corpid = corpid self.corpid = corpid
self.secret = secret self.secret = secret
self.access_token_for_contacts = '' self.access_token_for_contacts = ''
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.base_url = api_base_url
self.access_token = '' self.access_token = ''
self.logger = logger self.logger = logger
self.unified_mode = unified_mode self.unified_mode = unified_mode
@@ -66,7 +75,7 @@ class WecomCSClient:
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
async def get_access_token(self, secret): async def get_access_token(self, secret):
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}' url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url) response = await client.get(url)
data = response.json() data = response.json()
@@ -172,7 +181,7 @@ class WecomCSClient:
if not await self.check_access_token(): if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
url = f'https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}' url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
payload = { payload = {
'touser': external_userid, 'touser': external_userid,

View File

@@ -49,6 +49,14 @@ class PipelinesRouterGroup(group.RouterGroup):
return self.success() return self.success()
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
try:
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
return self.success(data={'uuid': new_uuid})
except ValueError as e:
return self.http_status(404, -1, str(e))
@self.route( @self.route(
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY '/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
) )

View File

@@ -9,12 +9,15 @@ class LLMModelsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
)
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()}) return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
elif quart.request.method == 'POST': elif quart.request.method == 'POST':
json_data = await quart.request.json json_data = await quart.request.json
model_uuid = await self.ap.llm_model_service.create_llm_model(json_data) model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
return self.success(data={'uuid': model_uuid}) return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
@@ -52,12 +55,19 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
provider_uuid
)
}
)
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()}) return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
elif quart.request.method == 'POST': elif quart.request.method == 'POST':
json_data = await quart.request.json json_data = await quart.request.json
model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data) model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
return self.success(data={'uuid': model_uuid}) return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)

View File

@@ -0,0 +1,45 @@
import quart
from ... import group
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(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':
providers = await self.ap.provider_service.get_providers()
# Add model counts
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
return self.success(data={'providers': providers})
elif quart.request.method == 'POST':
json_data = await quart.request.json
provider_uuid = await self.ap.provider_service.create_provider(json_data)
return self.success(data={'uuid': provider_uuid})
@self.route(
'/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(provider_uuid: str) -> str:
if quart.request.method == 'GET':
provider = await self.ap.provider_service.get_provider(provider_uuid)
if provider is None:
return self.http_status(404, -1, 'provider not found')
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
return self.success(data={'provider': provider})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.provider_service.update_provider(provider_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
try:
await self.ap.provider_service.delete_provider(provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))

View File

@@ -17,11 +17,13 @@ class SystemRouterGroup(group.RouterGroup):
'enable_marketplace', True 'enable_marketplace', True
), ),
'cloud_service_url': ( 'cloud_service_url': (
self.ap.instance_config.data.get('plugin', {}).get( self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')
'cloud_service_url', 'https://space.langbot.app' ),
) 'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(
if 'cloud_service_url' in self.ap.instance_config.data.get('plugin', {}) 'allow_modify_login_info', True
else 'https://space.langbot.app' ),
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
'disable_models_service', False
), ),
} }
) )

View File

@@ -1,8 +1,10 @@
import quart import quart
import argon2 import argon2
import asyncio import asyncio
import traceback
from .. import group from .. import group
from .....entity.errors import account as account_errors
@group.group_class('user', '/api/v1/user') @group.group_class('user', '/api/v1/user')
@@ -33,6 +35,8 @@ class UserRouterGroup(group.RouterGroup):
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
except argon2.exceptions.VerifyMismatchError: except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password') return self.fail(1, 'Invalid username or password')
except ValueError as e:
return self.fail(1, str(e))
return self.success(data={'token': token}) return self.success(data={'token': token})
@@ -70,6 +74,13 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str: async def _(user_email: str) -> str:
# Check if password change is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json json_data = await quart.request.json
current_password = json_data['current_password'] current_password = json_data['current_password']
@@ -83,3 +94,169 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, str(e)) return self.http_status(400, -1, str(e))
return self.success(data={'user': user_email}) return self.success(data={'user': user_email})
# Space OAuth endpoints (redirect flow)
@self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
try:
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except Exception as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Missing authorization code')
try:
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in
)
return self.success(
data={
'token': jwt_token,
'user': user_obj.user,
}
)
except account_errors.AccountEmailMismatchError as e:
return self.fail(3, str(e))
except ValueError as e:
traceback.print_exc()
return self.fail(1, str(e))
except Exception as e:
traceback.print_exc()
return self.fail(2, f'OAuth callback failed: {str(e)}')
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get current user information including account type"""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
return self.success(
data={
'user': user_obj.user,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
}
)
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get Space credits balance for current user"""
credits = await self.ap.space_service.get_credits(user_email)
return self.success(data={'credits': credits})
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
user_obj = await self.ap.user_service.get_first_user()
if user_obj is None:
return self.success(data={'initialized': False})
return self.success(
data={
'initialized': True,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
}
)
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Set password for Space account (first time) or change password"""
json_data = await quart.request.json
new_password = json_data.get('new_password')
current_password = json_data.get('current_password')
if not new_password:
return self.http_status(400, -1, 'New password is required')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
try:
await self.ap.user_service.set_password(user_email, new_password, current_password)
return self.success(data={'user': user_email})
except ValueError as e:
return self.http_status(400, -1, str(e))
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
@self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Bind Space account to existing local account"""
# Check if modifying login info is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state') # JWT token passed as state
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
# Verify state is a valid JWT token
try:
user_email = await self.ap.user_service.verify_jwt_token(state)
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
if user_obj.account_type != 'local':
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
return self.success(
data={
'token': jwt_token,
'user': updated_user.user,
'account_type': updated_user.account_type,
}
)
except ValueError as e:
return self.http_status(400, -1, str(e))
except Exception as e:
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')

View File

@@ -11,6 +11,18 @@ from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester from ....provider.modelmgr import requester as model_requester
def _parse_provider_api_keys(provider_dict: dict) -> dict:
"""Parse api_keys if it's a JSON string"""
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
return provider_dict
class LLMModelsService: class LLMModelsService:
ap: app.Application ap: app.Application
@@ -18,29 +30,72 @@ class LLMModelsService:
self.ap = ap self.ap = ap
async def get_llm_models(self, include_secret: bool = True) -> list[dict]: async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
"""Get all LLM models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
models = result.all() models = result.all()
masked_columns = [] # Get all providers for lookup
if not include_secret: providers_result = await self.ap.persistence_mgr.execute_async(
masked_columns = ['api_keys'] sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
return [ models_list = []
self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model, masked_columns) for model in models:
for model in models model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
] provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
provider_dict = _parse_provider_api_keys(provider_dict)
if not include_secret:
provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
model_dict['provider'] = provider_dict
models_list.append(model_dict)
async def create_llm_model(self, model_data: dict) -> str: return models_list
model_data['uuid'] = str(uuid.uuid4())
async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get LLM models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
async def create_llm_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new LLM model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
# Handle provider creation if needed
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
# Create new provider
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.LLMModel).values(**model_data)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
llm_model = await self.get_llm_model(model_data['uuid']) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_llm_model(llm_model) runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
# check if default pipeline has no model bound # set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True persistence_pipeline.LegacyPipeline.is_default == True
@@ -56,21 +111,47 @@ class LLMModelsService:
return model_data['uuid'] return model_data['uuid']
async def get_llm_model(self, model_uuid: str) -> dict | None: async def get_llm_model(self, model_uuid: str) -> dict | None:
"""Get a single LLM model with provider info"""
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
) )
model = result.first() model = result.first()
if model is None: if model is None:
return None return None
return self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
# Get provider
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_llm_model(self, model_uuid: str, model_data: dict) -> None: async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
if 'uuid' in model_data: if 'uuid' in model_data:
del model_data['uuid'] del model_data['uuid']
# Handle provider update if needed
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( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.LLMModel) sqlalchemy.update(persistence_model.LLMModel)
.where(persistence_model.LLMModel.uuid == model_uuid) .where(persistence_model.LLMModel.uuid == model_uuid)
@@ -79,18 +160,25 @@ class LLMModelsService:
await self.ap.model_mgr.remove_llm_model(model_uuid) await self.ap.model_mgr.remove_llm_model(model_uuid)
llm_model = await self.get_llm_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')
await self.ap.model_mgr.load_llm_model(llm_model) runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
async def delete_llm_model(self, model_uuid: str) -> None: async def delete_llm_model(self, model_uuid: str) -> None:
"""Delete an LLM model"""
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
) )
await self.ap.model_mgr.remove_llm_model(model_uuid) await self.ap.model_mgr.remove_llm_model(model_uuid)
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None: async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
"""Test an LLM model"""
runtime_llm_model: model_requester.RuntimeLLMModel | None = None runtime_llm_model: model_requester.RuntimeLLMModel | None = None
if model_uuid != '_': if model_uuid != '_':
@@ -98,25 +186,18 @@ class LLMModelsService:
if model.model_entity.uuid == model_uuid: if model.model_entity.uuid == model_uuid:
runtime_llm_model = model runtime_llm_model = model
break break
if runtime_llm_model is None: if runtime_llm_model is None:
raise Exception('model not found') raise Exception('model not found')
else: else:
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data) runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)
# Mon Nov 10 2025: Commented for some providers may not support thinking parameter extra_args = model_data.get('extra_args', {})
# # 有些模型厂商默认开启了思考功能,测试容易延迟 await runtime_llm_model.provider.requester.invoke_llm(
# extra_args = model_data.get('extra_args', {})
# if not extra_args or 'thinking' not in extra_args:
# extra_args['thinking'] = {'type': 'disabled'}
await runtime_llm_model.requester.invoke_llm(
query=None, query=None,
model=runtime_llm_model, model=runtime_llm_model,
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')], messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[], funcs=[],
# extra_args=extra_args, extra_args=extra_args,
) )
@@ -127,42 +208,111 @@ class EmbeddingModelsService:
self.ap = ap self.ap = ap
async def get_embedding_models(self) -> list[dict]: async def get_embedding_models(self) -> list[dict]:
"""Get all embedding models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
models = result.all() models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) for model in models]
async def create_embedding_model(self, model_data: dict) -> str: providers_result = await self.ap.persistence_mgr.execute_async(
model_data['uuid'] = str(uuid.uuid4()) 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.EmbeddingModel, 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_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get embedding models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new embedding 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( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data) sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
) )
embedding_model = await self.get_embedding_model(model_data['uuid']) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_embedding_model(embedding_model) runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
return model_data['uuid'] return model_data['uuid']
async def get_embedding_model(self, model_uuid: str) -> dict | None: async def get_embedding_model(self, model_uuid: str) -> dict | None:
"""Get a single embedding model with provider info"""
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where( sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid persistence_model.EmbeddingModel.uuid == model_uuid
) )
) )
model = result.first() model = result.first()
if model is None: if model is None:
return None return None
return self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, 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_embedding_model(self, model_uuid: str, model_data: dict) -> None: async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
if 'uuid' in model_data: if 'uuid' in model_data:
del model_data['uuid'] 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( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.EmbeddingModel) sqlalchemy.update(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.uuid == model_uuid) .where(persistence_model.EmbeddingModel.uuid == model_uuid)
@@ -171,20 +321,27 @@ class EmbeddingModelsService:
await self.ap.model_mgr.remove_embedding_model(model_uuid) await self.ap.model_mgr.remove_embedding_model(model_uuid)
embedding_model = await self.get_embedding_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')
await self.ap.model_mgr.load_embedding_model(embedding_model) runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
async def delete_embedding_model(self, model_uuid: str) -> None: async def delete_embedding_model(self, model_uuid: str) -> None:
"""Delete an embedding model"""
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.EmbeddingModel).where( sqlalchemy.delete(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid persistence_model.EmbeddingModel.uuid == model_uuid
) )
) )
await self.ap.model_mgr.remove_embedding_model(model_uuid) await self.ap.model_mgr.remove_embedding_model(model_uuid)
async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None: async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
"""Test an embedding model"""
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
if model_uuid != '_': if model_uuid != '_':
@@ -192,14 +349,12 @@ class EmbeddingModelsService:
if model.model_entity.uuid == model_uuid: if model.model_entity.uuid == model_uuid:
runtime_embedding_model = model runtime_embedding_model = model
break break
if runtime_embedding_model is None: if runtime_embedding_model is None:
raise Exception('model not found') raise Exception('model not found')
else: else:
runtime_embedding_model = await self.ap.model_mgr.init_runtime_embedding_model(model_data) runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
await runtime_embedding_model.requester.invoke_embedding( await runtime_embedding_model.provider.requester.invoke_embedding(
model=runtime_embedding_model, model=runtime_embedding_model,
input_text=['Hello, world!'], input_text=['Hello, world!'],
extra_args={}, extra_args={},

View File

@@ -151,6 +151,52 @@ class PipelineService:
) )
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
async def copy_pipeline(self, pipeline_uuid: str) -> str:
"""Copy a pipeline with all its configurations"""
# Get the original pipeline
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
)
)
original_pipeline = result.first()
if original_pipeline is None:
raise ValueError(f'Pipeline {pipeline_uuid} not found')
# Create new pipeline data
new_uuid = str(uuid.uuid4())
new_pipeline_data = {
'uuid': new_uuid,
'name': f'{original_pipeline.name} (Copy)',
'description': original_pipeline.description,
'for_version': self.ap.ver_mgr.get_current_version(),
'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),
'config': original_pipeline.config.copy() if original_pipeline.config else {},
'is_default': False,
'extensions_preferences': (
original_pipeline.extensions_preferences.copy()
if original_pipeline.extensions_preferences
else {
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
),
}
# Insert the new pipeline
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data)
)
# Load the new pipeline
pipeline = await self.get_pipeline(new_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
return new_uuid
async def update_pipeline_extensions( async def update_pipeline_extensions(
self, self,
pipeline_uuid: str, pipeline_uuid: str,

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import uuid
import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
class ModelProviderService:
"""Service for managing model providers"""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_providers(self) -> list[dict]:
"""Get all providers"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
providers = result.all()
providers_list = []
for p in providers:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p)
# Parse api_keys if it's a JSON string
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
providers_list.append(provider_dict)
return providers_list
async def get_provider(self, provider_uuid: str) -> dict | None:
"""Get a single provider by UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
provider = result.first()
if provider is None:
return None
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
# Parse api_keys if it's a JSON string
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
return provider_dict
async def create_provider(self, provider_data: dict) -> str:
"""Create a new provider"""
provider_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
return provider_data['uuid']
async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider"""
if 'uuid' in provider_data:
del provider_data['uuid']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.values(**provider_data)
)
await self.ap.model_mgr.reload_provider(provider_uuid)
async def delete_provider(self, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
)
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
await self.ap.model_mgr.remove_provider(provider_uuid)
async def get_provider_model_counts(self, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.LLMModel)
.where(persistence_model.LLMModel.provider_uuid == provider_uuid)
)
llm_count = llm_result.scalar() or 0
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
)
embedding_count = embedding_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one"""
# Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == requester,
persistence_model.ModelProvider.base_url == base_url,
)
)
for provider in result.all():
if sorted(provider.api_keys or []) == sorted(api_keys or []):
return provider.uuid
# Create new provider
provider_name = requester
if base_url:
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
return await self.create_provider(
{
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys or [],
}
)
async def update_space_model_provider_api_keys(self, api_key: str) -> None:
"""Update Space model provider API keys"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=[api_key])
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')

View File

@@ -0,0 +1,189 @@
from __future__ import annotations
import aiohttp
import typing
import datetime
import time
import sqlalchemy
from ....core import app
from ....entity.persistence import user
from ....entity.dto.space_model import SpaceModel
class SpaceService:
"""Service for interacting with LangBot Space API"""
ap: app.Application
_credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)}
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._credits_cache = {}
def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file"""
space_config = self.ap.instance_config.data.get('space', {})
return {
'url': space_config.get('url', 'https://space.langbot.app'),
'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),
}
async def _get_user_by_email(self, user_email: str) -> user.User | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
)
result_list = result.all()
return result_list[0] if result_list else None
async def _ensure_valid_token(self, user_email: str) -> str | None:
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
user_obj = await self._get_user_by_email(user_email)
if not user_obj or user_obj.account_type != 'space':
return None
if not user_obj.space_access_token:
return None
# Check if token is expired (with 60s buffer)
if user_obj.space_access_token_expires_at:
if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60):
# Token expired, try to refresh
if user_obj.space_refresh_token:
try:
new_token = await self._refresh_and_save_token(user_obj)
return new_token
except Exception:
return None
return None
return user_obj.space_access_token
async def _refresh_and_save_token(self, user_obj: user.User) -> str:
"""Refresh token and save to database"""
token_data = await self.refresh_token(user_obj.space_refresh_token)
access_token = token_data.get('access_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
raise ValueError('Failed to refresh token')
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_obj.user)
.values(
space_access_token=access_token,
space_access_token_expires_at=expires_at,
)
)
return access_token
# === Raw API calls (no token validation) ===
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
"""Get the Space OAuth authorization URL for redirect"""
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = f'redirect_uri={redirect_uri}'
if state:
params += f'&state={state}'
return f'{authorize_url}?{params}'
async def exchange_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code, 'instance_id': constants.instance_id},
) as response:
if response.status != 200:
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
return data.get('data', {})
async def refresh_token(self, refresh_token: str) -> typing.Dict:
"""Refresh Space access token"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
) as response:
if response.status != 200:
raise ValueError(f'Failed to refresh token: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
return data.get('data', {})
async def get_user_info_raw(self, access_token: str) -> typing.Dict:
"""Get user info from Space using access token (no validation)"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.get(
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
) as response:
if response.status != 200:
raise ValueError(f'Failed to get user info: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {})
# === API calls with token validation ===
async def get_user_info(self, user_email: str) -> typing.Dict | None:
"""Get user info from Space (with token validation)"""
access_token = await self._ensure_valid_token(user_email)
if not access_token:
return None
return await self.get_user_info_raw(access_token)
async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
"""Get Space credits for user with caching (60s TTL)"""
cache_ttl = 60
if not force_refresh and user_email in self._credits_cache:
credits, ts = self._credits_cache[user_email]
if time.time() - ts < cache_ttl:
return credits
try:
info = await self.get_user_info(user_email)
if info is None:
return None
credits = info.get('credits')
if credits is not None:
self._credits_cache[user_email] = (credits, time.time())
return credits
except Exception:
return self._credits_cache.get(user_email, (None, 0))[0]
async def get_models(self) -> typing.List[SpaceModel]:
"""Get models from Space"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]

View File

@@ -4,17 +4,22 @@ import sqlalchemy
import argon2 import argon2
import jwt import jwt
import datetime import datetime
import typing
import asyncio
from ....core import app from ....core import app
from ....entity.persistence import user from ....entity.persistence import user
from ....utils import constants from ....utils import constants
from ....entity.errors import account as account_errors
class UserService: class UserService:
ap: app.Application ap: app.Application
_create_user_lock: asyncio.Lock
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap self.ap = ap
self._create_user_lock = asyncio.Lock()
async def is_initialized(self) -> bool: async def is_initialized(self) -> bool:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
@@ -28,7 +33,7 @@ class UserService:
hashed_password = ph.hash(password) hashed_password = ph.hash(password)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password) sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
) )
async def get_user_by_email(self, user_email: str) -> user.User | None: async def get_user_by_email(self, user_email: str) -> user.User | None:
@@ -39,6 +44,15 @@ class UserService:
result_list = result.all() result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
"""Get user by Space account UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
)
result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def authenticate(self, user_email: str, password: str) -> str | None: async def authenticate(self, user_email: str, password: str) -> str | None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email) sqlalchemy.select(user.User).where(user.User.user == user_email)
@@ -51,6 +65,10 @@ class UserService:
user_obj = result_list[0] user_obj = result_list[0]
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher() ph = argon2.PasswordHasher()
ph.verify(user_obj.password, password) ph.verify(user_obj.password, password)
@@ -90,6 +108,10 @@ 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 user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password) ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password) hashed_password = ph.hash(new_password)
@@ -97,3 +119,180 @@ class UserService:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
) )
# Space user management
async def create_or_update_space_user(
self,
space_account_uuid: str,
email: str,
access_token: str,
refresh_token: str,
api_key: str,
expires_in: int = 0,
) -> user.User:
"""Create or update a Space user account (only if system not initialized or user exists)"""
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
async with self._create_user_lock:
# Check if user with this Space UUID already exists
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_user:
# Update existing user's tokens
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.space_account_uuid == space_account_uuid)
.values(
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists
existing_email_user = await self.get_user_by_email(email)
if existing_email_user:
# Update existing user to link with Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == email)
.values(
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_email(email)
# Check if system is already initialized
is_initialized = await self.is_initialized()
if is_initialized:
raise account_errors.AccountEmailMismatchError()
# Create new Space user (first time initialization)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=email,
password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
async def authenticate_space_user(
self, access_token: str, refresh_token: str, expires_in: int = 0
) -> typing.Tuple[str, user.User]:
"""Authenticate with Space and return JWT token"""
# Get user info from Space using raw API (token just obtained, no need to validate)
user_info = await self.ap.space_service.get_user_info_raw(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
email = account.get('email')
if not space_account_uuid or not email:
raise ValueError('Invalid Space user info')
# Create or update Space user in local database
user_obj = await self.create_or_update_space_user(
space_account_uuid=space_account_uuid,
email=email,
access_token=access_token,
refresh_token=refresh_token,
api_key=api_key,
expires_in=expires_in,
)
# Generate JWT token
jwt_token = await self.generate_jwt_token(email)
return jwt_token, user_obj
async def get_first_user(self) -> user.User | None:
"""Get the first user (for single-user mode)"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
result_list = result.all()
return result_list[0] if result_list else None
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
"""Set or change password for a user"""
ph = argon2.PasswordHasher()
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('User not found')
# If user already has a password, verify current password
has_password = bool(user_obj.password and user_obj.password.strip())
if has_password:
if not current_password:
raise ValueError('Current password is required')
ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
raise ValueError('Failed to get access token from Space')
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
# Get Space user info (token just obtained, use raw API)
user_info = await self.ap.space_service.get_user_info_raw(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
space_email = account.get('email')
if not space_account_uuid or not space_email:
raise ValueError('Invalid Space user info')
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.user != user_email:
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_email)
.values(
user=space_email, # Update email to Space email
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
return await self.get_user_by_email(space_email)

View File

@@ -19,7 +19,9 @@ from ..utils import version as version_mgr, proxy as proxy_mgr
from ..persistence import mgr as persistencemgr from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service from ..api.http.service import user as user_service
from ..api.http.service import space as space_service
from ..api.http.service import model as model_service from ..api.http.service import model as model_service
from ..api.http.service import provider as provider_service
from ..api.http.service import pipeline as pipeline_service from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service from ..api.http.service import bot as bot_service
from ..api.http.service import knowledge as knowledge_service from ..api.http.service import knowledge as knowledge_service
@@ -34,6 +36,7 @@ from . import taskmgr
from . import entities as core_entities from . import entities as core_entities
from ..rag.knowledge import kbmgr as rag_mgr from ..rag.knowledge import kbmgr as rag_mgr
from ..vector import mgr as vectordb_mgr from ..vector import mgr as vectordb_mgr
from ..telemetry import telemetry as telemetry_module
class Application: class Application:
@@ -75,6 +78,8 @@ class Application:
instance_config: config_mgr.ConfigManager = None instance_config: config_mgr.ConfigManager = None
instance_id: config_mgr.ConfigManager = None # used to identify the instance
# ======= Metadata config manager ======= # ======= Metadata config manager =======
sensitive_meta: config_mgr.ConfigManager = None sensitive_meta: config_mgr.ConfigManager = None
@@ -114,10 +119,14 @@ class Application:
user_service: user_service.UserService = None user_service: user_service.UserService = None
space_service: space_service.SpaceService = None
llm_model_service: model_service.LLMModelsService = None llm_model_service: model_service.LLMModelsService = None
embedding_models_service: model_service.EmbeddingModelsService = None embedding_models_service: model_service.EmbeddingModelsService = None
provider_service: provider_service.ModelProviderService = None
pipeline_service: pipeline_service.PipelineService = None pipeline_service: pipeline_service.PipelineService = None
bot_service: bot_service.BotService = None bot_service: bot_service.BotService = None
@@ -132,6 +141,8 @@ class Application:
webhook_service: webhook_service.WebhookService = None webhook_service: webhook_service.WebhookService = None
telemetry: telemetry_module.TelemetryManager = None
def __init__(self): def __init__(self):
pass pass

View File

@@ -1,4 +1,5 @@
import logging import logging
import logging.handlers
import sys import sys
import time import time
@@ -15,6 +16,10 @@ log_colors_config = {
'CRITICAL': 'cyan', 'CRITICAL': 'cyan',
} }
# Log rotation configuration to prevent unbounded log file growth
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
# Remove all existing loggers # Remove all existing loggers
@@ -43,9 +48,17 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
# stream_handler.setFormatter(color_formatter) # stream_handler.setFormatter(color_formatter)
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
# Use RotatingFileHandler to prevent unbounded log file growth
rotating_file_handler = logging.handlers.RotatingFileHandler(
log_file_name,
encoding='utf-8',
maxBytes=LOG_FILE_MAX_BYTES,
backupCount=LOG_FILE_BACKUP_COUNT,
)
log_handlers: list[logging.Handler] = [ log_handlers: list[logging.Handler] = [
stream_handler, stream_handler,
logging.FileHandler(log_file_name, encoding='utf-8'), rotating_file_handler,
] ]
log_handlers += extra_handlers if extra_handlers is not None else [] log_handlers += extra_handlers if extra_handlers is not None else []

View File

@@ -16,7 +16,9 @@ from ...platform.webhook_pusher import WebhookPusher
from ...persistence import mgr as persistencemgr from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service from ...api.http.service import user as user_service
from ...api.http.service import space as space_service
from ...api.http.service import model as model_service from ...api.http.service import model as model_service
from ...api.http.service import provider as provider_service
from ...api.http.service import pipeline as pipeline_service from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_service from ...api.http.service import knowledge as knowledge_service
@@ -29,6 +31,8 @@ from ...storage import mgr as storagemgr
from ...utils import logcache from ...utils import logcache
from ...vector import mgr as vectordb_mgr from ...vector import mgr as vectordb_mgr
from .. import taskmgr from .. import taskmgr
from ...telemetry import telemetry as telemetry_module
@stage.stage_class('BuildAppStage') @stage.stage_class('BuildAppStage')
@@ -43,6 +47,42 @@ class BuildAppStage(stage.BootingStage):
discover.discover_blueprint('templates/components.yaml') discover.discover_blueprint('templates/components.yaml')
ap.discover = discover ap.discover = discover
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
space_service_inst = space_service.SpaceService(ap)
ap.space_service = space_service_inst
llm_model_service_inst = model_service.LLMModelsService(ap)
ap.llm_model_service = llm_model_service_inst
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
provider_service_inst = provider_service.ModelProviderService(ap)
ap.provider_service = provider_service_inst
pipeline_service_inst = pipeline_service.PipelineService(ap)
ap.pipeline_service = pipeline_service_inst
bot_service_inst = bot_service.BotService(ap)
ap.bot_service = bot_service_inst
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
ap.external_kb_service = external_kb_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
proxy_mgr = proxy.ProxyManager(ap) proxy_mgr = proxy.ProxyManager(ap)
await proxy_mgr.initialize() await proxy_mgr.initialize()
ap.proxy_mgr = proxy_mgr ap.proxy_mgr = proxy_mgr
@@ -64,13 +104,18 @@ class BuildAppStage(stage.BootingStage):
ap.persistence_mgr = persistence_mgr_inst ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize() await persistence_mgr_inst.initialize()
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
telemetry_inst = telemetry_module.TelemetryManager(ap)
await telemetry_inst.initialize()
ap.telemetry = telemetry_inst
cmd_mgr_inst = cmdmgr.CommandManager(ap) cmd_mgr_inst = cmdmgr.CommandManager(ap)
await cmd_mgr_inst.initialize() await cmd_mgr_inst.initialize()
ap.cmd_mgr = cmd_mgr_inst ap.cmd_mgr = cmd_mgr_inst
llm_model_mgr_inst = llm_model_mgr.ModelManager(ap) llm_model_mgr_inst = llm_model_mgr.ModelManager(ap)
await llm_model_mgr_inst.initialize()
ap.model_mgr = llm_model_mgr_inst ap.model_mgr = llm_model_mgr_inst
await llm_model_mgr_inst.initialize()
llm_session_mgr_inst = llm_session_mgr.SessionManager(ap) llm_session_mgr_inst = llm_session_mgr.SessionManager(ap)
await llm_session_mgr_inst.initialize() await llm_session_mgr_inst.initialize()
@@ -105,36 +150,6 @@ class BuildAppStage(stage.BootingStage):
await http_ctrl.initialize() await http_ctrl.initialize()
ap.http_ctrl = http_ctrl ap.http_ctrl = http_ctrl
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
llm_model_service_inst = model_service.LLMModelsService(ap)
ap.llm_model_service = llm_model_service_inst
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
pipeline_service_inst = pipeline_service.PipelineService(ap)
ap.pipeline_service = pipeline_service_inst
bot_service_inst = bot_service.BotService(ap)
ap.bot_service = bot_service_inst
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
ap.external_kb_service = external_kb_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3) await asyncio.sleep(3)
await plugin_connector_inst.initialize() await plugin_connector_inst.initialize()

View File

@@ -2,8 +2,11 @@ from __future__ import annotations
import os import os
from typing import Any from typing import Any
from langbot.pkg.utils import constants
import yaml import yaml
import importlib.resources as resources import importlib.resources as resources
import uuid
import time
from .. import stage, app from .. import stage, app
from ..bootutils import config from ..bootutils import config
@@ -142,6 +145,22 @@ class LoadConfigStage(stage.BootingStage):
await ap.instance_config.dump_config() await ap.instance_config.dump_config()
# load or generate instance id
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': f'instance_{str(uuid.uuid4())}',
'instance_create_ts': int(time.time()),
},
completion=False,
)
constants.instance_id = ap.instance_id.data['instance_id']
print(f'LangBot instance id: {constants.instance_id}')
await ap.instance_id.dump_config()
ap.sensitive_meta = await config.load_json_config( ap.sensitive_meta = await config.load_json_config(
'data/metadata/sensitive-words.json', 'data/metadata/sensitive-words.json',
'metadata/sensitive-words.json', 'metadata/sensitive-words.json',

View File

View File

@@ -0,0 +1,49 @@
# [
# {
# "uuid": "7652ebdb-54dc-412c-a830-e9268ac88471",
# "model_id": "claude-opus-4-5-20251101",
# "display_name": {
# "en_US": "claude-opus-4-5-20251101",
# "zh_Hans": "claude-opus-4-5-20251101"
# },
# "description": {},
# "provider": "anthropic",
# "category": "chat",
# "icon_url": "Claude.Color",
# "tags": {},
# "is_featured": true,
# "featured_order": 999,
# "model_ratio": 2.5,
# "completion_ratio": 5,
# "quota_type": 0,
# "model_price": 0,
# "input_credits": 500,
# "output_credits": 2500,
# "vendor_id": 1,
# "vendor_name": "Anthropic",
# "vendor_icon": "Claude.Color",
# "supported_endpoints": [
# "anthropic",
# "openai"
# ],
# "status": "active",
# "metadata": null,
# "created_at": "2025-12-30T22:23:38.337207+08:00",
# "updated_at": "2025-12-30T22:23:38.337207+08:00"
# }
# ]
import pydantic
class SpaceModel(pydantic.BaseModel):
uuid: str
model_id: str
provider: str
category: str # chat / embedding
llm_abilities: list[str] | None = None
is_featured: bool = False
featured_order: int = 0
status: str
created_at: str | None = None
updated_at: str | None = None

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
class AccountEmailMismatchError(Exception):
def __str__(self):
return 'Account email mismatch'

View File

@@ -7,3 +7,11 @@ class RequesterNotFoundError(Exception):
def __str__(self): def __str__(self):
return f'Requester {self.requester_name} not found' return f'Requester {self.requester_name} not found'
class ProviderNotFoundError(Exception):
def __init__(self, provider_name: str):
self.provider_name = provider_name
def __str__(self):
return f'Provider {self.provider_name} not found'

View File

@@ -9,7 +9,7 @@ class MCPServer(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(

View File

@@ -3,6 +3,25 @@ import sqlalchemy
from .base import Base from .base import Base
class ModelProvider(Base):
"""Model provider"""
__tablename__ = 'model_providers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
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(),
)
class LLMModel(Base): class LLMModel(Base):
"""LLM model""" """LLM model"""
@@ -10,12 +29,10 @@ class LLMModel(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) 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()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,
@@ -26,17 +43,15 @@ class LLMModel(Base):
class EmbeddingModel(Base): class EmbeddingModel(Base):
"""Embedding 模型""" """Embedding model"""
__tablename__ = 'embedding_models' __tablename__ = 'embedding_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) 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()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,

View File

@@ -9,6 +9,17 @@ class User(Base):
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
# Account type: 'local' (default) or 'space'
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
# Space account fields (nullable, only used when account_type='space')
space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,

View File

@@ -9,7 +9,7 @@ import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy import sqlalchemy
from . import database, migration from . import database, migration
from ..entity.persistence import base, pipeline, metadata from ..entity.persistence import base, pipeline, metadata, model as persistence_model
from ..entity import persistence from ..entity import persistence
from ..core import app from ..core import app
from ..utils import constants, importutil from ..utils import constants, importutil
@@ -79,6 +79,7 @@ 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}.')
await self.write_default_pipeline() await self.write_default_pipeline()
await self.write_space_model_providers()
async def create_tables(self): async def create_tables(self):
# create tables # create tables
@@ -123,7 +124,42 @@ class PersistenceManager:
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data)) await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
# ================================= async def write_space_model_providers(self):
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
)
# write space model providers
result = await self.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == 'space-chat-completions'
)
)
exists_space_chat_completions_model_provider = result.first()
# api keys will be set/updated when the oauth callback
if exists_space_chat_completions_model_provider is None:
self.ap.logger.info('Creating space model providers...')
space_chat_completions_model_provider = {
'uuid': '00000000-0000-0000-0000-000000000000',
'name': 'LangBot Models',
'requester': 'space-chat-completions',
'base_url': space_models_gateway_api_url,
'api_keys': [],
}
await self.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider)
)
else:
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
await self.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
.values({'base_url': space_models_gateway_api_url})
)
# =================================
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:

View File

@@ -0,0 +1,94 @@
import sqlalchemy
from .. import migration
@migration.migration_class(14)
class DBMigrateSpaceAccountSupport(migration.DBMigration):
"""Add Space account support fields to users table"""
async def upgrade(self):
"""Upgrade"""
# Get all column names from the users table
columns = []
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
)
all_result = result.fetchall()
columns = [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))
all_result = result.fetchall()
columns = [row[1] for row in all_result]
# Add account_type column
if 'account_type' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
# Add space_account_uuid column
if 'space_account_uuid' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
# Add space_access_token column
if 'space_access_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
# Add space_refresh_token column
if 'space_refresh_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
# Add space_access_token_expires_at column
if 'space_access_token_expires_at' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME')
)
# Add space_api_key column
if 'space_api_key' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,15 @@
from .. import migration
# this is a deprecated migration
@migration.migration_class(15)
class DBMigrateModelSourceTracking(migration.DBMigration):
"""Add source tracking fields to models tables for Space integration"""
async def upgrade(self):
"""Upgrade"""
pass
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,305 @@
import uuid as uuid_lib
import sqlalchemy
from .. import migration
@migration.migration_class(16)
class DBMigrateModelProviderRefactor(migration.DBMigration):
"""Refactor model structure: create providers from existing models and update references"""
async def upgrade(self):
"""Upgrade"""
# Step 1: Create model_providers table if not exists
await self._create_providers_table()
# Step 2: Migrate existing models to use providers
await self._migrate_llm_models()
await self._migrate_embedding_models()
# Step 3: Remove deprecated columns
await self._cleanup_columns()
async def _create_providers_table(self):
"""Create model_providers table"""
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS model_providers (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
requester VARCHAR(255) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_keys JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS model_providers (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
requester VARCHAR(255) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_keys JSON NOT NULL DEFAULT '[]',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
async def _migrate_llm_models(self):
"""Migrate LLM models to use providers"""
llm_columns = await self._get_columns('llm_models')
# Add provider_uuid column if not exists
if 'provider_uuid' not in llm_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)')
)
# Add prefered_ranking column if not exists
if 'prefered_ranking' not in llm_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
)
# Only migrate if old columns exist
if 'requester' not in llm_columns:
return
# Get all LLM models with old structure
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models')
)
models = result.fetchall()
# Create providers and update models
provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid
for model in models:
model_uuid, model_name, requester, requester_config, api_keys = model
# Extract base_url from requester_config
base_url = ''
if requester_config:
if isinstance(requester_config, str):
import json
requester_config = json.loads(requester_config)
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
# Parse api_keys if it's a string
if isinstance(api_keys, str):
import json
try:
api_keys = json.loads(api_keys)
except Exception:
api_keys = []
if not api_keys:
api_keys = []
# Create cache key
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
cache_key = (requester, base_url, api_keys_str)
if cache_key in provider_cache:
provider_uuid = provider_cache[cache_key]
else:
# Create new provider
provider_uuid = str(uuid_lib.uuid4())
provider_name = f'{requester}'
if base_url:
# Extract domain for name
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
import json
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
"""),
{
'uuid': provider_uuid,
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys_json,
},
)
provider_cache[cache_key] = provider_uuid
# Update model with provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
)
async def _migrate_embedding_models(self):
"""Migrate embedding models to use providers"""
embedding_columns = await self._get_columns('embedding_models')
# Add provider_uuid column if not exists
if 'provider_uuid' not in embedding_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)')
)
# Add prefered_ranking column if not exists
if 'prefered_ranking' not in embedding_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
)
# Only migrate if old columns exist
if 'requester' not in embedding_columns:
return
# Get all embedding models with old structure
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models')
)
models = result.fetchall()
# Get existing providers
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers')
)
existing_providers = provider_result.fetchall()
provider_cache = {}
for p in existing_providers:
p_uuid, p_requester, p_base_url, p_api_keys = p
api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]'
provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid
for model in models:
model_uuid, model_name, requester, requester_config, api_keys = model
base_url = ''
if requester_config:
if isinstance(requester_config, str):
import json
requester_config = json.loads(requester_config)
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
# Parse api_keys if it's a string
if isinstance(api_keys, str):
import json
try:
api_keys = json.loads(api_keys)
except Exception:
api_keys = []
if not api_keys:
api_keys = []
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
cache_key = (requester, base_url, api_keys_str)
if cache_key in provider_cache:
provider_uuid = provider_cache[cache_key]
else:
provider_uuid = str(uuid_lib.uuid4())
provider_name = f'{requester}'
if base_url:
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
import json
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
"""),
{
'uuid': provider_uuid,
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys_json,
},
)
provider_cache[cache_key] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
)
async def _cleanup_columns(self):
"""Remove deprecated columns from model tables"""
llm_columns = await self._get_columns('llm_models')
deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id']
for col in deprecated_llm_cols:
if col in llm_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}')
)
embedding_columns = await self._get_columns('embedding_models')
deprecated_embedding_cols = [
'requester',
'requester_config',
'api_keys',
'description',
'source',
'space_model_id',
]
for col in deprecated_embedding_cols:
if col in embedding_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}')
)
async def _get_columns(self, table_name: str) -> list:
"""Get column names for a table"""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
)
)
all_result = result.fetchall()
return [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
all_result = result.fetchall()
return [row[1] for row in all_result]
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,25 @@
from .. import migration
@migration.migration_class(17)
class MoveCloudServiceUrl(migration.DBMigration):
"""迁移云服务 URL 配置"""
async def upgrade(self):
"""升级"""
if 'space' not in self.ap.instance_config.data:
self.ap.instance_config.data['space'] = {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
'disable_models_service': False,
}
if 'plugin' in self.ap.instance_config.data:
self.ap.instance_config.data['plugin'].pop('cloud_service_url', None)
await self.ap.instance_config.dump_config()
async def downgrade(self):
"""降级"""
pass

View File

@@ -33,11 +33,14 @@ class Controller:
for query in queries: for query in queries:
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
self.ap.logger.debug(f'Checking query {query} session {session}') # Debug logging removed from tight loop to prevent excessive log generation
# that can cause memory overflow in high-traffic scenarios
if not session._semaphore.locked(): if not session._semaphore.locked():
selected_query = query selected_query = query
await session._semaphore.acquire() await session._semaphore.acquire()
# Only log when actually selecting a query
self.ap.logger.debug(f'Selected query {query.query_id} for processing')
break break

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import uuid import uuid
import typing import typing
import traceback import traceback
import time
from datetime import datetime
from .. import handler from .. import handler
@@ -10,10 +12,11 @@ from ... import entities
from ....provider import runner as runner_module from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
from ....utils import importutil from ....utils import importutil, constants
from ....provider import runners from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
importutil.import_modules_in_pkg(runners) importutil.import_modules_in_pkg(runners)
@@ -61,8 +64,14 @@ class ChatMessageHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else: else:
if event_ctx.event.user_message_alter is not None: if event_ctx.event.user_message_alter is not None:
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter if isinstance(event_ctx.event.user_message_alter, list):
query.user_message.content = event_ctx.event.user_message_alter query.user_message.content = event_ctx.event.user_message_alter
elif isinstance(event_ctx.event.user_message_alter, str):
query.user_message.content = [
provider_message.ContentElement.from_text(event_ctx.event.user_message_alter)
]
elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement):
query.user_message.content = [event_ctx.event.user_message_alter]
text_length = 0 text_length = 0
try: try:
@@ -77,8 +86,12 @@ class ChatMessageHandler(handler.MessageHandler):
break break
else: else:
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
# Mark start time for telemetry
start_ts = time.time()
if is_stream: if is_stream:
resp_message_id = uuid.uuid4() resp_message_id = uuid.uuid4()
chunk_count = 0 # Track streaming chunks to reduce excessive logging
async for result in runner.run(query): async for result in runner.run(query):
result.resp_message_id = str(resp_message_id) result.resp_message_id = str(resp_message_id)
@@ -91,15 +104,30 @@ class ChatMessageHandler(handler.MessageHandler):
await query.adapter.create_message_card(str(resp_message_id), query.message_event) await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True is_create_card = True
query.resp_messages.append(result) query.resp_messages.append(result)
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming Response: {self.cut_str(result.readable_str())}' chunk_count += 1
) # Only log every 10th chunk to reduce excessive logging during streaming
# This prevents memory overflow from thousands of log entries per conversation
# First chunk uses INFO level to confirm connection establishment
if chunk_count == 1:
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
)
elif chunk_count % 10 == 0:
self.ap.logger.debug(
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
)
if result.content is not None: if result.content is not None:
text_length += len(result.content) text_length += len(result.content)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
# Log final summary after streaming completes
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
)
else: else:
async for result in runner.run(query): async for result in runner.run(query):
query.resp_messages.append(result) query.resp_messages.append(result)
@@ -117,7 +145,8 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.extend(query.resp_messages) query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e: except Exception as e:
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}') error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
traceback.print_exc() traceback.print_exc()
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception'] hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
@@ -130,5 +159,47 @@ class ChatMessageHandler(handler.MessageHandler):
debug_notice=traceback.format_exc(), debug_notice=traceback.format_exc(),
) )
finally: finally:
# TODO statistics # Telemetry reporting: collect minimal per-query execution info and send asynchronously
pass try:
end_ts = time.time()
duration_ms = None
if 'start_ts' in locals():
duration_ms = int((end_ts - start_ts) * 1000)
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
runner_name = (
query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')
if query.pipeline_config
else None
)
# Model name if using localagent
model_name = None
try:
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
if m and getattr(m, 'model_entity', None):
model_name = getattr(m.model_entity, 'name', None)
except Exception:
model_name = None
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
payload = {
'query_id': query.query_id,
'adapter': adapter_name,
'runner': runner_name,
'duration_ms': duration_ms,
'model_name': model_name,
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'pipeline_plugins': pipeline_plugins,
'error': locals().get('error_info', None),
'timestamp': datetime.utcnow().isoformat(),
}
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
await self.ap.telemetry.start_send_task(payload)
except Exception as ex:
# Ensure telemetry issues do not affect normal flow
self.ap.logger.warning(f'Failed to send telemetry: {ex}')

View File

@@ -31,4 +31,8 @@ class AtBotRule(rule_model.GroupRespondRule):
remove_at(message_chain) remove_at(message_chain)
remove_at(message_chain) # 回复消息时会at两次检查并删除重复的 remove_at(message_chain) # 回复消息时会at两次检查并删除重复的
should_respond_at = rule_dict.get('at', None)
if should_respond_at is not None:
return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain)
return entities.RuleJudgeResult(matching=found, replacement=message_chain) return entities.RuleJudgeResult(matching=found, replacement=message_chain)

View File

@@ -260,7 +260,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.start() await self.bot.start()
async def kill(self) -> bool: async def kill(self) -> bool:
return False await self.bot.stop()
return True
async def is_muted(self) -> bool: async def is_muted(self) -> bool:
return False return False

View File

@@ -9,9 +9,13 @@ import re
import base64 import base64
import uuid import uuid
import json import json
import time
import datetime import datetime
import hashlib import hashlib
from Crypto.Cipher import AES from Crypto.Cipher import AES
import tempfile
import os
import mimetypes
import aiohttp import aiohttp
import lark_oapi.ws.exception import lark_oapi.ws.exception
@@ -19,6 +23,8 @@ import quart
from lark_oapi.api.im.v1 import * from lark_oapi.api.im.v1 import *
import pydantic import pydantic
from lark_oapi.api.cardkit.v1 import * from lark_oapi.api.cardkit.v1 import *
from lark_oapi.api.auth.v3 import *
from lark_oapi.core.model import *
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -238,6 +244,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time)) lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))
if message.message_type == 'text': if message.message_type == 'text':
element_list = [] element_list = []
@@ -301,6 +308,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_content['content'] = [ message_content['content'] = [
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']} {'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
] ]
elif message.message_type == 'audio':
message_content['content'] = [
{'tag': 'audio', 'file_key': message_content['file_key'], "duration": message_content.get('duration',0)}
]
for ele in message_content['content']: for ele in message_content['content']:
if ele['tag'] == 'text': if ele['tag'] == 'text':
@@ -331,6 +342,60 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_format = response.raw.headers['content-type'] image_format = response.raw.headers['content-type']
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
elif ele['tag'] == 'audio':
file_key = ele['file_key']
duration = ele['duration']
# Download audio file
request: GetMessageResourceRequest = (
GetMessageResourceRequest.builder()
.message_id(message.message_id)
.file_key(file_key)
.type('file')
.build()
)
try:
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
print(f'Failed to download audio: code: {response.code}, msg: {response.msg}')
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
return platform_message.MessageChain(lb_msg_list)
# Read audio bytes
audio_bytes = response.file.read()
audio_base64 = base64.b64encode(audio_bytes).decode()
# Get content type from response headers
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
mime_main = content_type.split(';')[0].strip()
ext = mimetypes.guess_extension(mime_main) or '.bin'
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
with open(temp_file_path, 'wb') as f:
f.write(audio_bytes)
# Create Voice message: prefer path/url + length, include base64 as optional data URI
lb_msg_list.append(
platform_message.Voice(
voice_id=file_key,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{content_type};base64,{audio_base64}',
length=(duration // 1000) if duration else None,
)
)
except Exception as e:
print(f'Error downloading audio: {e}')
traceback.print_exc()
lb_msg_list.append(platform_message.Plain(text='[Audio file download error]'))
elif ele['tag'] == 'file': elif ele['tag'] == 'file':
file_key = ele['file_key'] file_key = ele['file_key']
file_name = ele['file_name'] file_name = ele['file_name']
@@ -353,12 +418,42 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
file_bytes = response.file.read() file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode() file_base64 = base64.b64encode(file_bytes).decode()
file_format = response.raw.headers['content-type'] file_format = response.raw.headers['content-type']
file_size = len(file_bytes)
# Determine extension from content-type if possible
content_type = response.raw.headers.get('content-type', '')
mime_main = content_type.split(';')[0].strip() if content_type else ''
ext = mimetypes.guess_extension(mime_main) or ''
# Ensure a safe filename (avoid path components)
safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
if ext and not safe_name.lower().endswith(ext.lower()):
filename_with_ext = f'{safe_name}{ext}'
else:
filename_with_ext = safe_name
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
with open(temp_file_path, 'wb') as f:
f.write(file_bytes)
# Create File message with local path and file:// URL
lb_msg_list.append( lb_msg_list.append(
platform_message.File(base64=f'data:{file_format};base64,{file_base64}', name=file_name) platform_message.File(
id=file_key,
name=file_name,
size=file_size,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
)
) )
return platform_message.MessageChain(lb_msg_list) return platform_message.MessageChain(lb_msg_list)
@@ -384,6 +479,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.event.message.create_time, time=event.event.message.create_time,
source_platform_object=event,
) )
elif event.event.message.chat_type == 'group': elif event.event.message.chat_type == 'group':
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -400,6 +496,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.event.message.create_time, time=event.event.message.create_time,
source_platform_object=event,
) )
@@ -429,6 +526,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识 seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
app_access_token: str = None # 商店应用用到
app_access_token_expire_at: int = None
tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
@@ -448,8 +549,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id = config['bot_name'] bot_account_id = config['bot_name']
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build() api_client = self.build_api_client(config)
cipher = AESCipher(config.get('encrypt-key', '')) cipher = AESCipher(config.get('encrypt-key', ''))
self.request_app_ticket(api_client, config)
super().__init__( super().__init__(
config=config, config=config,
@@ -466,6 +568,101 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**kwargs, **kwargs,
) )
def request_app_ticket(self, api_client, config):
app_id = config['app_id']
app_secret = config['app_secret']
print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}')
if 'isv' == config.get('app_type', 'self'):
request: ResendAppTicketRequest = (
ResendAppTicketRequest.builder()
.request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build())
.build()
)
response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.app_ticket_resend 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)}'
)
def request_app_access_token(self):
app_id = self.config['app_id']
app_secret = self.config['app_secret']
if 'isv' == self.config.get('app_type', 'self'):
request: CreateAppAccessTokenRequest = (
CreateAppAccessTokenRequest.builder()
.request_body(
CreateAppAccessTokenRequestBody.builder()
.app_id(app_id)
.app_secret(app_secret)
.app_ticket(self.app_ticket)
.build()
)
.build()
)
response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.app_access_token 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)}'
)
content = json.loads(response.raw.content)
self.app_access_token = content['app_access_token']
self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300
def get_app_access_token(self):
if 'isv' != self.config.get('app_type', 'self'):
return None
if (
self.app_access_token is None
or self.app_access_token_expire_at is None
or int(time.time()) >= self.app_access_token_expire_at
):
self.request_app_access_token()
return self.app_access_token
def request_tenant_access_token(self, tenant_key: str):
app_access_token = self.get_app_access_token()
if 'isv' == self.config.get('app_type', 'self'):
request: CreateTenantAccessTokenRequest = (
CreateTenantAccessTokenRequest.builder()
.request_body(
CreateTenantAccessTokenRequestBody.builder()
.app_access_token(app_access_token)
.tenant_key(tenant_key)
.build()
)
.build()
)
response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.tenant_access_token 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)}'
)
content = json.loads(response.raw.content)
tenant_access_token = content['tenant_access_token']
expire = content['expire']
self.tenant_access_tokens[tenant_key] = {
'token': tenant_access_token,
'expire_at': int(time.time()) + expire - 300,
}
def get_tenant_access_token(self, tenant_key: str):
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
return None
tenant_access_token = self.tenant_access_tokens.get(tenant_key)
if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']:
self.request_tenant_access_token(tenant_key)
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
def build_api_client(self, config):
app_id = config['app_id']
app_secret = config['app_secret']
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
if 'isv' == config.get('app_type', 'self'):
api_client = (
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
)
return api_client
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass pass
@@ -693,9 +890,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
.build() .build()
) )
tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
# 发起请求 # 发起请求
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
# 处理失败返回 # 处理失败返回
if not response.success(): if not response.success():
@@ -722,7 +929,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': text_elements, 'content': text_elements,
}, },
} }
request: ReplyMessageRequest = ( request: ReplyMessageRequest = (
ReplyMessageRequest.builder() ReplyMessageRequest.builder()
.message_id(message_source.message_chain.message_id) .message_id(message_source.message_chain.message_id)
@@ -737,7 +943,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build() .build()
) )
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
if not response.success(): if not response.success():
raise Exception( raise Exception(
@@ -762,7 +983,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build() .build()
) )
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
if not response.success(): if not response.success():
raise Exception( raise Exception(
@@ -816,8 +1052,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if is_final and bot_message.tool_calls is None: if is_final and bot_message.tool_calls is None:
# self.seq = 1 # 消息回复结束之后重置seq # self.seq = 1 # 消息回复结束之后重置seq
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片 self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
# 发起请求 # 发起请求
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request) response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)
# 处理失败返回 # 处理失败返回
if not response.success(): if not response.success():
@@ -851,6 +1103,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL""" """设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid self.bot_uuid = bot_uuid
def get_event_type(self, data):
schema = '1.0'
if 'schema' in data:
schema = data['schema']
if '2.0' == schema:
return data['header']['event_type']
elif 'event' in data:
return data['event']['type']
else:
return data['type']
async def handle_unified_webhook(self, bot_uuid: str, path: str, request): async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。 """处理统一 webhook 请求。
Args: Args:
@@ -866,21 +1129,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if 'encrypt' in data: if 'encrypt' in data:
data = self.cipher.decrypt_string(data['encrypt']) data = self.cipher.decrypt_string(data['encrypt'])
data = json.loads(data) data = json.loads(data)
type = data.get('type') type = self.get_event_type(data)
if type is None: context = EventContext(data)
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type: if 'url_verification' == type:
# todo 验证verification token # todo 验证verification token
return {'challenge': data.get('challenge')} return {'challenge': data.get('challenge')}
context = EventContext(data) elif 'app_ticket' == type:
type = context.header.event_type self.app_ticket = context.event['app_ticket']
p2v1 = P2ImMessageReceiveV1() elif 'im.message.receive_v1' == type:
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
if 'im.message.receive_v1' == type:
try: try:
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message']) event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender']) event.sender = EventSender(context.event['sender'])
p2v1.event = event p2v1.event = event
@@ -898,7 +1158,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
final_content = { final_content = {
'zh_Hans': { 'zh_Hans': {
'title': '', 'title': '',
'content': bot_added_welcome_msg, 'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],
}, },
} }
chat_id = context.event['chat_id'] chat_id = context.event['chat_id']
@@ -915,17 +1175,30 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
.build() .build()
) )
response: CreateMessageResponse = self.api_client.im.v1.message.create(request) tenant_key = context.header.tenant_key if context.header else None
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success(): if not response.success():
raise Exception( raise Exception(
f'client.im.v1.message.create 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)}' f'client.im.v1.message.create 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)}'
) )
except Exception: except Exception as e:
print(f'im.chat.member.bot.added_v1: {e}')
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 200, 'message': 'ok'} return {'code': 200, 'message': 'ok'}
except Exception: except Exception as e:
print(f'Error in lark callback: {e}')
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'} return {'code': 500, 'message': 'error'}

View File

@@ -65,6 +65,25 @@ spec:
type: boolean type: boolean
required: true required: true
default: false default: false
- name: app_type
label:
en_US: App Type
zh_Hans: 应用类型
description:
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
type: select
options:
- name: self
label:
en_US: Self-built Application
zh_Hans: 自建应用
- name: isv
label:
en_US: Store Application
zh_Hans: 商店应用
required: false
default: self
- name: bot_added_welcome - name: bot_added_welcome
label: label:
en_US: Bot Welcome Message en_US: Bot Welcome Message

View File

@@ -76,6 +76,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
AppID=config['AppID'], AppID=config['AppID'],
logger=logger, logger=logger,
unified_mode=True, unified_mode=True,
api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),
) )
elif config['Mode'] == 'passive': elif config['Mode'] == 'passive':
bot = OAClientForLongerResponse( bot = OAClientForLongerResponse(
@@ -86,6 +87,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
LoadingMessage=config.get('LoadingMessage', ''), LoadingMessage=config.get('LoadingMessage', ''),
logger=logger, logger=logger,
unified_mode=True, unified_mode=True,
api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'),
) )
else: else:
raise KeyError('请设置微信公众号通信模式') raise KeyError('请设置微信公众号通信模式')

View File

@@ -53,6 +53,16 @@ spec:
type: string type: string
required: true required: true
default: "AI正在思考中请发送任意内容获取回复。" default: "AI正在思考中请发送任意内容获取回复。"
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API可根据文档修改此项
type: string
required: false
default: "https://api.weixin.qq.com"
execution: execution:
python: python:
path: ./officialaccount.py path: ./officialaccount.py

View File

@@ -65,6 +65,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True) outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
"""后端主动推送消息的队列""" """后端主动推送消息的队列"""
# 流式输出开关
stream_enabled: bool = pydantic.Field(default=True, exclude=True)
"""是否启用流式输出"""
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__( super().__init__(
config=config, config=config,
@@ -77,6 +81,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.bot_account_id = 'websocketbot' self.bot_account_id = 'websocketbot'
self.outbound_message_queue = asyncio.Queue() self.outbound_message_queue = asyncio.Queue()
self.stream_enabled = True
async def send_message( async def send_message(
self, self,
@@ -212,8 +217,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return message_data.model_dump() return message_data.model_dump()
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
"""WebSocket始终支持流式输出""" """根据stream_enabled标志返回是否支持流式输出"""
return True return self.stream_enabled
def register_listener( def register_listener(
self, self,
@@ -314,11 +319,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Args: Args:
connection: WebSocket连接对象 connection: WebSocket连接对象
message_data: 消息数据 message_data: 消息数据,包含:
- message: 消息链
- stream: 是否启用流式输出 (可选默认True)
""" """
pipeline_uuid = connection.pipeline_uuid pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type session_type = connection.session_type
# 获取stream参数默认为True
self.stream_enabled = message_data.get('stream', True)
# 选择会话 # 选择会话
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session

View File

@@ -15,6 +15,58 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
def split_string_by_bytes(text, limit=2048, encoding='utf-8'):
"""
Splits a string into a list of strings, where each part is at most 'limit' bytes.
Args:
text (str): The original string to split.
limit (int): The maximum byte size for each split part.
encoding (str): The encoding to use (default is 'utf-8').
Returns:
list: A list of split strings.
"""
# 1. Encode the entire string into bytes
bytes_data = text.encode(encoding)
total_len = len(bytes_data)
parts = []
start = 0
while start < total_len:
# 2. Determine the end index for the current chunk
# It shouldn't exceed the total length
end = min(start + limit, total_len)
# 3. Slice the byte array
chunk = bytes_data[start:end]
# 4. Attempt to decode the chunk
# Use errors='ignore' to drop any partial bytes at the end of the chunk
# (e.g., if a 3-byte character was cut after the 2nd byte)
part_str = chunk.decode(encoding, errors='ignore')
# 5. Calculate the actual byte length of the successfully decoded string
# This tells us exactly where the valid character boundary ended
part_bytes = part_str.encode(encoding)
part_len = len(part_bytes)
# Safety check: Prevent infinite loop if limit is too small (e.g., limit=1 for a Chinese char)
if part_len == 0 and end < total_len:
# Force advance by 1 byte to consume the un-decodable byte or raise error
# Here we just treat it as a part to avoid stuck loops, though it might be invalid
start += 1
continue
parts.append(part_str)
# 6. Move the start pointer by the actual length consumed
start += part_len
return parts
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient): async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient):
@@ -22,12 +74,14 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content_list.append( chunks = split_string_by_bytes(msg.text)
content_list.extend([
{ {
'type': 'text', 'type': 'text',
'content': msg.text, 'content': chunk,
} }
) for chunk in chunks
])
elif type(msg) is platform_message.Image: elif type(msg) is platform_message.Image:
content_list.append( content_list.append(
{ {
@@ -170,6 +224,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
contacts_secret=config['contacts_secret'], contacts_secret=config['contacts_secret'],
logger=logger, logger=logger,
unified_mode=True, unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
) )
super().__init__( super().__init__(

View File

@@ -46,6 +46,16 @@ spec:
type: string type: string
required: true required: true
default: "" default: ""
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档填写此项
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"
execution: execution:
python: python:
path: ./wecom.py path: ./wecom.py

View File

@@ -141,6 +141,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'], EncodingAESKey=config['EncodingAESKey'],
logger=logger, logger=logger,
unified_mode=True, unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
) )
super().__init__( super().__init__(

View File

@@ -39,6 +39,16 @@ spec:
type: string type: string
required: true required: true
default: "" default: ""
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档修改此项
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"
execution: execution:
python: python:
path: ./wecomcs.py path: ./wecomcs.py

View File

@@ -324,7 +324,7 @@ class RuntimeConnectionHandler(handler.Handler):
messages_obj = [provider_message.Message.model_validate(message) for message in messages] messages_obj = [provider_message.Message.model_validate(message) for message in messages]
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs] funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
result = await llm_model.requester.invoke_llm( result = await llm_model.provider.requester.invoke_llm(
query=None, query=None,
model=llm_model, model=llm_model,
messages=messages_obj, messages=messages_obj,

View File

@@ -9,22 +9,24 @@ from ...discover import engine
from . import token from . import token
from ...entity.persistence import model as persistence_model from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors from ...entity.errors import provider as provider_errors
from async_lru import alru_cache
FETCH_MODEL_LIST_URL = 'https://api.qchatgpt.rockchin.top/api/v2/fetch/model_list'
class ModelManager: class ModelManager:
"""模型管理器""" """Model manager"""
ap: app.Application ap: app.Application
provider_dict: dict[str, requester.RuntimeProvider]
"""运行时模型提供商字典, uuid -> RuntimeProvider"""
llm_models: list[requester.RuntimeLLMModel] llm_models: list[requester.RuntimeLLMModel]
embedding_models: list[requester.RuntimeEmbeddingModel] embedding_models: list[requester.RuntimeEmbeddingModel]
requester_components: list[engine.Component] requester_components: list[engine.Component]
requester_dict: dict[str, type[requester.ProviderAPIRequester]] # cache requester_dict: dict[str, type[requester.ProviderAPIRequester]]
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
self.ap = ap self.ap = ap
@@ -36,7 +38,6 @@ class ModelManager:
async def initialize(self): async def initialize(self):
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester') self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
# forge requester class dict
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {} requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
for component in self.requester_components: for component in self.requester_components:
requester_dict[component.metadata.name] = component.get_python_component_class() requester_dict[component.metadata.name] = component.get_python_component_class()
@@ -45,139 +46,342 @@ class ModelManager:
await self.load_models_from_db() await self.load_models_from_db()
# Check if space models service is disabled
space_config = self.ap.instance_config.data.get('space', {})
if space_config.get('disable_models_service', False):
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
return
try:
await self.sync_new_models_from_space()
except Exception as e:
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
self.ap.logger.warning(f' - Error: {e}')
async def load_models_from_db(self): async def load_models_from_db(self):
"""从数据库加载模型""" """Load models from database"""
self.ap.logger.info('Loading models from db...') self.ap.logger.info('Loading models from db...')
self.llm_models = [] self.llm_models = []
self.embedding_models = [] self.embedding_models = []
# llm models # Load all providers first
self.provider_dict = {}
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
for provider in providers_result.all():
try:
runtime_provider = await self.load_provider(provider)
self.provider_dict[provider.uuid] = runtime_provider
except provider_errors.RequesterNotFoundError as e:
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping provider {provider.uuid}')
continue
except Exception as e:
self.ap.logger.error(f'Failed to load provider {provider.uuid}: {e}\n{traceback.format_exc()}')
# Load LLM models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
llm_models = result.all() llm_models = result.all()
for llm_model in llm_models: for llm_model in llm_models:
try: try:
await self.load_llm_model(llm_model) provider = self.provider_dict.get(llm_model.provider_uuid)
except provider_errors.RequesterNotFoundError as e: if provider is None:
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm model {llm_model.uuid}') self.ap.logger.warning(f'Provider {llm_model.provider_uuid} not found for model {llm_model.uuid}')
continue
runtime_llm_model = await self.load_llm_model_with_provider(llm_model, provider)
self.llm_models.append(runtime_llm_model)
except Exception as e: except Exception as e:
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
# embedding models # Load embedding models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
embedding_models = result.all() embedding_models = result.all()
for embedding_model in embedding_models: for embedding_model in embedding_models:
try: try:
await self.load_embedding_model(embedding_model) provider = self.provider_dict.get(embedding_model.provider_uuid)
except provider_errors.RequesterNotFoundError as e: if provider is None:
self.ap.logger.warning( self.ap.logger.warning(
f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}' f'Provider {embedding_model.provider_uuid} not found for model {embedding_model.uuid}'
) )
continue
runtime_embedding_model = await self.load_embedding_model_with_provider(embedding_model, provider)
self.embedding_models.append(runtime_embedding_model)
except Exception as e: except Exception as e:
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
async def init_runtime_llm_model( async def sync_new_models_from_space(self):
"""Sync models from Space"""
space_model_provider = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == 'space-chat-completions'
)
)
result = space_model_provider.first()
if result is None:
raise provider_errors.ProviderNotFoundError('LangBot Models')
space_model_provider = result
# get the latest models from space
space_models = await self.ap.space_service.get_models()
exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()]
exists_embedding_models_uuids = [
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()
]
for space_model in space_models:
if space_model.category == 'chat':
uuid = space_model.uuid
if uuid in exists_llm_models_uuids:
continue
# model will be automatically loaded
await self.ap.llm_model_service.create_llm_model(
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'abilities': space_model.llm_abilities or [],
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
elif space_model.category == 'embedding':
uuid = space_model.uuid
if uuid in exists_embedding_models_uuids:
continue
# model will be automatically loaded
await self.ap.embedding_models_service.create_embedding_model(
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
async def init_temporary_runtime_llm_model(
self, self,
model_info: persistence_model.LLMModel | sqlalchemy.Row[persistence_model.LLMModel] | dict, model_info: dict,
): ) -> requester.RuntimeLLMModel:
"""初始化运行时 LLM 模型""" """Initialize runtime LLM model from dict (for testing)"""
if isinstance(model_info, sqlalchemy.Row): provider_info = model_info.get('provider', {})
model_info = persistence_model.LLMModel(**model_info._mapping)
elif isinstance(model_info, dict):
model_info = persistence_model.LLMModel(**model_info)
if model_info.requester not in self.requester_dict: runtime_provider = await self.load_provider(provider_info)
raise provider_errors.RequesterNotFoundError(model_info.requester)
requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config)
await requester_inst.initialize()
runtime_llm_model = requester.RuntimeLLMModel( runtime_llm_model = requester.RuntimeLLMModel(
model_entity=model_info, model_entity=persistence_model.LLMModel(
token_mgr=token.TokenManager( uuid=model_info.get('uuid', ''),
name=model_info.uuid, name=model_info.get('name', ''),
tokens=model_info.api_keys, provider_uuid='',
abilities=model_info.get('abilities', []),
extra_args=model_info.get('extra_args', {}),
), ),
requester=requester_inst, provider=runtime_provider,
) )
return runtime_llm_model return runtime_llm_model
async def init_runtime_embedding_model( async def init_temporary_runtime_embedding_model(
self, self,
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row[persistence_model.EmbeddingModel] | dict, model_info: dict,
): ) -> requester.RuntimeEmbeddingModel:
"""初始化运行时 Embedding 模型""" """Initialize runtime embedding model from dict (for testing)"""
if isinstance(model_info, sqlalchemy.Row): provider_info = model_info.get('provider', {})
model_info = persistence_model.EmbeddingModel(**model_info._mapping) runtime_provider = await self.load_provider(provider_info)
elif isinstance(model_info, dict):
model_info = persistence_model.EmbeddingModel(**model_info)
if model_info.requester not in self.requester_dict:
raise provider_errors.RequesterNotFoundError(model_info.requester)
requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config)
await requester_inst.initialize()
runtime_embedding_model = requester.RuntimeEmbeddingModel( runtime_embedding_model = requester.RuntimeEmbeddingModel(
model_entity=model_info, model_entity=persistence_model.EmbeddingModel(
token_mgr=token.TokenManager( uuid=model_info.get('uuid', ''),
name=model_info.uuid, name=model_info.get('name', ''),
tokens=model_info.api_keys, provider_uuid='',
extra_args=model_info.get('extra_args', {}),
), ),
requester=requester_inst, provider=runtime_provider,
) )
return runtime_embedding_model return runtime_embedding_model
async def load_llm_model( async def load_provider(
self, self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
model_info: persistence_model.LLMModel | sqlalchemy.Row[persistence_model.LLMModel] | dict, ) -> requester.RuntimeProvider:
): """Load provider from dict"""
"""加载 LLM 模型""" if isinstance(provider_info, sqlalchemy.Row):
runtime_llm_model = await self.init_runtime_llm_model(model_info) provider_entity = persistence_model.ModelProvider(**provider_info._mapping)
self.llm_models.append(runtime_llm_model) elif isinstance(provider_info, dict):
provider_entity = persistence_model.ModelProvider(**provider_info)
else:
provider_entity = provider_info
async def load_embedding_model( if provider_entity.requester not in self.requester_dict:
self, raise provider_errors.RequesterNotFoundError(provider_entity.requester)
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row[persistence_model.EmbeddingModel] | dict,
):
"""加载 Embedding 模型"""
runtime_embedding_model = await self.init_runtime_embedding_model(model_info)
self.embedding_models.append(runtime_embedding_model)
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 [])
provider = requester.RuntimeProvider(
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
return provider
async def remove_provider(self, provider_uuid: str):
"""Remove provider
This method will not consider the models using this provider,
because the models should be removed by the caller.
"""
del self.provider_dict[provider_uuid]
async def reload_provider(self, provider_uuid: str):
"""Reload provider"""
provider_entity = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
provider_entity = provider_entity.first()
if provider_entity is None:
raise provider_errors.ProviderNotFoundError(provider_uuid)
new_runtime_provider = await self.load_provider(provider_entity)
# update refs in runtime models
for model in self.llm_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
for model in self.embedding_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
async def load_llm_model_with_provider(
self,
model_info: persistence_model.LLMModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeLLMModel:
"""Load LLM model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.LLMModel(**model_info._mapping)
runtime_llm_model = requester.RuntimeLLMModel(
model_entity=model_info,
provider=provider,
)
return runtime_llm_model
async def load_embedding_model_with_provider(
self,
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeEmbeddingModel:
"""Load embedding model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.EmbeddingModel(**model_info._mapping)
runtime_embedding_model = requester.RuntimeEmbeddingModel(
model_entity=model_info,
provider=provider,
)
return runtime_embedding_model
async def load_llm_model(self, model_info: dict):
"""Load LLM model from dict (with provider info)"""
provider_info = model_info.get('provider', {})
if not provider_info:
raise ValueError('Provider info is required')
model_entity = persistence_model.LLMModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=model_info.get('provider_uuid', ''),
abilities=model_info.get('abilities', []),
extra_args=model_info.get('extra_args', {}),
)
provider_entity = persistence_model.ModelProvider(
uuid=provider_info.get('uuid', ''),
name=provider_info.get('name', ''),
requester=provider_info.get('requester', ''),
base_url=provider_info.get('base_url', ''),
api_keys=provider_info.get('api_keys', []),
)
await self.load_llm_model_with_provider(model_entity, provider_entity)
async def load_embedding_model(self, model_info: dict):
"""Load embedding model from dict (with provider info)"""
provider_info = model_info.get('provider', {})
if not provider_info:
raise ValueError('Provider info is required')
model_entity = persistence_model.EmbeddingModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid=model_info.get('provider_uuid', ''),
extra_args=model_info.get('extra_args', {}),
)
provider_entity = persistence_model.ModelProvider(
uuid=provider_info.get('uuid', ''),
name=provider_info.get('name', ''),
requester=provider_info.get('requester', ''),
base_url=provider_info.get('base_url', ''),
api_keys=provider_info.get('api_keys', []),
)
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: async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
"""通过uuid获取 LLM 模型""" """Get LLM model by uuid"""
for model in self.llm_models: for model in self.llm_models:
if model.model_entity.uuid == uuid: if model.model_entity.uuid == uuid:
return model return model
raise ValueError(f'LLM model {uuid} not found') 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: async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
"""通过uuid获取 Embedding 模型""" """Get embedding model by uuid"""
for model in self.embedding_models: for model in self.embedding_models:
if model.model_entity.uuid == uuid: if model.model_entity.uuid == uuid:
return model return model
raise ValueError(f'Embedding model {uuid} not found') raise ValueError(f'Embedding model {uuid} not found')
async def remove_llm_model(self, model_uuid: str): async def remove_llm_model(self, model_uuid: str):
"""移除 LLM 模型""" """Remove LLM model"""
for model in self.llm_models: for model in self.llm_models:
if model.model_entity.uuid == model_uuid: if model.model_entity.uuid == model_uuid:
self.llm_models.remove(model) self.llm_models.remove(model)
return return
async def remove_embedding_model(self, model_uuid: str): async def remove_embedding_model(self, model_uuid: str):
"""移除 Embedding 模型""" """Remove embedding model"""
for model in self.embedding_models: for model in self.embedding_models:
if model.model_entity.uuid == model_uuid: if model.model_entity.uuid == model_uuid:
self.embedding_models.remove(model) self.embedding_models.remove(model)
return return
def get_available_requesters_info(self, model_type: str) -> list[dict]: def get_available_requesters_info(self, model_type: str) -> list[dict]:
"""获取所有可用的请求器""" """Get all available requesters"""
if model_type != '': if model_type != '':
return [ return [
component.to_plain_dict() component.to_plain_dict()
@@ -188,14 +392,14 @@ class ModelManager:
return [component.to_plain_dict() for component in self.requester_components] return [component.to_plain_dict() for component in self.requester_components]
def get_available_requester_info_by_name(self, name: str) -> dict | None: def get_available_requester_info_by_name(self, name: str) -> dict | None:
"""通过名称获取请求器信息""" """Get requester info by name"""
for component in self.requester_components: for component in self.requester_components:
if component.metadata.name == name: if component.metadata.name == name:
return component.to_plain_dict() return component.to_plain_dict()
return None return None
def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None: def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None:
"""通过名称获取请求器清单""" """Get requester manifest by name"""
for component in self.requester_components: for component in self.requester_components:
if component.metadata.name == name: if component.metadata.name == name:
return component return component

View File

@@ -11,11 +11,11 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
class RuntimeLLMModel: class RuntimeProvider:
"""运行时模型""" """运行时模型提供商"""
model_entity: persistence_model.LLMModel provider_entity: persistence_model.ModelProvider
"""模型数据""" """提供商数据"""
token_mgr: token.TokenManager token_mgr: token.TokenManager
"""api key管理器""" """api key管理器"""
@@ -25,36 +25,49 @@ class RuntimeLLMModel:
def __init__( def __init__(
self, self,
model_entity: persistence_model.LLMModel, provider_entity: persistence_model.ModelProvider,
token_mgr: token.TokenManager, token_mgr: token.TokenManager,
requester: ProviderAPIRequester, requester: ProviderAPIRequester,
): ):
self.model_entity = model_entity self.provider_entity = provider_entity
self.token_mgr = token_mgr self.token_mgr = token_mgr
self.requester = requester self.requester = requester
class RuntimeLLMModel:
"""运行时模型"""
model_entity: persistence_model.LLMModel
"""模型数据"""
provider: RuntimeProvider
"""提供商实例"""
def __init__(
self,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
):
self.model_entity = model_entity
self.provider = provider
class RuntimeEmbeddingModel: class RuntimeEmbeddingModel:
"""运行时 Embedding 模型""" """运行时 Embedding 模型"""
model_entity: persistence_model.EmbeddingModel model_entity: persistence_model.EmbeddingModel
"""模型数据""" """模型数据"""
token_mgr: token.TokenManager provider: RuntimeProvider
"""api key管理器""" """提供商实例"""
requester: ProviderAPIRequester
"""请求器实例"""
def __init__( def __init__(
self, self,
model_entity: persistence_model.EmbeddingModel, model_entity: persistence_model.EmbeddingModel,
token_mgr: token.TokenManager, provider: RuntimeProvider,
requester: ProviderAPIRequester,
): ):
self.model_entity = model_entity self.model_entity = model_entity
self.token_mgr = token_mgr self.provider = provider
self.requester = requester
class ProviderAPIRequester(metaclass=abc.ABCMeta): class ProviderAPIRequester(metaclass=abc.ABCMeta):

View File

@@ -56,7 +56,7 @@ class AnthropicMessages(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = model.token_mgr.get_token() self.client.api_key = model.provider.token_mgr.get_token()
args = extra_args.copy() args = extra_args.copy()
args['model'] = model.model_entity.name args['model'] = model.model_entity.name
@@ -190,7 +190,7 @@ class AnthropicMessages(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = model.token_mgr.get_token() self.client.api_key = model.provider.token_mgr.get_token()
args = extra_args.copy() args = extra_args.copy()
args['model'] = model.model_entity.name args['model'] = model.model_entity.name

View File

@@ -30,7 +30,7 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name
@@ -117,7 +117,7 @@ class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
if is_use_dashscope_call: if is_use_dashscope_call:
response = dashscope.MultiModalConversation.call( response = dashscope.MultiModalConversation.call(
# 若没有配置环境变量请用百炼API Key将下行替换为api_key = "sk-xxx" # 若没有配置环境变量请用百炼API Key将下行替换为api_key = "sk-xxx"
api_key=use_model.token_mgr.get_token(), api_key=use_model.provider.token_mgr.get_token(),
model=use_model.model_entity.name, model=use_model.model_entity.name,
messages=messages, messages=messages,
result_format='message', result_format='message',

View File

@@ -4,7 +4,7 @@ import asyncio
import typing import typing
import openai import openai
import openai.types.chat.chat_completion as chat_completion import openai.types.chat.chat_completion as chat_completion_module
import httpx import httpx
from .. import errors, requester from .. import errors, requester
@@ -35,7 +35,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
self, self,
args: dict, args: dict,
extra_body: dict = {}, extra_body: dict = {},
) -> chat_completion.ChatCompletion: ) -> chat_completion_module.ChatCompletion:
return await self.client.chat.completions.create(**args, extra_body=extra_body) return await self.client.chat.completions.create(**args, extra_body=extra_body)
async def _req_stream( async def _req_stream(
@@ -48,9 +48,12 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
async def _make_msg( async def _make_msg(
self, self,
chat_completion: chat_completion.ChatCompletion, chat_completion: chat_completion_module.ChatCompletion,
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
if not isinstance(chat_completion, chat_completion_module.ChatCompletion):
raise TypeError(f'Expected ChatCompletion, got {type(chat_completion).__name__}: {chat_completion[:16]}')
chatcmpl_message = chat_completion.choices[0].message.model_dump() chatcmpl_message = chat_completion.choices[0].message.model_dump()
# 确保 role 字段存在且不为 None # 确保 role 字段存在且不为 None
@@ -130,7 +133,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.MessageChunk: ) -> provider_message.MessageChunk:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name
@@ -251,7 +254,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name
@@ -337,7 +340,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
) -> list[list[float]]: ) -> list[list[float]]:
"""调用 Embedding API""" """调用 Embedding API"""
self.client.api_key = model.token_mgr.get_token() self.client.api_key = model.provider.token_mgr.get_token()
args = { args = {
'model': model.model_entity.name, 'model': model.model_entity.name,

View File

@@ -26,7 +26,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -29,7 +29,7 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.MessageChunk: ) -> provider_message.MessageChunk:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -109,7 +109,7 @@ class JieKouAIChatCompletions(chatcmpl.OpenAIChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -131,7 +131,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name
@@ -181,7 +181,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -27,7 +27,7 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message: ) -> provider_message.Message:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -109,7 +109,7 @@ class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
remove_think: bool = False, remove_think: bool = False,
) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> provider_message.Message | typing.AsyncGenerator[provider_message.MessageChunk, None]:
self.client.api_key = use_model.token_mgr.get_token() self.client.api_key = use_model.provider.token_mgr.get_token()
args = {} args = {}
args['model'] = use_model.model_entity.name args['model'] = use_model.model_entity.name

View File

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 569 B

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import typing
from .. import requester
REQUESTER_NAME: str = 'seekdb-embedding'
class SeekDBEmbedding(requester.ProviderAPIRequester):
"""SeekDB built-in embedding requester.
Uses pyseekdb's local embedding function (all-MiniLM-L6-v2).
The base_url config is reserved for future remote embedding support.
"""
default_config: dict[str, typing.Any] = {
'base_url': '',
}
_embedding_function = None
async def initialize(self):
try:
import pyseekdb
except ImportError:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self._embedding_function = pyseekdb.get_default_embedding_function()
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('SeekDB 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 SeekDB's built-in embedding function."""
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)
except Exception as e:
from .. import errors
raise errors.RequesterError(f'SeekDB embedding failed: {str(e)}')

View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: seekdb-embedding
label:
en_US: SeekDB Embedding
zh_Hans: SeekDB 嵌入
description:
en_US: SeekDB Python library built-in embedding model (all-MiniLM-L6-v2), it will take time to download the model file for the first time
zh_Hans: 使用来自 SeekDB Python 库的内置嵌入模型 (all-MiniLM-L6-v2),首次使用时将会花费时间自动下载模型文件
ja_JP: SeekDB Python ライブラリの組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。初回使用時にモデルファイルのダウンロードに時間がかかります。
icon: seekdb.svg
spec:
config: []
support_type:
- text-embedding
provider_category: builtin
execution:
python:
path: ./seekdbembed.py
attr: SeekDBEmbedding

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import typing
import openai
from . import chatcmpl
class LangBotSpaceChatCompletions(chatcmpl.OpenAIChatCompletions):
"""LangBot Space ChatCompletion API 请求器"""
client: openai.AsyncClient
default_config: dict[str, typing.Any] = {
'base_url': 'https://api.langbot.cloud/v1',
'timeout': 120,
}

View File

@@ -0,0 +1,32 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: space-chat-completions
label:
en_US: Space
zh_Hans: Space
icon: space.webp
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.langbot.cloud/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
- text-embedding
provider_category: maas
execution:
python:
path: ./spacechatcmpl.py
attr: LangBotSpaceChatCompletions

View File

@@ -18,6 +18,8 @@ class TokenManager:
self.using_token_index = 0 self.using_token_index = 0
def get_token(self) -> str: def get_token(self) -> str:
if len(self.tokens) == 0:
return ''
return self.tokens[self.using_token_index] return self.tokens[self.using_token_index]
def next_token(self): def next_token(self):

View File

@@ -152,7 +152,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]: ) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手""" """调用聊天助手"""
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or None
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
@@ -218,7 +218,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]: ) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手""" """调用聊天助手"""
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or None
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
@@ -387,7 +387,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手""" """调用聊天助手"""
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or None
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
@@ -471,7 +471,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手""" """调用聊天助手"""
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or None
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
@@ -529,7 +529,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
think_end = True think_end = True
elif think_end or not think_start: elif think_end or not think_start:
pending_agent_message += chunk['answer'] pending_agent_message += chunk['answer']
if think_start: if think_start and not think_end:
continue continue
else: else:

View File

@@ -130,7 +130,7 @@ class LocalAgentRunner(runner.RequestRunner):
if not is_stream: if not is_stream:
# 非流式输出,直接请求 # 非流式输出,直接请求
msg = await use_llm_model.requester.invoke_llm( msg = await use_llm_model.provider.requester.invoke_llm(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
@@ -147,7 +147,7 @@ class LocalAgentRunner(runner.RequestRunner):
accumulated_content = '' # 从开始累积的所有内容 accumulated_content = '' # 从开始累积的所有内容
last_role = 'assistant' last_role = 'assistant'
msg_sequence = 1 msg_sequence = 1
async for msg in use_llm_model.requester.invoke_llm_stream( async for msg in use_llm_model.provider.requester.invoke_llm_stream(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
@@ -215,16 +215,24 @@ class LocalAgentRunner(runner.RequestRunner):
parameters = json.loads(func.arguments) parameters = json.loads(func.arguments)
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query) func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
# Handle return value content
tool_content = None
if isinstance(func_ret, list) and len(func_ret) > 0 and isinstance(func_ret[0], provider_message.ContentElement):
tool_content = func_ret
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
if is_stream: if is_stream:
msg = provider_message.MessageChunk( msg = provider_message.MessageChunk(
role='tool', role='tool',
content=json.dumps(func_ret, ensure_ascii=False), content=tool_content,
tool_call_id=tool_call.id, tool_call_id=tool_call.id,
) )
else: else:
msg = provider_message.Message( msg = provider_message.Message(
role='tool', role='tool',
content=json.dumps(func_ret, ensure_ascii=False), content=tool_content,
tool_call_id=tool_call.id, tool_call_id=tool_call.id,
) )
@@ -250,7 +258,7 @@ class LocalAgentRunner(runner.RequestRunner):
last_role = 'assistant' last_role = 'assistant'
msg_sequence = first_end_sequence msg_sequence = first_end_sequence
async for msg in use_llm_model.requester.invoke_llm_stream( async for msg in use_llm_model.provider.requester.invoke_llm_stream(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
@@ -306,7 +314,7 @@ class LocalAgentRunner(runner.RequestRunner):
) )
else: else:
# 处理完所有调用,再次请求 # 处理完所有调用,再次请求
msg = await use_llm_model.requester.invoke_llm( msg = await use_llm_model.provider.requester.invoke_llm(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,

View File

@@ -70,30 +70,88 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[ async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[
provider_message.Message, None]: provider_message.Message, None]:
"""处理流式响应""" """处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
full_content = "" full_content = ""
message_idx = 0 chunk_idx = 0
is_final = False is_final = False
async for chunk in response.content.iter_chunked(1024): message_idx = 0
if not chunk:
buffer = ""
decoder = json.JSONDecoder()
async for raw_chunk in response.content.iter_chunked(1024):
if not raw_chunk:
continue continue
try: try:
data = json.loads(chunk) # 将 bytes 解码为字符串(容忍错误)
if data.get('type') == 'item' and 'content' in data: if isinstance(raw_chunk, (bytes, bytearray)):
chunk_str = raw_chunk.decode('utf-8', errors='replace')
else:
chunk_str = str(raw_chunk)
buffer += chunk_str
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
while buffer:
buffer = buffer.lstrip()
if not buffer:
break
try:
obj, idx = decoder.raw_decode(buffer)
buffer = buffer[idx:]
if not isinstance(obj, dict):
# 忽略非字典类型的顶级 JSON
continue
if obj.get('type') == 'item' and 'content' in obj:
chunk_idx += 1
content = obj['content']
full_content += content
elif obj.get('type') == 'end':
is_final = True
if is_final or chunk_idx % 8 == 0:
message_idx += 1
yield provider_message.MessageChunk(
role='assistant',
content=full_content,
is_final=is_final,
msg_sequence=message_idx,
)
except json.JSONDecodeError:
# buffer 末尾可能是一个不完整的 JSON等待更多数据
break
except Exception as e:
# 记录解析失败并继续接收后续 chunk
try:
preview = chunk_str[:200]
except Exception:
preview = '<unavailable>'
self.ap.logger.warning(f"Failed to process chunk: {e}; chunk preview: {preview}")
# 流结束后,尝试解析残余 buffer
if buffer:
try:
buffer = buffer.strip()
if buffer:
obj, _ = decoder.raw_decode(buffer)
if isinstance(obj, dict):
if obj.get('type') == 'item' and 'content' in obj:
full_content += obj['content']
elif obj.get('type') == 'end':
is_final = True
message_idx += 1 message_idx += 1
content = data['content']
full_content += content
elif data.get('type') == 'end':
is_final = True
if is_final or message_idx % 8 == 0:
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role='assistant', role='assistant',
content=full_content, content=full_content,
is_final=is_final, is_final=is_final,
msg_sequence=message_idx,
) )
except json.JSONDecodeError: except Exception as e:
self.ap.logger.warning(f"Failed to parse final JSON line: {response.text()}") preview = buffer[:200]
self.ap.logger.warning(f"Failed to parse remaining buffer: {e}; buffer preview: {preview}")
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用n8n webhook""" """调用n8n webhook"""

View File

@@ -7,14 +7,18 @@ import traceback
from langbot_plugin.api.entities.events import pipeline_query from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy import sqlalchemy
import asyncio import asyncio
import httpx
import uuid as uuid_module
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from .. import loader from .. import loader
from ....core import app from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp from ....entity.persistence import mcp as persistence_mcp
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
server_config: dict server_config: dict
session: ClientSession session: ClientSession | None
exit_stack: AsyncExitStack exit_stack: AsyncExitStack
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event _ready_event: asyncio.Event
error_message: str | None = None
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
self.server_name = server_name self.server_name = server_name
self.server_uuid = server_config.get('uuid', '') self.server_uuid = server_config.get('uuid', '')
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
await self.session.initialize() await self.session.initialize()
async def _init_streamable_http_server(self):
transport = await self.exit_stack.enter_async_context(
streamable_http_client(
self.server_config['url'],
http_client=httpx.AsyncClient(
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
follow_redirects=True,
),
)
)
read, write, _ = transport
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
async def _lifecycle_loop(self): async def _lifecycle_loop(self):
"""在后台任务中管理整个MCP会话的生命周期""" """在后台任务中管理整个MCP会话的生命周期"""
try: try:
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
await self._init_stdio_python_server() await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse': elif self.server_config['mode'] == 'sse':
await self._init_sse_server() await self._init_sse_server()
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
else: else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
except Exception as e: except Exception as e:
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# 即使出错也要设置ready事件让start()方法知道初始化已完成 # 即使出错也要设置ready事件让start()方法知道初始化已完成
self._ready_event.set() self._ready_event.set()
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
raise Exception('Connection failed, please check URL') raise Exception('Connection failed, please check URL')
async def refresh(self): async def refresh(self):
if not self.session:
return
self.functions.clear() self.functions.clear()
tools = await self.session.list_tools() tools = await self.session.list_tools()
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
for tool in tools.tools: for tool in tools.tools:
async def func(*, _tool=tool, **kwargs): async def func(*, _tool=tool, **kwargs):
if not self.session:
raise Exception("MCP session is not connected")
result = await self.session.call_tool(_tool.name, kwargs) result = await self.session.call_tool(_tool.name, kwargs)
if result.isError: if result.isError:
raise Exception(result.content[0].text) error_texts = []
return result.content[0].text for content in result.content:
if content.type == 'text':
error_texts.append(content.text)
raise Exception("\n".join(error_texts) if error_texts else "Unknown error from MCP tool")
result_contents: list[provider_message.ContentElement] = []
for content in result.content:
if content.type == 'text':
result_contents.append(provider_message.ContentElement.from_text(content.text))
elif content.type == 'image':
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
elif content.type == 'resource':
# TODO: Handle resource content
pass
return result_contents
func.__name__ = tool.name func.__name__ = tool.name
self.functions.append( self.functions.append(
resource_tool.LLMTool( resource_tool.LLMTool(
name=tool.name, name=tool.name,
human_desc=tool.description, human_desc=tool.description or "",
description=tool.description, description=tool.description or "",
parameters=tool.inputSchema, parameters=tool.inputSchema,
func=func, func=func,
) )
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict: def get_runtime_info_dict(self) -> dict:
return { return {
'status': self.status.value, 'status': self.status.value,
'error_message': self.error_message,
'tool_count': len(self.get_tools()), 'tool_count': len(self.get_tools()),
'tools': [ 'tools': [
{ {
@@ -287,6 +336,14 @@ class MCPLoader(loader.ToolLoader):
- enable: 是否启用 - enable: 是否启用
- extra_args: 额外的配置参数 (可选) - extra_args: 额外的配置参数 (可选)
""" """
uuid_ = server_config.get('uuid')
if not uuid_:
self.ap.logger.warning(
'Server UUID is None for MCP server, maybe testing in the config page.'
)
uuid_ = str(uuid_module.uuid4())
server_config['uuid'] = uuid_
name = server_config['name'] name = server_config['name']
uuid = server_config['uuid'] uuid = server_config['uuid']

View File

@@ -32,12 +32,18 @@ class Embedder(BaseService):
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.Chunk).values(chunk_dicts)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.Chunk).values(chunk_dicts))
# get embeddings # get embeddings (batch size limit: 64 for OpenAI)
embeddings_list: list[list[float]] = await embedding_model.requester.invoke_embedding( MAX_BATCH_SIZE = 64
model=embedding_model, embeddings_list: list[list[float]] = []
input_text=chunks,
extra_args={}, # TODO: add extra args for i in range(0, len(chunks), MAX_BATCH_SIZE):
) batch = chunks[i:i + MAX_BATCH_SIZE]
batch_embeddings = await embedding_model.provider.requester.invoke_embedding(
model=embedding_model,
input_text=batch,
extra_args={}, # TODO: add extra args
)
embeddings_list.extend(batch_embeddings)
# save embeddings to vdb # save embeddings to vdb
await self.ap.vector_db_mgr.vector_db.add_embeddings(kb_id, chunk_ids, embeddings_list, chunk_dicts) await self.ap.vector_db_mgr.vector_db.add_embeddings(kb_id, chunk_ids, embeddings_list, chunk_dicts)

View File

@@ -19,7 +19,7 @@ class Retriever(base_service.BaseService):
f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}" f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}"
) )
query_embedding: list[float] = await embedding_model.requester.invoke_embedding( query_embedding: list[float] = await embedding_model.provider.requester.invoke_embedding(
model=embedding_model, model=embedding_model,
input_text=[query], input_text=[query],
extra_args={}, # TODO: add extra args extra_args={}, # TODO: add extra args

View File

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
import httpx
from ..core import app as core_app
class TelemetryManager:
"""TelemetryManager handles sending telemetry for a given application instance.
Usage:
telemetry = TelemetryManager(ap)
await telemetry.send({ ... })
"""
send_tasks: list[asyncio.Task] = []
def __init__(self, ap: core_app.Application):
self.ap = ap
self.telemetry_config = {}
async def initialize(self):
self.telemetry_config = self.ap.instance_config.data.get('space', {})
async def start_send_task(self, payload: dict):
task = asyncio.create_task(self.send(payload))
self.send_tasks.append(task)
async def send(self, payload: dict):
"""Send telemetry payload to configured telemetry server (non-blocking).
Expects ap.instance_config.data.telemetry to have:
- enabled: bool
- server: str (base URL, e.g. https://space.example.com)
- timeout_seconds: optional int, overall request timeout (default 10)
Posts to {server.rstrip('/')}/api/v1/telemetry as JSON. Failures are logged but do not raise.
"""
try:
cfg = self.telemetry_config
if not cfg:
return
if cfg.get('disable_telemetry', False):
return
server = cfg.get('url', '')
if not server:
return
# Normalize URL
url = server.rstrip('/') + '/api/v1/telemetry'
try:
# Sanitize payload so string fields are strings and not nulls
sanitized = dict(payload)
if 'query_id' in sanitized:
try:
sanitized['query_id'] = '' if sanitized['query_id'] is None else str(sanitized['query_id'])
except Exception:
sanitized['query_id'] = str(sanitized.get('query_id', ''))
for sfield in ('adapter', 'runner', 'model_name', 'version', 'error', 'timestamp'):
v = sanitized.get(sfield)
sanitized[sfield] = '' if v is None else str(v)
if 'duration_ms' in sanitized:
try:
sanitized['duration_ms'] = (
int(sanitized['duration_ms']) if sanitized['duration_ms'] is not None else 0
)
except Exception:
sanitized['duration_ms'] = 0
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
try:
# Use asyncio.wait_for to ensure we always bound the total time
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
if resp.status_code >= 400:
self.ap.logger.warning(
f'Telemetry post to {url} returned status {resp.status_code} - {resp.text}'
)
else:
# Detect application-level errors inside HTTP 200 responses
app_err = False
try:
j = resp.json()
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
app_err = True
self.ap.logger.warning(
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
)
except Exception:
pass
if app_err:
self.ap.logger.warning(
f'Telemetry post to {url} returned app-level error - response: {resp.text[:200]}'
)
else:
self.ap.logger.debug(
f'Telemetry posted to {url}, status {resp.status_code} - response: {resp.text[:200]}'
)
except asyncio.TimeoutError:
self.ap.logger.warning(f'Telemetry post to {url} timed out')
except Exception as e:
self.ap.logger.warning(f'Failed to post telemetry to {url}: {e}', exc_info=True)
except Exception as e:
try:
self.ap.logger.warning(
f'Failed to create HTTP client for telemetry or sanitize payload: {e}', exc_info=True
)
except Exception:
pass
except Exception as e:
# Never raise from telemetry; surface as warning for visibility
try:
self.ap.logger.warning(f'Unexpected telemetry error: {e}', exc_info=True)
except Exception:
pass

View File

@@ -2,9 +2,11 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 13 required_database_version = 17
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False
edition = 'community' edition = 'community'
instance_id = ''

View File

@@ -153,7 +153,9 @@ async def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, s
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession(trust_env=False) as session: async with aiohttp.ClientSession(trust_env=False) as session:
async with session.get(image_url, params=query, ssl=ssl_context) as resp: async with session.get(
image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)
) as resp:
resp.raise_for_status() resp.raise_for_status()
file_bytes = await resp.read() file_bytes = await resp.read()
content_type = resp.headers.get('Content-Type') content_type = resp.headers.get('Content-Type')

View File

@@ -4,6 +4,7 @@ from ..core import app
from .vdb import VectorDatabase from .vdb import VectorDatabase
from .vdbs.chroma import ChromaVectorDatabase from .vdbs.chroma import ChromaVectorDatabase
from .vdbs.qdrant import QdrantVectorDatabase from .vdbs.qdrant import QdrantVectorDatabase
from .vdbs.seekdb import SeekDBVectorDatabase
from .vdbs.milvus import MilvusVectorDatabase from .vdbs.milvus import MilvusVectorDatabase
from .vdbs.pgvector_db import PgVectorDatabase from .vdbs.pgvector_db import PgVectorDatabase
@@ -27,13 +28,17 @@ class VectorDBManager:
elif vdb_type == 'qdrant': elif vdb_type == 'qdrant':
self.vector_db = QdrantVectorDatabase(self.ap) self.vector_db = QdrantVectorDatabase(self.ap)
self.ap.logger.info('Initialized Qdrant vector database backend.') self.ap.logger.info('Initialized Qdrant vector database backend.')
elif vdb_type == 'seekdb':
self.vector_db = SeekDBVectorDatabase(self.ap)
self.ap.logger.info('Initialized SeekDB vector database backend.')
elif vdb_type == 'milvus': elif vdb_type == 'milvus':
# Get Milvus configuration # Get Milvus configuration
milvus_config = kb_config.get('milvus', {}) milvus_config = kb_config.get('milvus', {})
uri = milvus_config.get('uri', './data/milvus.db') uri = milvus_config.get('uri', './data/milvus.db')
token = milvus_config.get('token') token = milvus_config.get('token')
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token) db_name = milvus_config.get('db_name', 'default')
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token, db_name=db_name)
self.ap.logger.info('Initialized Milvus vector database backend.') self.ap.logger.info('Initialized Milvus vector database backend.')
elif vdb_type == 'pgvector': elif vdb_type == 'pgvector':

View File

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

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any, Dict from typing import Any, Dict
from pymilvus import MilvusClient, DataType from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
from pymilvus.milvus_client.index import IndexParams
from langbot.pkg.vector.vdb import VectorDatabase from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app from langbot.pkg.core import app
@@ -9,7 +10,7 @@ from langbot.pkg.core import app
class MilvusVectorDatabase(VectorDatabase): class MilvusVectorDatabase(VectorDatabase):
"""Milvus vector database implementation""" """Milvus vector database implementation"""
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None): def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None, db_name: str = None):
"""Initialize Milvus vector database """Initialize Milvus vector database
Args: Args:
@@ -21,30 +22,76 @@ class MilvusVectorDatabase(VectorDatabase):
self.ap = ap self.ap = ap
self.uri = uri self.uri = uri
self.token = token self.token = token
self.db_name = db_name
self.client = None self.client = None
self._collections = {} self._collections: set[str] = set()
self._initialize_client() self._initialize_client()
def _initialize_client(self): def _initialize_client(self):
"""Initialize Milvus client connection""" """Initialize Milvus client connection"""
try: try:
if self.token: if self.token:
self.client = MilvusClient(uri=self.uri, token=self.token) self.client = MilvusClient(uri=self.uri, token=self.token, db_name=self.db_name)
else: else:
self.client = MilvusClient(uri=self.uri) self.client = MilvusClient(uri=self.uri, db_name=self.db_name)
self.ap.logger.info(f"Connected to Milvus at {self.uri}") self.ap.logger.info(f"Connected to Milvus at {self.uri}")
except Exception as e: except Exception as e:
self.ap.logger.error(f"Failed to connect to Milvus: {e}") self.ap.logger.error(f"Failed to connect to Milvus: {e}")
raise raise
async def get_or_create_collection(self, collection: str): @staticmethod
"""Get or create a Milvus collection def _normalize_collection_name(collection: str) -> str:
"""Normalize collection name to comply with Milvus naming requirements.
Milvus requirements:
- First character must be an underscore or letter
- Can only contain numbers, letters and underscores
Args:
collection: Original collection name (e.g., UUID with hyphens)
Returns:
Normalized collection name that complies with Milvus requirements
"""
# Replace hyphens with underscores
normalized = collection.replace('-', '_')
# If first character is not a letter or underscore, prepend 'kb_'
if normalized and not (normalized[0].isalpha() or normalized[0] == '_'):
normalized = 'kb_' + normalized
return normalized
async def _ensure_vector_index(self, collection: str) -> None:
"""Ensure the vector field has an index.
Args:
collection: Normalized collection name
"""
index_params = IndexParams()
index_params.add_index(
field_name="vector",
index_type="AUTOINDEX",
metric_type="COSINE",
)
await asyncio.to_thread(
self.client.create_index,
collection_name=collection,
index_params=index_params
)
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None):
"""Internal method to get or create a Milvus collection with proper configuration.
Args: Args:
collection: Collection name (corresponds to knowledge base UUID) collection: Collection name (corresponds to knowledge base UUID)
vector_size: Dimension of the vectors (if None, defaults to 1536)
""" """
# Normalize collection name for Milvus compatibility
collection = self._normalize_collection_name(collection)
if collection in self._collections: if collection in self._collections:
return self._collections[collection] return collection
# Check if collection exists # Check if collection exists
has_collection = await asyncio.to_thread( has_collection = await asyncio.to_thread(
@@ -52,12 +99,13 @@ class MilvusVectorDatabase(VectorDatabase):
) )
if not has_collection: if not has_collection:
# Create collection with custom schema to support string IDs # Default dimension if not specified (for backward compatibility)
from pymilvus import CollectionSchema, FieldSchema, DataType if vector_size is None:
vector_size = 1536
fields = [ fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255), FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1536), FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=vector_size),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255), FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255), FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
@@ -72,26 +120,42 @@ class MilvusVectorDatabase(VectorDatabase):
metric_type="COSINE", metric_type="COSINE",
) )
# Create index for vector field (required for loading/searching) await self._ensure_vector_index(collection)
index_params = { self.ap.logger.info(f"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX")
"metric_type": "COSINE",
"index_type": "AUTOINDEX",
"params": {}
}
await asyncio.to_thread(
self.client.create_index,
collection_name=collection,
field_name="vector",
index_params=index_params
)
self.ap.logger.info(f"Created Milvus collection '{collection}' with index")
else: else:
# Ensure index exists for existing collection
await self._ensure_index_if_missing(collection)
self.ap.logger.info(f"Milvus collection '{collection}' already exists") self.ap.logger.info(f"Milvus collection '{collection}' already exists")
self._collections[collection] = collection self._collections.add(collection)
return collection return collection
async def _ensure_index_if_missing(self, collection: str) -> None:
"""Check if index exists for collection and create if missing.
Args:
collection: Normalized collection name
"""
try:
indexes = await asyncio.to_thread(
self.client.list_indexes,
collection_name=collection
)
if "vector" not in indexes:
await self._ensure_vector_index(collection)
self.ap.logger.info(f"Created index for existing Milvus collection '{collection}'")
except Exception as e:
self.ap.logger.warning(f"Could not verify/create index for collection '{collection}': {e}")
async def get_or_create_collection(self, collection: str):
"""Get or create a Milvus collection (without vector size - will use default).
Args:
collection: Collection name (corresponds to knowledge base UUID)
"""
collection = self._normalize_collection_name(collection)
return await self._get_or_create_collection_internal(collection)
async def add_embeddings( async def add_embeddings(
self, self,
collection: str, collection: str,
@@ -107,7 +171,14 @@ class MilvusVectorDatabase(VectorDatabase):
embeddings_list: List of embedding vectors embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries for each vector metadatas: List of metadata dictionaries for each vector
""" """
await self.get_or_create_collection(collection) collection = self._normalize_collection_name(collection)
if not embeddings_list:
return
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
await self._get_or_create_collection_internal(collection, vector_size)
# Prepare data in Milvus format # Prepare data in Milvus format
data = [] data = []
@@ -156,6 +227,7 @@ class MilvusVectorDatabase(VectorDatabase):
Returns: Returns:
Dictionary with search results in Chroma-compatible format Dictionary with search results in Chroma-compatible format
""" """
collection = self._normalize_collection_name(collection)
await self.get_or_create_collection(collection) await self.get_or_create_collection(collection)
# Perform search # Perform search
@@ -214,6 +286,7 @@ class MilvusVectorDatabase(VectorDatabase):
collection: Collection name collection: Collection name
file_id: File ID to filter deletion file_id: File ID to filter deletion
""" """
collection = self._normalize_collection_name(collection)
await self.get_or_create_collection(collection) await self.get_or_create_collection(collection)
# Delete entities matching the file_id # Delete entities matching the file_id
@@ -232,8 +305,9 @@ class MilvusVectorDatabase(VectorDatabase):
Args: Args:
collection: Collection name to delete collection: Collection name to delete
""" """
if collection in self._collections: collection = self._normalize_collection_name(collection)
del self._collections[collection]
self._collections.discard(collection)
# Check if collection exists before attempting deletion # Check if collection exists before attempting deletion
has_collection = await asyncio.to_thread( has_collection = await asyncio.to_thread(

View File

@@ -0,0 +1,252 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
import sqlalchemy
from langbot.pkg.core import app
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.vector.vdb import VectorDatabase
try:
import pyseekdb
from pyseekdb import HNSWConfiguration
SEEKDB_AVAILABLE = True
except ImportError:
SEEKDB_AVAILABLE = False
SEEKDB_EMBEDDING_MODEL_UUID = 'seekdb-builtin-embedding'
SEEKDB_EMBEDDING_REQUESTER = 'seekdb-embedding'
class SeekDBVectorDatabase(VectorDatabase):
"""SeekDB vector database adapter for LangBot.
SeekDB is an AI-native search database by OceanBase that unifies
relational, vector, text, JSON and GIS in a single engine.
Supports both embedded mode and remote server mode.
"""
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
# Determine connection mode based on config
mode = config.get('mode', 'embedded') # 'embedded' or 'server'
if mode == 'embedded':
# Embedded mode: local database
path = config.get('path', './data/seekdb')
database = config.get('database', 'langbot')
# Use AdminClient for database management operations
admin_client = pyseekdb.AdminClient(path=path)
# Check if database exists using public API
existing_dbs = [db.name for db in admin_client.list_databases()]
if database not in existing_dbs:
# Use public API to create database
admin_client.create_database(database)
self.ap.logger.info(f"Created SeekDB database '{database}'")
self.client = pyseekdb.Client(path=path, database=database)
self.ap.logger.info(f"Initialized SeekDB in embedded mode at '{path}', database '{database}'")
elif mode == 'server':
# Server mode: remote SeekDB or OceanBase server
host = config.get('host', 'localhost')
port = config.get('port', 2881)
database = config.get('database', 'langbot')
user = config.get('user', 'root')
password = config.get('password', '')
tenant = config.get('tenant', None) # Optional, for OceanBase
connection_params = {
'host': host,
'port': int(port),
'database': database,
'user': user,
'password': password,
}
if tenant:
connection_params['tenant'] = tenant
self.client = pyseekdb.Client(**connection_params)
self.ap.logger.info(
f"Initialized SeekDB in server mode: {host}:{port}, database '{database}'"
+ (f", tenant '{tenant}'" if tenant else '')
)
else:
raise ValueError(f"Invalid SeekDB mode: {mode}. Must be 'embedded' or 'server'")
self._collections: Dict[str, Any] = {}
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._escape_table = str.maketrans({
'\x00': '',
'\\': '\\\\',
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
})
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
"""Internal method to get or create a collection with proper configuration."""
if collection in self._collections:
return self._collections[collection]
# Check if collection exists
if await asyncio.to_thread(self.client.has_collection, collection):
# Collection exists, get it
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
self.ap.logger.info(f"SeekDB collection '{collection}' retrieved.")
return coll
# Collection doesn't exist, create it
if vector_size is None:
# Default dimension if not specified
vector_size = 384
# Create HNSW configuration
config = HNSWConfiguration(dimension=vector_size, distance='cosine')
self._collection_configs[collection] = config
# Create collection without embedding function (we manage embeddings externally)
coll = await asyncio.to_thread(
self.client.create_collection,
name=collection,
configuration=config,
embedding_function=None, # Disable automatic embedding
)
self._collections[collection] = coll
self.ap.logger.info(f"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'")
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table) if isinstance(v, str)
else v if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
return await self._get_or_create_collection_internal(collection)
async def add_embeddings(
self,
collection: str,
ids: List[str],
embeddings_list: List[List[float]],
metadatas: List[Dict[str, Any]]
) -> None:
"""Add vector embeddings to the specified collection.
Args:
collection: Collection name
ids: List of document IDs
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries
"""
if not embeddings_list:
return
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size)
cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas]
await asyncio.to_thread(coll.add, ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
async def search(self, collection: str, query_embedding: List[float], k: int = 5) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
Args:
collection: Collection name
query_embedding: Query vector
k: Number of results to return
Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys
"""
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
else:
coll = self._collections[collection]
# Perform query
# SeekDB's query() returns: {'ids': [[...]], 'metadatas': [[...]], 'distances': [[...]]}
results = await asyncio.to_thread(coll.query, query_embeddings=query_embedding, n_results=k)
self.ap.logger.info(f"SeekDB search in '{collection}' returned {len(results.get('ids', [[]])[0])} results")
return results
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors from the collection by file_id metadata.
Args:
collection: Collection name
file_id: File ID to delete
"""
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
else:
coll = self._collections[collection]
# SeekDB's delete() expects a where clause for filtering
# Delete all records where metadata['file_id'] == file_id
await asyncio.to_thread(coll.delete, where={'file_id': file_id})
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}")
async def delete_collection(self, collection: str):
"""Delete the entire collection.
Args:
collection: Collection name
"""
# Remove from cache
if collection in self._collections:
del self._collections[collection]
if collection in self._collection_configs:
del self._collection_configs[collection]
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Delete collection
await asyncio.to_thread(self.client.delete_collection, collection)
self.ap.logger.info(f"SeekDB collection '{collection}' deleted")

View File

@@ -16,6 +16,7 @@ proxy:
https: '' https: ''
system: system:
recovery_key: '' recovery_key: ''
allow_modify_login_info: true
jwt: jwt:
expire: 604800 expire: 604800
secret: '' secret: ''
@@ -36,9 +37,21 @@ vdb:
host: localhost host: localhost
port: 6333 port: 6333
api_key: '' api_key: ''
seekdb:
mode: embedded # 'embedded' or 'server'
# Embedded mode options:
path: './data/seekdb'
database: 'langbot'
# Server mode options (used when mode='server'):
host: 'localhost'
port: 2881
user: 'root'
password: ''
tenant: '' # Optional, for OceanBase server
milvus: milvus:
uri: 'http://127.0.0.1:19530' uri: 'http://127.0.0.1:19530'
token: '' token: ''
db_name: ''
pgvector: pgvector:
host: '127.0.0.1' host: '127.0.0.1'
port: 5433 port: 5433
@@ -57,5 +70,13 @@ plugin:
enable: true enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true enable_marketplace: true
cloud_service_url: 'https://space.langbot.app'
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'
# Space API URL for model requests (MaaS)
models_gateway_api_url: 'https://api.langbot.cloud/v1'
# OAuth authorization page URL (user will be redirected here)
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
disable_models_service: false
disable_telemetry: false

View File

@@ -25,6 +25,7 @@
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -33,7 +34,7 @@
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
@@ -51,9 +52,10 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"next": "~15.5.7", "next": "~15.5.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
@@ -82,6 +84,7 @@
"@types/mdast": "^4.0.4", "@types/mdast": "^4.0.4",
"@types/ms": "^2.1.0", "@types/ms": "^2.1.0",
"@types/node": "^20", "@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.7", "@types/react": "~19.2.7",
"@types/react-dom": "~19.2.3", "@types/react-dom": "~19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",

223
web/pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ dependencies:
'@radix-ui/react-checkbox': '@radix-ui/react-checkbox':
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-context-menu': '@radix-ui/react-context-menu':
specifier: ^2.2.15 specifier: ^2.2.15
version: 2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
@@ -48,7 +51,7 @@ dependencies:
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-separator': '@radix-ui/react-separator':
specifier: ^1.1.7 specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
@@ -102,14 +105,17 @@ dependencies:
specifier: ^0.507.0 specifier: ^0.507.0
version: 0.507.0(react@19.2.1) version: 0.507.0(react@19.2.1)
next: next:
specifier: ~15.5.7 specifier: ~15.5.9
version: 15.5.7(react-dom@19.2.1)(react@19.2.1) version: 15.5.9(react-dom@19.2.1)(react@19.2.1)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1)(react@19.2.1) version: 0.4.6(react-dom@19.2.1)(react@19.2.1)
postcss: postcss:
specifier: ^8.5.3 specifier: ^8.5.3
version: 8.5.6 version: 8.5.6
qrcode:
specifier: ^1.5.4
version: 1.5.4
react: react:
specifier: 19.2.1 specifier: 19.2.1
version: 19.2.1 version: 19.2.1
@@ -190,6 +196,9 @@ devDependencies:
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
version: 20.19.25 version: 20.19.25
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react': '@types/react':
specifier: ~19.2.7 specifier: ~19.2.7
version: 19.2.7 version: 19.2.7
@@ -718,8 +727,8 @@ packages:
dev: true dev: true
optional: true optional: true
/@next/env@15.5.7: /@next/env@15.5.9:
resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
dev: false dev: false
/@next/eslint-plugin-next@15.2.4: /@next/eslint-plugin-next@15.2.4:
@@ -911,6 +920,33 @@ packages:
react-dom: 19.2.1(react@19.2.1) react-dom: 19.2.1(react@19.2.1)
dev: false dev: false
/@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
dev: false
/@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1): /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -2015,6 +2051,12 @@ packages:
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
dev: false dev: false
/@types/qrcode@1.5.6:
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
dependencies:
'@types/node': 20.19.25
dev: true
/@types/react-dom@19.2.3(@types/react@19.2.7): /@types/react-dom@19.2.3(@types/react@19.2.7):
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -2369,6 +2411,11 @@ packages:
environment: 1.1.0 environment: 1.1.0
dev: true dev: true
/ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
dev: false
/ansi-regex@6.2.2: /ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2379,7 +2426,6 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
dev: true
/ansi-styles@6.2.3: /ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
@@ -2591,6 +2637,11 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true dev: true
/camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
dev: false
/caniuse-lite@1.0.30001757: /caniuse-lite@1.0.30001757:
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
dev: false dev: false
@@ -2653,6 +2704,14 @@ packages:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
dev: false dev: false
/cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
dev: false
/clsx@2.1.1: /clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2663,11 +2722,9 @@ packages:
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
dev: true
/color-name@1.1.4: /color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: true
/colorette@2.0.20: /colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -2758,6 +2815,11 @@ packages:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
/decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
dev: false
/decode-named-character-reference@1.2.0: /decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
dependencies: dependencies:
@@ -2811,6 +2873,10 @@ packages:
dequal: 2.0.3 dequal: 2.0.3
dev: false dev: false
/dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dev: false
/doctrine@2.1.0: /doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2830,6 +2896,10 @@ packages:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
dev: true dev: true
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: false
/emoji-regex@9.2.2: /emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: true dev: true
@@ -3394,6 +3464,14 @@ packages:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
dev: true dev: true
/find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
dev: false
/find-up@5.0.0: /find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3471,6 +3549,11 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dev: true dev: true
/get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: false
/get-east-asian-width@1.4.0: /get-east-asian-width@1.4.0:
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3912,6 +3995,11 @@ packages:
call-bound: 1.0.4 call-bound: 1.0.4
dev: true dev: true
/is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
dev: false
/is-fullwidth-code-point@4.0.0: /is-fullwidth-code-point@4.0.0:
resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4289,6 +4377,13 @@ packages:
wrap-ansi: 9.0.2 wrap-ansi: 9.0.2
dev: true dev: true
/locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
dependencies:
p-locate: 4.1.0
dev: false
/locate-path@6.0.0: /locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4878,8 +4973,8 @@ packages:
react-dom: 19.2.1(react@19.2.1) react-dom: 19.2.1(react@19.2.1)
dev: false dev: false
/next@15.5.7(react-dom@19.2.1)(react@19.2.1): /next@15.5.9(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -4899,7 +4994,7 @@ packages:
sass: sass:
optional: true optional: true
dependencies: dependencies:
'@next/env': 15.5.7 '@next/env': 15.5.9
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001757 caniuse-lite: 1.0.30001757
postcss: 8.4.31 postcss: 8.4.31
@@ -5029,6 +5124,13 @@ packages:
safe-push-apply: 1.0.0 safe-push-apply: 1.0.0
dev: true dev: true
/p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
dependencies:
p-try: 2.2.0
dev: false
/p-limit@3.1.0: /p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5036,6 +5138,13 @@ packages:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
dev: true dev: true
/p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
dependencies:
p-limit: 2.3.0
dev: false
/p-locate@5.0.0: /p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5043,6 +5152,11 @@ packages:
p-limit: 3.1.0 p-limit: 3.1.0
dev: true dev: true
/p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
dev: false
/parent-module@1.0.1: /parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -5071,7 +5185,6 @@ packages:
/path-exists@4.0.0: /path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
dev: true
/path-key@3.1.1: /path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
@@ -5107,6 +5220,11 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
dev: false
/possible-typed-array-names@1.1.0: /possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5178,6 +5296,16 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true dev: true
/qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
dev: false
/queue-microtask@1.2.3: /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true dev: true
@@ -5448,6 +5576,15 @@ packages:
unified: 11.0.5 unified: 11.0.5
dev: false dev: false
/require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: false
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: false
/resolve-from@4.0.0: /resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -5541,6 +5678,10 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/set-function-length@1.2.2: /set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5717,6 +5858,15 @@ packages:
engines: {node: '>=0.6.19'} engines: {node: '>=0.6.19'}
dev: true dev: true
/string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: false
/string-width@7.2.0: /string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5800,6 +5950,13 @@ packages:
character-entities-legacy: 3.0.0 character-entities-legacy: 3.0.0
dev: false dev: false
/strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
dependencies:
ansi-regex: 5.0.1
dev: false
/strip-ansi@7.1.2: /strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6210,6 +6367,10 @@ packages:
is-weakset: 2.0.4 is-weakset: 2.0.4
dev: true dev: true
/which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
dev: false
/which-typed-array@1.1.19: /which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6236,6 +6397,15 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: false
/wrap-ansi@9.0.2: /wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -6245,12 +6415,41 @@ packages:
strip-ansi: 7.1.2 strip-ansi: 7.1.2
dev: true dev: true
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: false
/yaml@2.8.1: /yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
dev: true dev: true
/yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
dev: false
/yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
dev: false
/yocto-queue@0.1.0: /yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}

View File

@@ -0,0 +1,248 @@
'use client';
import { useEffect, useState, useCallback, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Loader2,
AlertCircle,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error'
>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback(
async (authCode: string) => {
try {
const response = await httpClient.exchangeSpaceOAuthCode(authCode);
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
setTimeout(() => {
router.push('/home');
}, 1000);
} catch (err) {
setStatus('error');
const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
} else {
setErrorMessage(t('common.spaceLoginFailed'));
}
}
},
[router, t],
);
const [bindState, setBindState] = useState<string | null>(null);
const handleBindAccount = useCallback(
async (authCode: string, state: string) => {
setIsProcessing(true);
try {
const response = await httpClient.bindSpaceAccount(authCode, state);
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
}
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
setTimeout(() => {
router.push('/home');
}, 1000);
} catch (err) {
setStatus('error');
const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
} else {
setErrorMessage(t('account.bindSpaceFailed'));
}
} finally {
setIsProcessing(false);
}
},
[router, t],
);
useEffect(() => {
const authCode = searchParams.get('code');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
if (error) {
setStatus('error');
setErrorMessage(
errorDescription || error || t('common.spaceLoginFailed'),
);
return;
}
if (!authCode) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
setCode(authCode);
if (mode === 'bind') {
// Bind mode - verify state (token) exists
if (!state) {
setStatus('error');
setErrorMessage(t('account.bindSpaceInvalidState'));
return;
}
setBindState(state);
setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm');
} else {
// Normal login/register mode
handleOAuthCallback(authCode);
}
}, [searchParams, handleOAuthCallback, t]);
const handleConfirmBind = () => {
if (code && bindState) {
handleBindAccount(code, bindState);
}
};
const handleCancelBind = () => {
router.push('/home');
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardHeader className="text-center">
<img
src={langbotIcon.src}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
<CardTitle className="text-xl">
{status === 'loading' && t('common.spaceLoginProcessing')}
{status === 'confirm' && t('account.bindSpaceConfirmTitle')}
{status === 'success' &&
(isBindMode
? t('account.bindSpaceSuccess')
: t('common.spaceLoginSuccess'))}
{status === 'error' &&
(isBindMode
? t('account.bindSpaceFailed')
: t('common.spaceLoginError'))}
</CardTitle>
<CardDescription>
{status === 'loading' &&
t('common.spaceLoginProcessingDescription')}
{status === 'confirm' && t('account.bindSpaceConfirmDescription')}
{status === 'success' && t('common.spaceLoginSuccessDescription')}
{status === 'error' && errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{status === 'loading' && (
<Loader2 className="h-12 w-12 animate-spin text-primary" />
)}
{status === 'confirm' && (
<>
<AlertTriangle className="h-12 w-12 text-yellow-500" />
<p className="text-sm text-center text-muted-foreground px-4">
{t('account.bindSpaceWarning', {
localEmail: localEmail || '-',
})}
</p>
<div className="flex gap-3 w-full">
<Button
variant="outline"
className="flex-1"
onClick={handleCancelBind}
disabled={isProcessing}
>
{t('common.cancel')}
</Button>
<Button
className="flex-1"
onClick={handleConfirmBind}
disabled={isProcessing}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t('common.confirm')}
</Button>
</div>
</>
)}
{status === 'success' && (
<CheckCircle2 className="h-12 w-12 text-green-500" />
)}
{status === 'error' && (
<>
<AlertCircle className="h-12 w-12 text-red-500" />
<Button
onClick={() => router.push(isBindMode ? '/home' : '/login')}
className="w-full mt-4"
>
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
</Button>
</>
)}
</CardContent>
</Card>
</div>
);
}
function LoadingFallback() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardContent className="flex flex-col items-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</CardContent>
</Card>
</div>
);
}
export default function SpaceOAuthCallback() {
return (
<Suspense fallback={<LoadingFallback />}>
<SpaceOAuthCallbackContent />
</Suspense>
);
}

View File

@@ -19,6 +19,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -48,6 +49,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
const getFormSchema = (t: (key: string) => string) => const getFormSchema = (t: (key: string) => string) =>
z.object({ z.object({
@@ -116,6 +118,7 @@ export default function BotForm({
const [, setIsLoading] = useState<boolean>(false); const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>(''); const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(null); const webhookInputRef = React.useRef<HTMLInputElement>(null);
const [copied, setCopied] = useState<boolean>(false);
// Watch adapter and adapter_config for filtering // Watch adapter and adapter_config for filtering
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
@@ -153,7 +156,6 @@ export default function BotForm({
const inputElement = webhookInputRef.current; const inputElement = webhookInputRef.current;
if (!inputElement) { if (!inputElement) {
console.error('[Copy] Input element not found'); console.error('[Copy] Input element not found');
toast.error(t('common.copyFailed'));
return; return;
} }
@@ -178,7 +180,8 @@ export default function BotForm({
console.log('[Copy] Clipboard API success'); console.log('[Copy] Clipboard API success');
inputElement.blur(); // 取消选中 inputElement.blur(); // 取消选中
inputElement.readOnly = true; inputElement.readOnly = true;
toast.success(t('bots.webhookUrlCopied')); setCopied(true);
setTimeout(() => setCopied(false), 2000);
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(
@@ -191,9 +194,8 @@ export default function BotForm({
inputElement.blur(); inputElement.blur();
inputElement.readOnly = true; inputElement.readOnly = true;
if (successful) { if (successful) {
toast.success(t('bots.webhookUrlCopied')); setCopied(true);
} else { setTimeout(() => setCopied(false), 2000);
toast.error(t('common.copyFailed'));
} }
}); });
} else { } else {
@@ -207,15 +209,13 @@ export default function BotForm({
inputElement.blur(); inputElement.blur();
inputElement.readOnly = true; inputElement.readOnly = true;
if (successful) { if (successful) {
toast.success(t('bots.webhookUrlCopied')); setCopied(true);
} else { setTimeout(() => setCopied(false), 2000);
toast.error(t('common.copyFailed'));
} }
} }
} catch (err) { } catch (err) {
console.error('[Copy] Copy failed:', err); console.error('[Copy] Copy failed:', err);
inputElement.readOnly = true; inputElement.readOnly = true;
toast.error(t('common.copyFailed'));
} }
}; };
@@ -242,7 +242,9 @@ export default function BotForm({
} }
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.getBotConfigError') + err.message); toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
}); });
} else { } else {
form.reset(); form.reset();
@@ -310,6 +312,7 @@ export default function BotForm({
name: item.name, name: item.name,
required: item.required, required: item.required,
type: parseDynamicFormItemType(item.type), type: parseDynamicFormItemType(item.type),
options: item.options,
}), }),
), ),
); );
@@ -384,7 +387,7 @@ export default function BotForm({
toast.success(t('bots.saveSuccess')); toast.success(t('bots.saveSuccess'));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.saveError') + err.message); toast.error(t('bots.saveError') + err.msg);
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@@ -410,7 +413,7 @@ export default function BotForm({
onNewBotCreated(res.uuid); onNewBotCreated(res.uuid);
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.createError') + err.message); toast.error(t('bots.createError') + err.msg);
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@@ -429,7 +432,7 @@ export default function BotForm({
toast.success(t('bots.deleteSuccess')); toast.success(t('bots.deleteSuccess'));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.deleteError') + err.message); toast.error(t('bots.deleteError') + err.msg);
}); });
} }
} }
@@ -547,6 +550,11 @@ export default function BotForm({
size="sm" size="sm"
onClick={copyToClipboard} onClick={copyToClipboard}
> >
{copied ? (
<Check className="h-4 w-4 text-green-600 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{t('common.copy')} {t('common.copy')}
</Button> </Button>
</div> </div>

View File

@@ -1,15 +1,17 @@
'use client'; 'use client';
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css'; import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view'; import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { Check } from 'lucide-react';
export function BotLogCard({ botLog }: { botLog: BotLog }) { export function BotLogCard({ botLog }: { botLog: BotLog }) {
const { t } = useTranslation(); const { t } = useTranslation();
const baseURL = httpClient.getBaseUrl(); const baseURL = httpClient.getBaseUrl();
const [copied, setCopied] = useState(false);
function formatTime(timestamp: number) { function formatTime(timestamp: number) {
const now = new Date(); const now = new Date();
@@ -75,42 +77,47 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
</div> </div>
{botLog.message_session_id && ( {botLog.message_session_id && (
<div <div
className={`${styles.tag} ${styles.chatTag}`} className={`${styles.tag} ${styles.chatTag} relative`}
onClick={() => { onClick={() => {
navigator.clipboard navigator.clipboard
.writeText(botLog.message_session_id) .writeText(botLog.message_session_id)
.then(() => { .then(() => {
toast.success(t('common.copySuccess')); setCopied(true);
setTimeout(() => setCopied(false), 2000);
}); });
}} }}
title={t('common.clickToCopy')} title={t('common.clickToCopy')}
> >
<svg {copied ? (
className="icon" <Check className="w-4 h-4 text-green-600" />
viewBox="0 0 1024 1024" ) : (
version="1.1" <svg
xmlns="http://www.w3.org/2000/svg" className="icon"
p-id="1664" viewBox="0 0 1024 1024"
width="16" version="1.1"
height="16" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" p-id="1664"
> width="16"
<path height="16"
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1665"
fill="currentColor" fill="currentColor"
></path> >
<path <path
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z" d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1666" p-id="1665"
fill="currentColor" fill="currentColor"
></path> ></path>
<path <path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z" d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
p-id="1667" p-id="1666"
fill="currentColor" fill="currentColor"
></path> ></path>
</svg> <path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
p-id="1667"
fill="currentColor"
></path>
</svg>
)}
<span className={`${styles.chatId}`}> <span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)} {getSubChatId(botLog.message_session_id)}

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