Compare commits

...

72 Commits

Author SHA1 Message Date
Junyan Qin
f4db53b759 chore: bump version to 4.9.4 in pyproject.toml and __init__.py 2026-03-26 00:16:21 +08:00
Junyan Qin
9f90341dcb fix(web): correct UTC timestamp parsing in monitoring panel
Backend serializes monitoring timestamps as naive ISO strings without
timezone designator. JavaScript's new Date() treats such strings as
local time, causing displayed times to be off by the user's UTC offset.
Add parseUTCTimestamp() utility that appends 'Z' to ensure correct UTC
interpretation.
2026-03-26 00:05:44 +08:00
Junyan Qin
67b726afb2 chore: uv.lock 2026-03-25 23:44:34 +08:00
fdc310
01852b81d4 Feat/openclaw weixin adapter (#2074)
* feat: add wexin openclaw adapter

* feat: The new feature will store the token and other configurations after login.

* fix: wexin qc to base64 and in log image print

* feat: add image to base64

* feat: add update file and image and voice
2026-03-25 23:34:35 +08:00
RockChinQ
4d6f109788 chore: bump langbot-plugin SDK to 0.3.5 2026-03-25 21:10:59 +08:00
Junyan Chin
e1e5e7aedf fix: get_llm_models handler returns UUID strings instead of full model dicts (#2081)
The plugin SDK declares get_llm_models() -> list[str] (UUID strings),
but the host handler returned the full model dict list from
llm_model_service.get_llm_models(). This caused TypeError when
invoke_llm passed a dict to get_model_by_uuid (which is decorated
with @async_lru and requires hashable arguments).

Extract only the 'uuid' field to match the SDK contract.
2026-03-25 21:06:49 +08:00
RockChinQ
cd53abc440 fix(web): prevent plugin market search trigger during IME composition 2026-03-24 21:39:49 +08:00
Junyan Qin
16a15a122a fix: update langbot-plugin dependency to version 0.3.4 2026-03-24 12:00:12 +08:00
zpf2000
6fa653f232 feat: 支持可配置的混合检索融合权重 (#2071)
* feat: 支持可配置的混合检索融合权重

* style: 修复 ruff format 检查
2026-03-24 09:50:08 +08:00
Junyan Chin
c13971d7d6 feat(web): merge plugin readme and config into single detail dialog (#2076)
* feat(web): merge plugin readme and config into single detail dialog

- Click plugin card directly opens combined dialog (left: readme, right: config)
- Remove hover overlay with separate readme/config buttons
- Dropdown menu (⋯) still available for update/delete/view source

* fix: prettier format for lucide import
2026-03-23 22:22:31 +08:00
Junyan Qin
9c659ce8fa fix: update langbot-plugin dependency to version 0.3.4 2026-03-23 22:14:41 +08:00
Junyan Qin
c9fc64360f feat(plugin): add unrestricted knowledge base query handlers
Add handlers for LIST_KNOWLEDGE_BASES and RETRIEVE_KNOWLEDGE actions
that allow plugins to list and retrieve from any knowledge base without
pipeline scope restrictions, complementing the existing pipeline-scoped handlers.
2026-03-23 21:06:23 +08:00
Guanchao Wang
88a04fdbe8 Merge pull request #2055 from langbot-app/copilot/fix-sender-name-parameter 2026-03-23 14:14:36 +08:00
WangCham
bbe019f0c6 fix: wrong agentid 2026-03-23 14:02:10 +08:00
RockChinQ
865f6ee81b style: format telegram.py for ruff 2026-03-21 22:10:23 +08:00
fdc310
bd5ec59b7c fix:The fix is in place — content = '' is now reset at the start of each loop iteration , which prevents stale text from being duplicated across tool call and end-turn chunks. (#2060) 2026-03-21 22:08:35 +08:00
fdc310
9c0cc1003d Fixed the issue where the at bot did not remove the at symbol, result… (#2062)
* Fixed the issue where the at bot did not remove the at symbol, resulting in some commands not being activated in group chats. Also, adjusted the logic in the on_message section.

* fix:reply_message  del bot_name
2026-03-21 22:07:31 +08:00
Bijin
ea07d8ad00 fix(telegram): add document message support (docx/pdf/etc) (#2069)
The Telegram adapter only handles TEXT, COMMAND, PHOTO, and VOICE
messages. Document files (docx, pdf, etc.) sent by users are silently
dropped because:

1. MessageHandler filters lack filters.Document.ALL
2. target2yiri() has no message.document branch
3. yiri2target() has no platform_message.File branch
4. send_message() has no 'document' component handler

Changes:
- Add filters.Document.ALL to the MessageHandler filter set
- Add message.document parsing in target2yiri() → platform_message.File
- Add platform_message.File handling in yiri2target() → document component
- Add 'document' type handling in send_message() via bot.send_document()

This allows Telegram document messages to flow through the existing
PreProcessor and Dify file upload pipeline, consistent with how other
adapters (Lark, KOOK, Discord, WeCom) already handle files.

Closes #2065
2026-03-21 22:06:54 +08:00
youhuanghe
3ac3fad4bc chore: upgrade plugin sdk to 0.3.3 2026-03-19 12:48:29 +00:00
youhuanghe
254a13bba3 fix: 4355f0fa78 ruff lint 2026-03-16 06:39:29 +00:00
youhuanghe
4355f0fa78 feat(rag): expose vector listing API with backend filter support 2026-03-16 06:26:05 +00:00
Junyan Qin
031737f05d chore: remove all preset sensitive words 2026-03-16 13:42:19 +08:00
Nody the lobster
9e366fc536 fix: allow env overrides to create missing config keys (#2064)
Previously, environment variable overrides (e.g. SYSTEM__INSTANCE_ID)
were silently skipped if the target key didn't already exist in
data/config.yaml. This caused SaaS pods running older LangBot images
(whose config template lacked system.instance_id) to ignore the
SYSTEM__INSTANCE_ID env var, falling back to a random UUID that
didn't match the pod UUID — breaking idle timeout tracking.

Now env overrides create missing keys (as strings) and missing
intermediate dicts, so they work regardless of template version.

Co-authored-by: rocksclawbot <rocksclawbot@users.noreply.github.com>
2026-03-15 23:03:40 +08:00
youhuanghe
8bd6442965 chore: upgrade plugin sdk to 0.3.2 2026-03-14 12:56:54 +00:00
Junyan Qin
1a1eadb282 chore: bump version 4.9.3 2026-03-14 20:20:48 +08:00
Nody the lobster
eed72b1c12 fix: show error message on login page when backend is unreachable (#2063) 2026-03-14 19:20:01 +08:00
RockChinQ
351350ea03 fix: instance_id priority: config.yaml > file > generate new
- If system.instance_id set in config (via env var), use it
- If not set but file exists, read from file (don't generate new)
- If neither, generate new and save to file
2026-03-13 11:33:32 -04:00
RockChinQ
bc3d6ba92f feat: support instance_id in system config
Add instance_id field to system section in config.yaml.
Can be set via SYSTEM__INSTANCE_ID env var (auto-mapped).
Falls back to data/labels/instance_id.json if not set.
2026-03-13 11:31:51 -04:00
RockChinQ
345e4baf2a Revert "feat: support pre-setting instance_id via LANGBOT__INSTANCE_ID env var"
This reverts commit 6c64dc057f.
2026-03-13 11:30:36 -04:00
RockChinQ
6c64dc057f feat: support pre-setting instance_id via LANGBOT__INSTANCE_ID env var
In SaaS (cloud edition), the instance_id can now be injected via
environment variable to match the pod UUID. This enables zero-lookup
telemetry routing in Space - no need to reverse-lookup instance_id
to find the pod.
2026-03-13 11:26:16 -04:00
youhuanghe
eec0a9c9d9 feat(plugin): expose KB UUIDs in query variables and pass session context to retrieve API
Extract knowledge base UUID list into query.variables['_knowledge_base_uuids']
in PreProcessor so plugins can modify it during PromptPreProcessing. Runner now
reads from variables instead of pipeline_config. Also pass session_name,
bot_uuid, and sender_id to kb.retrieve() in the RETRIEVE_KNOWLEDGE_BASE handler
so knowledge engines receive proper session context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:23:19 +00:00
Junyan Qin
6896a55485 fix: bot form error 2026-03-13 12:26:45 +08:00
Junyan Qin
4b0fad233e chore: bump version 4.9.2 2026-03-13 12:15:21 +08:00
Junyan Qin
52eb991a70 feat: add extra webhook prefix config 2026-03-13 12:06:22 +08:00
Junyan Qin
10c716be0c fix: bad model field ref 2026-03-13 11:47:31 +08:00
youhuanghe
6e77351eda refactor: up rag ingest timeout 2026-03-13 02:37:32 +00:00
Junyan Qin
20f5ebd9b8 chore: bump version 4.9.1 2026-03-12 23:24:33 +08:00
Junyan Qin
d2c75329cf fix: kbform react error 2026-03-12 23:20:51 +08:00
Junyan Qin
7e2fe082f0 chore: bump langbot-plugin to 0.3.1 2026-03-12 23:16:09 +08:00
fdc310
d451b059fd feat: Implement WebSocket long connection client for WeChat Work AI Bot (#2054)
* feat: Implement WebSocket long connection client for WeChat Work AI Bot

- Added WecomBotWsClient to handle WebSocket connections for receiving messages and sending replies.
- Introduced a new migration (dbm022) to add 'enable-webhook' field to existing wecombot adapter configs, ensuring backward compatibility.
- Updated WecomBotAdapter to support both WebSocket and webhook modes based on the new configuration.
- Enhanced YAML configuration for WecomBot to include 'enable-webhook' and 'Secret' fields, adjusting requirements accordingly.
- Incremented database version to 22 to reflect schema changes.

* fix:db enable-webhook is false

* fix:add logic

* fix:Removed an unnecessary configuration check

* fix: migration

* fix: update migration

* fix:migration
2026-03-12 22:31:14 +08:00
marun
93c52fcd4c Enhance Lark Bot Ability to Reply to Quoted Messages (#2043)
* fix(database): Update database version requirement to 20

- Increase required_database_version from 19 to 20
- Add documentation on database schema version check

* feat(lark): Added support for message references and topic message grouping

- Implemented the function to extract reference message IDs from messages, supporting parent message identification

- Added a method to construct event messages from SDK message items

- Implemented the function to asynchronously obtain reference messages and convert them into message chains

- Integrated reference message injection logic into the message processing flow

- Added a mechanism to filter source components while retaining reference content

- Implemented a method to obtain the starter ID with topic awareness

- Provided session isolation support for topic range in group thread messages

- Supported stable maintenance of conversation context in group thread discussions

- Handled cases where topic messages cannot reliably detect reference targets

* feat(lark): Implement a duplicate prevention mechanism for Feishu topic message references

- Add class-level cache to store processed topic IDs and timestamps

- Implement a timed cleanup mechanism to remove expired topic records

- Add cache size limit to prevent memory from growing indefinitely

- Return the parent message ID and mark it as processed when the first reply is made to a topic

- Return None in subsequent replies to the same topic to avoid duplicate references

- Implement automatic cache trimming to ensure stable performance
2026-03-12 21:48:30 +08:00
huanghuoguoguo
f1608682e6 Feat/agentic rag and parser invoke api (#2052)
* feat: add pipeline api

* feat: add list parser

* ruff lint

* fix: add filter but agentic rag not to use

* feat: add bot uuid for memory..
2026-03-12 21:47:27 +08:00
youhuanghe
077e631c13 fix(rag): normalize vector search to distance semantics 2026-03-12 12:33:09 +00:00
Junyan Chin
d7df1f05d1 fix: resolve security vulnerabilities in dependencies (#2059)
Python (uv.lock):
- langchain-core 1.2.7 → 1.2.18 (SSRF via image_url token counting)
- langgraph 1.0.7 → 1.1.1 (unsafe msgpack deserialization)
- flask 3.1.2 → 3.1.3 (missing Vary: Cookie header)
- werkzeug 3.1.5 → 3.1.6 (Windows special device name in safe_join)

npm (web/pnpm-lock.yaml):
- minimatch updated to fix ReDoS vulnerabilities
2026-03-12 20:09:19 +08:00
Junyan Chin
8b8cfb76de fix(market): sync plugin market UI improvements from Space (#2056)
* fix(market): sync plugin market UI from space - page size 12, full list display, fix double separator, adaptive tag display

* fix: lint and prettier formatting

* fix: prettier formatting for remaining files
2026-03-12 15:06:11 +08:00
Junyan Chin
79311ccde3 feat: model fallback chain (#2017) (#2018) 2026-03-12 03:33:05 +08:00
copilot-swe-agent[bot]
def798bf1f fix: WeCom sender_name shows user ID instead of actual username
- Add get_user_info() to WecomClient to fetch user name via /user/get API
- Update WecomEventConverter.target2yiri to accept bot param and fetch real user name
- Update register_listener call to pass self.bot for user name lookup
- URL-encode userid parameter for safety

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-03-11 17:52:43 +00:00
copilot-swe-agent[bot]
5290834b8b Initial plan 2026-03-11 17:48:12 +00:00
Guanchao Wang
89064a9d5b feat: add support for username (#2047)
* feat: add support for username

* fix: lint

* fix: migerations

* fix: change to version 21

* fix: remove duplicate dbm021 migration and rename dbm022

* feat: add user_id and user_name display with copy functionality in BotSessionMonitor

---------

Co-authored-by: wangcham <wangcham@gmail.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-12 01:27:22 +08:00
RockChinQ
8c2aef3734 fix: prettier formatting for long URL strings 2026-03-11 07:05:45 -04:00
RockChinQ
3fb9e542b6 fix(web): use locale-aware data collection policy URL 2026-03-11 07:03:52 -04:00
RockChinQ
01844d8687 feat(web): add privacy & data collection policy consent to login/register pages 2026-03-11 06:50:54 -04:00
Copilot
2655425fbe fix: deduplicate final chunk yield in Dify chatflow streaming (#2049)
* Initial plan

* fix: prevent duplicate messages when Dify chatflow sends both workflow_finished and message_end events

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

* style: apply ruff formatting to difysvapi.py

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>
2026-03-11 14:45:55 +08:00
youhuanghe
bd15b630b0 fix: chroma ruff lint 2026-03-11 04:07:21 +00:00
youhuanghe
fe5ce68436 feat(vector): add full-text and hybrid search support for Chroma backend
- Implement full-text search via Chroma's $contains filter
  - Implement hybrid search with RRF (Reciprocal Rank Fusion) combining
    vector and full-text results, with min-max normalized distances
  - Fix add_embeddings to use col.upsert instead of col.add for idempotency
  - Bump chromadb dependency to >=1.0.0,<2.0.0
  - Re-lock uv.lock with official PyPI source
2026-03-11 03:59:14 +00:00
Typer_Body
0541b05966 refactor: optimized error handling (#2020)
* Update output.yaml

* Update default-pipeline-config.json

* Update chat.py

* Add files via upload

* Update chat.py

* Update default-pipeline-config.json

* Update output.yaml

* Update constants.py

* feat: update logic

* fix: update required database version to 21

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-10 22:01:23 +08:00
youhuanghe
13cb0aa9be bugfix: rollback filter, add to retrive settings 2026-03-10 12:49:24 +00:00
youhuanghe
a048369b38 feat: Pass session context (session_name) to knowledge engine retrieval filters.
Allow KnowledgeEngine plugins to filter retrieval results by session,enabling per-session memory isolation in plugin-based knowledge bases
2026-03-10 12:27:50 +00:00
Junyan Qin
9ae0c263dc fix: update documentation links and translations for knowledge engine 2026-03-09 20:31:50 +08:00
Junyan Qin
a4e66f6459 feat: update version to 4.9.0 in pyproject.toml, __init__.py, and uv.lock 2026-03-09 20:10:01 +08:00
huanghuoguoguo
2a74a8d6ae Feat/dbm20 rag (#2037)
* feat(rag): add knowledge base migration from v4.9.0 to plugin architecture

Rewrite dbm020 to backup old knowledge_bases data and preserve
external_knowledge_bases table. Add migration API endpoints and
frontend dialog so users can opt-in to auto-install LangRAG plugin
and restore their knowledge bases with original UUIDs preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rag): query marketplace for actual plugin version instead of 'latest'

The marketplace API does not support 'latest' as a version string.
Fetch the plugin info first to get latest_version, then use that
concrete version for installation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(rag): add data-only migration option and fix dialog width

Add option to migrate knowledge base data without auto-installing
the LangRAG plugin (for offline/intranet environments). Also
narrow the migration dialog to match other confirmation dialogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: to red and no more

* fix lint

* fix ruff lint

* feat: add external migration

* fix: show

* feat: add external plugin auto download

* feat: update migration messages for knowledge base in multiple languages

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-09 20:05:38 +08:00
Guanchao Wang
d31f25c8df Merge pull request #2041 from langbot-app/fix/websocket-chat-bug
Fix/websocket chat bug
2026-03-09 16:11:17 +08:00
WangCham
11c05ea8db style(format): fix ruff formatting issues 2026-03-09 16:04:38 +08:00
WangCham
2b8bd1cc71 fix: invoke_llm failed when use plugin 2026-03-09 16:01:45 +08:00
doujianghub
9148e02679 fix: centralized pipeline config type coercion to prevent string-type crashes (#2031)
* fix: coerce pipeline config types at load time using metadata definitions

Pipeline configs stored in SQLAlchemy JSON columns can have values turned
into strings after UI edits (e.g. "120" instead of 120), causing runtime
arithmetic/logic errors. Add centralized type coercion in load_pipeline()
that leverages existing metadata YAML type definitions (integer, number,
float, boolean) to convert values before they reach downstream stages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review - defensive getattr + add unit tests for config_coercion

- Use getattr with defaults for pipeline_config_meta_* attributes to
  avoid AttributeError when MockApplication lacks these fields
- Add 18 unit tests for config_coercion module covering all code paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dynamic form stage tracking and snapshot management

* fix: standardize string formatting in config coercion and improve logging messages

---------

Co-authored-by: KPC <kpc@kpc.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-09 14:30:07 +08:00
fdc310
fd15284d91 fix(platform): websocket send_message not delivering to webchat frontend (#2039)
- Include websocket_proxy_bot in get_bot_by_uuid lookup so plugins can
  find it by uuid
- Rewrite send_message to broadcast directly via ws_connection_manager
  using the correct pipeline_uuid instead of misusing target_id
- Save messages to session history with unique IDs so they persist
  across page reloads and don't overwrite each other

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:22:03 +08:00
Junyan Qin
8c7a0ec027 fix: update langbot-plugin version to 0.3.0 2026-03-08 21:08:08 +08:00
youhuanghe
a1cef5c9bf bugfix: update uv.lock 2026-03-08 11:10:03 +00:00
youhuanghe
90438cec36 lint: update web knowledge pnpm lint 2026-03-08 11:05:00 +00:00
youhuanghe
95dd19f4d7 bugfix: now knowledge toast right msg 2026-03-08 11:01:13 +00:00
youhuanghe
c64eb58cf8 feat: update pyseekdb version to 1.1.0.post3 2026-03-08 10:42:20 +00:00
Junyan Qin
fbd3d7ae3a feat: enhance RecommendationLists component with responsive pagination and auto-advance functionality
- Added dynamic column measurement to adjust the number of visible plugins based on the grid layout.
- Implemented auto-advance feature for pagination every 5 seconds when there are more plugins than the visible count.
- Updated pagination controls to reflect the current page accurately.
- Refactored code to improve readability and maintainability.
2026-03-08 17:35:30 +08:00
100 changed files with 7008 additions and 1163 deletions

View File

@@ -34,8 +34,6 @@
--- ---
## 什么是 LangBot
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型LLM连接到各种聊天平台帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。 LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型LLM连接到各种聊天平台帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
### 核心能力 ### 核心能力
@@ -43,7 +41,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 - **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。 - **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。 - **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.8.7" version = "4.9.4"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -61,10 +61,10 @@ dependencies = [
"html2text>=2024.2.26", "html2text>=2024.2.26",
"langchain>=0.2.0", "langchain>=0.2.0",
"langchain-text-splitters>=0.0.1", "langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.0.0b7", "pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.0rc1", "langbot-plugin==0.3.5",
"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 - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.8.7' __version__ = '4.9.4'

View File

@@ -0,0 +1,3 @@
from .client import OpenClawWeixinClient as OpenClawWeixinClient
from .types import ApiError as ApiError
from .types import LoginResult as LoginResult

View File

@@ -0,0 +1,807 @@
"""Async HTTP client for the OpenClaw WeChat API.
Implements the iLink Bot API protocol.
Reference: https://github.com/epiral/weixin-bot
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
import os
import struct
import typing
import uuid
from typing import Optional
from urllib.parse import quote
import aiohttp
from .types import (
ApiError,
CDNMedia,
FileItem,
GetConfigResponse,
GetUpdatesResponse,
GetUploadUrlResponse,
ImageItem,
LoginResult,
MessageItem,
QRCodeResponse,
QRStatusResponse,
RefMessage,
TextItem,
VideoItem,
VoiceItem,
WeixinMessage,
)
logger = logging.getLogger('openclaw-weixin-sdk')
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
CHANNEL_VERSION = '1.0.0'
DEFAULT_API_TIMEOUT = 15
DEFAULT_LONG_POLL_TIMEOUT = 40
DEFAULT_CONFIG_TIMEOUT = 10
DEFAULT_QR_POLL_TIMEOUT = 35
SESSION_EXPIRED_ERRCODE = -14
DEFAULT_BOT_TYPE = '3'
# Maximum text length per message chunk (WeChat limit)
MAX_TEXT_CHUNK_SIZE = 2000
def _random_wechat_uin() -> str:
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
rand_bytes = os.urandom(4)
uint32_val = struct.unpack('>I', rand_bytes)[0]
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
def _build_base_info() -> dict:
"""Build the base_info payload included in every API request."""
return {'channel_version': CHANNEL_VERSION}
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
"""Split long text into chunks that fit within WeChat's message size limit."""
if len(text) <= max_size:
return [text]
chunks = []
while text:
chunks.append(text[:max_size])
text = text[max_size:]
return chunks
class OpenClawWeixinClient:
"""Async client for the OpenClaw WeChat HTTP JSON API."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip('/')
self.token = token
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
if self._session and not self._session.closed:
await self._session.close()
def _build_headers(self) -> dict[str, str]:
headers = {
'Content-Type': 'application/json',
'AuthorizationType': 'ilink_bot_token',
'X-WECHAT-UIN': _random_wechat_uin(),
}
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
return headers
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
"""Make a POST request and return the JSON response.
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
"""
payload['base_info'] = _build_base_info()
session = await self._get_session()
url = f'{self.base_url}/{endpoint}'
headers = self._build_headers()
async with session.post(
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
# Check for application-level errors in the response body
errcode = data.get('errcode') or data.get('ret')
if errcode and errcode != 0:
raise ApiError(
data.get('errmsg') or f'API errcode {errcode}',
status=200,
code=errcode,
payload=data,
)
return data
async def get_updates(
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
) -> GetUpdatesResponse:
"""Long-poll for new messages.
Note: This method does NOT raise ApiError for errcode responses —
it returns them in the GetUpdatesResponse so the caller can handle
session expiry and other errors with full context.
"""
try:
# Bypass the errcode check in _post since get_updates needs
# to return error info (e.g. session expired) to the caller.
payload: dict = {'get_updates_buf': get_updates_buf}
payload['base_info'] = _build_base_info()
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/getupdates'
headers = self._build_headers()
async with session.post(
url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
except ApiError:
raise
except Exception as e:
if 'timeout' in str(e).lower():
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
raise
return _parse_get_updates_response(data)
async def send_message(
self,
to_user_id: str,
item_list: list[MessageItem],
context_token: str = '',
) -> None:
"""Send a message to a user."""
items_payload = [_message_item_to_dict(item) for item in item_list]
payload = {
'msg': {
'from_user_id': '',
'to_user_id': to_user_id,
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
'message_type': WeixinMessage.TYPE_BOT,
'message_state': WeixinMessage.STATE_FINISH,
'item_list': items_payload,
'context_token': context_token or None,
}
}
await self._post('ilink/bot/sendmessage', payload)
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
"""Send a plain text message, automatically chunking if too long."""
chunks = _chunk_text(text)
for chunk in chunks:
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
await self.send_message(to_user_id, [item], context_token)
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
"""Get bot config including typing_ticket."""
data = await self._post(
'ilink/bot/getconfig',
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
timeout=DEFAULT_CONFIG_TIMEOUT,
)
return GetConfigResponse(
ret=data.get('ret'),
errmsg=data.get('errmsg'),
typing_ticket=data.get('typing_ticket'),
)
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
"""Send typing indicator. status: 1=typing, 2=cancel."""
await self._post(
'ilink/bot/sendtyping',
{
'ilink_user_id': ilink_user_id,
'typing_ticket': typing_ticket,
'status': status,
},
timeout=DEFAULT_CONFIG_TIMEOUT,
)
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
"""Cancel the typing indicator for a user."""
await self.send_typing(ilink_user_id, typing_ticket, status=2)
async def download_media(
self,
media: CDNMedia,
) -> bytes:
"""Download and decrypt a file from the WeChat CDN.
Args:
media: CDNMedia object with encrypt_query_param and aes_key.
Returns:
Decrypted file bytes.
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
if not media.encrypt_query_param:
raise ApiError('CDN media has no encrypt_query_param', status=0)
if not media.aes_key:
raise ApiError('CDN media has no aes_key', status=0)
# Derive 16-byte AES key
# aes_key is base64-encoded; the decoded content may be:
# - raw 16 bytes (direct AES key)
# - 32-char hex string (decode hex to get 16 bytes)
raw = base64.b64decode(media.aes_key)
if len(raw) == 16:
aes_key = raw
elif len(raw) == 32:
# Hex-encoded 16-byte key
aes_key = bytes.fromhex(raw.decode('utf-8'))
else:
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
# Download encrypted bytes from CDN
session = await self._get_session()
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
encrypted = await resp.read()
# Decrypt AES-128-ECB with PKCS7 padding
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
decryptor = cipher.decryptor()
padded = decryptor.update(encrypted) + decryptor.finalize()
unpadder = PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
async def upload_media(
self,
file_bytes: bytes,
to_user_id: str,
media_type: int,
) -> CDNMedia:
"""Encrypt and upload media to WeChat CDN.
Args:
file_bytes: Raw file bytes to upload.
to_user_id: Recipient user ID.
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
Returns:
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
"""
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
# 1. Generate random 16-byte AES key
raw_key = os.urandom(16)
aes_key_hex = raw_key.hex() # 32-char hex string
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
# 3. Encrypt file with AES-128-ECB + PKCS7
padder = PKCS7(128).padder()
padded = padder.update(file_bytes) + padder.finalize()
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
encryptor = cipher.encryptor()
encrypted = encryptor.update(padded) + encryptor.finalize()
# 4. Get upload URL
raw_md5 = hashlib.md5(file_bytes).hexdigest()
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
upload_resp = await self.get_upload_url(
filekey=filekey,
media_type=media_type,
to_user_id=to_user_id,
rawsize=len(file_bytes),
rawfilemd5=raw_md5,
filesize=len(encrypted),
aeskey=aes_key_hex, # hex string, as expected by the API
)
if not upload_resp.upload_param:
raise ApiError('Failed to get upload URL', status=0)
# 5. Upload to CDN
# upload_param is an opaque token from the server — pass it as-is
session = await self._get_session()
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
logger.debug(
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
cdn_url,
len(file_bytes),
len(encrypted),
raw_md5,
encoded_key,
)
async with session.post(
cdn_url,
data=encrypted,
headers={'Content-Type': 'application/octet-stream'},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status != 200:
text = await resp.text()
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
download_param = resp.headers.get('x-encrypted-param', '')
if not download_param:
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
return CDNMedia(
encrypt_query_param=download_param,
aes_key=encoded_key,
encrypt_type=1,
)
async def send_image(
self,
to_user_id: str,
image_bytes: bytes,
context_token: str = '',
) -> None:
"""Upload an image to CDN and send it."""
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
item = MessageItem(
type=MessageItem.IMAGE,
image_item=ImageItem(
media=media,
aeskey=media.aes_key,
),
)
await self.send_message(to_user_id, [item], context_token)
async def send_file(
self,
to_user_id: str,
file_bytes: bytes,
file_name: str,
context_token: str = '',
) -> None:
"""Upload a file to CDN and send it."""
import hashlib
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
item = MessageItem(
type=MessageItem.FILE,
file_item=FileItem(
media=media,
file_name=file_name,
md5=hashlib.md5(file_bytes).hexdigest(),
len=str(len(file_bytes)),
),
)
await self.send_message(to_user_id, [item], context_token)
async def send_voice(
self,
to_user_id: str,
voice_bytes: bytes,
playtime: int = 0,
context_token: str = '',
) -> None:
"""Upload a voice message to CDN and send it."""
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
item = MessageItem(
type=MessageItem.VOICE,
voice_item=VoiceItem(
media=media,
playtime=playtime,
),
)
await self.send_message(to_user_id, [item], context_token)
async def get_upload_url(
self,
filekey: str,
media_type: int,
to_user_id: str,
rawsize: int,
rawfilemd5: str,
filesize: int,
thumb_rawsize: Optional[int] = None,
thumb_rawfilemd5: Optional[str] = None,
thumb_filesize: Optional[int] = None,
aeskey: Optional[str] = None,
) -> GetUploadUrlResponse:
"""Get a pre-signed CDN upload URL."""
payload: dict = {
'filekey': filekey,
'media_type': media_type,
'to_user_id': to_user_id,
'rawsize': rawsize,
'rawfilemd5': rawfilemd5,
'filesize': filesize,
'no_need_thumb': True,
}
if thumb_rawsize is not None:
payload['thumb_rawsize'] = thumb_rawsize
if thumb_rawfilemd5 is not None:
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
if thumb_filesize is not None:
payload['thumb_filesize'] = thumb_filesize
if aeskey is not None:
payload['aeskey'] = aeskey
data = await self._post('ilink/bot/getuploadurl', payload)
logger.debug('get_upload_url response: %s', data)
return GetUploadUrlResponse(
upload_param=data.get('upload_param'),
thumb_upload_param=data.get('thumb_upload_param'),
)
# -----------------------------------------------------------------------
# QR Code Login
# -----------------------------------------------------------------------
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'Failed to fetch QR code: {resp.status} {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
logger.debug(
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
)
return QRCodeResponse(
qrcode=data.get('qrcode'),
qrcode_img_content=data.get('qrcode_img_content'),
)
async def _fetch_qr_image_base64(self, url: str) -> str:
"""Generate a QR code image from the URL and return a data URI string.
The qrcode_img_content URL points to an HTML page (not a raw image),
so we generate the QR code locally using the qrcode library.
"""
import qrcode
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
return f'data:image/png;base64,{b64}'
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
session = await self._get_session()
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
headers = {'iLink-App-ClientVersion': '1'}
try:
async with session.get(
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
) as resp:
if resp.status != 200:
text = await resp.text()
raise ApiError(
f'Failed to poll QR status: {resp.status} {text}',
status=resp.status,
)
data = await resp.json(content_type=None)
logger.debug('QR status poll response: %s', data)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return QRStatusResponse(status='wait')
return QRStatusResponse(
status=data.get('status'),
bot_token=data.get('bot_token'),
ilink_bot_id=data.get('ilink_bot_id'),
baseurl=data.get('baseurl'),
ilink_user_id=data.get('ilink_user_id'),
)
async def login(
self,
max_retries: int = 5,
poll_timeout_ms: int = 480_000,
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
) -> LoginResult:
"""Complete QR code login flow with auto-retry on expiry.
Args:
max_retries: Max number of QR code refreshes on expiry.
poll_timeout_ms: Timeout per QR code in milliseconds.
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
new QR code is fetched. Use this to display the QR code.
on_status: Callback(status_str) called on each status poll change.
Returns:
LoginResult with token, base_url, and account_id.
Raises:
ApiError: On unrecoverable API errors.
Exception: If all retries are exhausted.
"""
last_qr_base64: Optional[str] = None
for attempt in range(max_retries):
qr_resp = await self.fetch_qrcode()
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
raise ApiError('Failed to get QR code from server', status=0)
# Convert QR image to base64 and notify caller
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
if on_qrcode:
try:
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
await result
except Exception as e:
logger.warning('on_qrcode callback error: %s', e)
# Poll until confirmed / expired / timeout
loop = asyncio.get_running_loop()
deadline = loop.time() + poll_timeout_ms / 1000.0
while loop.time() < deadline:
try:
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
except Exception as e:
logger.error('Error polling QR status: %s', e)
await asyncio.sleep(2)
continue
if on_status:
try:
cb_result = on_status(status_resp.status or 'unknown')
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
await cb_result
except Exception as e:
logger.warning('on_status callback error: %s', e)
if status_resp.status == 'confirmed' and status_resp.bot_token:
new_base_url = status_resp.baseurl or self.base_url
# Update this client instance as well
self.token = status_resp.bot_token
self.base_url = new_base_url.rstrip('/')
return LoginResult(
token=status_resp.bot_token,
base_url=new_base_url,
account_id=status_resp.ilink_bot_id or '',
qr_image_base64=last_qr_base64,
)
if status_resp.status == 'expired':
break # retry with a new QR code
await asyncio.sleep(1)
else:
# While-loop ended without break → poll timeout, treat as expired
pass
remaining = max_retries - attempt - 1
if remaining > 0:
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
else:
raise ApiError('QR code login failed: max retries exceeded', status=0)
# Should not reach here, but just in case
raise ApiError('QR code login failed', status=0)
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
if not data:
return None
return CDNMedia(
encrypt_query_param=data.get('encrypt_query_param'),
aes_key=data.get('aes_key'),
encrypt_type=data.get('encrypt_type'),
)
def _parse_message_item(data: dict) -> MessageItem:
item = MessageItem(
type=data.get('type'),
create_time_ms=data.get('create_time_ms'),
update_time_ms=data.get('update_time_ms'),
is_completed=data.get('is_completed'),
msg_id=data.get('msg_id'),
)
if data.get('text_item'):
item.text_item = TextItem(text=data['text_item'].get('text'))
if data.get('image_item'):
img = data['image_item']
item.image_item = ImageItem(
media=_parse_cdn_media(img.get('media')),
thumb_media=_parse_cdn_media(img.get('thumb_media')),
aeskey=img.get('aeskey'),
url=img.get('url'),
mid_size=img.get('mid_size'),
)
if data.get('voice_item'):
v = data['voice_item']
item.voice_item = VoiceItem(
media=_parse_cdn_media(v.get('media')),
encode_type=v.get('encode_type'),
playtime=v.get('playtime'),
text=v.get('text'),
)
if data.get('file_item'):
f = data['file_item']
item.file_item = FileItem(
media=_parse_cdn_media(f.get('media')),
file_name=f.get('file_name'),
md5=f.get('md5'),
len=f.get('len'),
)
if data.get('video_item'):
vid = data['video_item']
item.video_item = VideoItem(
media=_parse_cdn_media(vid.get('media')),
video_size=vid.get('video_size'),
play_length=vid.get('play_length'),
video_md5=vid.get('video_md5'),
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
)
if data.get('ref_msg'):
ref = data['ref_msg']
item.ref_msg = RefMessage(
title=ref.get('title'),
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
)
return item
def _parse_weixin_message(data: dict) -> WeixinMessage:
msg = WeixinMessage(
seq=data.get('seq'),
message_id=data.get('message_id'),
from_user_id=data.get('from_user_id'),
to_user_id=data.get('to_user_id'),
client_id=data.get('client_id'),
create_time_ms=data.get('create_time_ms'),
session_id=data.get('session_id'),
group_id=data.get('group_id'),
message_type=data.get('message_type'),
message_state=data.get('message_state'),
context_token=data.get('context_token'),
)
if data.get('item_list'):
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
return msg
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
resp = GetUpdatesResponse(
ret=data.get('ret'),
errcode=data.get('errcode'),
errmsg=data.get('errmsg'),
get_updates_buf=data.get('get_updates_buf'),
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
)
if data.get('msgs'):
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
return resp
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
if not media:
return None
d: dict = {}
if media.encrypt_query_param is not None:
d['encrypt_query_param'] = media.encrypt_query_param
if media.aes_key is not None:
d['aes_key'] = media.aes_key
if media.encrypt_type is not None:
d['encrypt_type'] = media.encrypt_type
return d or None
def _message_item_to_dict(item: MessageItem) -> dict:
d: dict = {'type': item.type}
if item.text_item:
d['text_item'] = {'text': item.text_item.text}
if item.image_item:
img_d: dict = {}
if item.image_item.media:
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
if item.image_item.mid_size is not None:
img_d['mid_size'] = item.image_item.mid_size
d['image_item'] = img_d
if item.voice_item:
voice_d: dict = {}
if item.voice_item.media:
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
if item.voice_item.playtime is not None:
voice_d['playtime'] = item.voice_item.playtime
d['voice_item'] = voice_d
if item.file_item:
file_d: dict = {}
if item.file_item.media:
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
if item.file_item.file_name:
file_d['file_name'] = item.file_item.file_name
if item.file_item.len:
file_d['len'] = item.file_item.len
d['file_item'] = file_d
if item.video_item:
vid_d: dict = {}
if item.video_item.media:
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
if item.video_item.video_size is not None:
vid_d['video_size'] = item.video_item.video_size
d['video_item'] = vid_d
return d

View File

@@ -0,0 +1,200 @@
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
SESSION_EXPIRED_ERRCODE = -14
class ApiError(Exception):
"""Structured error raised by the OpenClaw WeChat API."""
def __init__(
self,
message: str,
*,
status: int = 0,
code: int | None = None,
payload: Any = None,
):
super().__init__(message)
self.status = status
self.code = code
self.payload = payload
@property
def is_session_expired(self) -> bool:
return self.code == SESSION_EXPIRED_ERRCODE
@dataclass
class CDNMedia:
encrypt_query_param: Optional[str] = None
aes_key: Optional[str] = None
encrypt_type: Optional[int] = None
@dataclass
class TextItem:
text: Optional[str] = None
@dataclass
class ImageItem:
media: Optional[CDNMedia] = None
thumb_media: Optional[CDNMedia] = None
aeskey: Optional[str] = None
url: Optional[str] = None
mid_size: Optional[int] = None
thumb_size: Optional[int] = None
thumb_height: Optional[int] = None
thumb_width: Optional[int] = None
hd_size: Optional[int] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class VoiceItem:
media: Optional[CDNMedia] = None
encode_type: Optional[int] = None
bits_per_sample: Optional[int] = None
sample_rate: Optional[int] = None
playtime: Optional[int] = None
text: Optional[str] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class FileItem:
media: Optional[CDNMedia] = None
file_name: Optional[str] = None
md5: Optional[str] = None
len: Optional[str] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class VideoItem:
media: Optional[CDNMedia] = None
video_size: Optional[int] = None
play_length: Optional[int] = None
video_md5: Optional[str] = None
thumb_media: Optional[CDNMedia] = None
thumb_size: Optional[int] = None
thumb_height: Optional[int] = None
thumb_width: Optional[int] = None
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
@dataclass
class RefMessage:
message_item: Optional[MessageItem] = None
title: Optional[str] = None
@dataclass
class MessageItem:
"""A single content item inside a WeixinMessage."""
# Item types
NONE = 0
TEXT = 1
IMAGE = 2
VOICE = 3
FILE = 4
VIDEO = 5
type: Optional[int] = None
create_time_ms: Optional[int] = None
update_time_ms: Optional[int] = None
is_completed: Optional[bool] = None
msg_id: Optional[str] = None
ref_msg: Optional[RefMessage] = None
text_item: Optional[TextItem] = None
image_item: Optional[ImageItem] = None
voice_item: Optional[VoiceItem] = None
file_item: Optional[FileItem] = None
video_item: Optional[VideoItem] = None
@dataclass
class WeixinMessage:
"""Unified message from getUpdates or for sendMessage."""
# Message types
TYPE_USER = 1
TYPE_BOT = 2
# Message states
STATE_NEW = 0
STATE_GENERATING = 1
STATE_FINISH = 2
seq: Optional[int] = None
message_id: Optional[int] = None
from_user_id: Optional[str] = None
to_user_id: Optional[str] = None
client_id: Optional[str] = None
create_time_ms: Optional[int] = None
update_time_ms: Optional[int] = None
delete_time_ms: Optional[int] = None
session_id: Optional[str] = None
group_id: Optional[str] = None
message_type: Optional[int] = None
message_state: Optional[int] = None
item_list: Optional[list[MessageItem]] = None
context_token: Optional[str] = None
@dataclass
class GetUpdatesResponse:
ret: Optional[int] = None
errcode: Optional[int] = None
errmsg: Optional[str] = None
msgs: list[WeixinMessage] = field(default_factory=list)
get_updates_buf: Optional[str] = None
longpolling_timeout_ms: Optional[int] = None
@dataclass
class GetConfigResponse:
ret: Optional[int] = None
errmsg: Optional[str] = None
typing_ticket: Optional[str] = None
@dataclass
class GetUploadUrlResponse:
upload_param: Optional[str] = None
thumb_upload_param: Optional[str] = None
@dataclass
class QRCodeResponse:
"""Response from get_bot_qrcode endpoint."""
qrcode: Optional[str] = None
qrcode_img_content: Optional[str] = None
@dataclass
class QRStatusResponse:
"""Response from get_qrcode_status endpoint."""
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
bot_token: Optional[str] = None
ilink_bot_id: Optional[str] = None
baseurl: Optional[str] = None
ilink_user_id: Optional[str] = None
@dataclass
class LoginResult:
"""Result returned by the login flow."""
token: str
base_url: str
account_id: str
qr_image_base64: Optional[str] = None # data URI of the last QR code shown

View File

@@ -199,6 +199,253 @@ class StreamSessionManager:
self._msg_index.pop(msg_id, None) self._msg_index.pop(msg_id, None)
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:
"""Download an AES-encrypted file from WeChat Work and return as data URI.
Args:
download_url: The encrypted file download URL.
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
logger: Logger instance.
Returns:
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
"""
if not download_url:
return None
async with httpx.AsyncClient() as client:
response = await client.get(download_url)
if response.status_code != 200:
await logger.error(f'failed to get file: {response.text}')
return None
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + '=')
iv = aes_key[:16]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted_bytes)
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
if decrypted.startswith(b'\xff\xd8'):
mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'):
mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
mime_type = 'image/gif'
elif decrypted.startswith(b'BM'):
mime_type = 'image/bmp'
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
mime_type = 'image/tiff'
else:
mime_type = 'application/octet-stream'
base64_str = base64.b64encode(decrypted).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
async def parse_wecom_bot_message(
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
) -> dict[str, Any]:
"""Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.
This is the shared message parsing logic used by both webhook and WebSocket modes.
Args:
msg_json: The decrypted message JSON from WeChat Work.
encoding_aes_key: AES key for file decryption.
logger: Logger instance.
Returns:
A dict suitable for constructing a WecomBotEvent.
"""
message_data: dict[str, Any] = {}
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
max_inline_file_size = 5 * 1024 * 1024
async def _safe_download(url: str):
if not url:
return None
return await download_encrypted_file(url, encoding_aes_key, logger)
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '')
base64_data = await _safe_download(picurl)
if base64_data:
message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
voices = []
videos = []
links = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_url = item.get('image', {}).get('url')
base64_data = await _safe_download(img_url)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
if texts:
message_data['content'] = ' '.join(texts)
if images:
message_data['images'] = images
message_data['picurl'] = images[0]
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
from_info = msg_json.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
if msg_json.get('chattype', '') == 'group':
message_data['chatid'] = msg_json.get('chatid', '')
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
message_data['msgid'] = msg_json.get('msgid', '')
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
return message_data
class WecomBotClient: class WecomBotClient:
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False): def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
"""企业微信智能机器人客户端。 """企业微信智能机器人客户端。
@@ -455,196 +702,7 @@ class WecomBotClient:
return await self._handle_post_initial_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce)
async def get_message(self, msg_json): async def get_message(self, msg_json):
message_data = {} return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
async def _safe_download(url: str):
if not url:
return None
return await self.download_url_to_base64(url, self.EnCodingAESKey)
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '')
base64_data = await _safe_download(picurl)
if base64_data:
message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
voices = []
videos = []
links = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_url = item.get('image', {}).get('url')
base64_data = await _safe_download(img_url)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
if texts:
message_data['content'] = ' '.join(texts) # 拼接所有 text
if images:
message_data['images'] = images
message_data['picurl'] = images[0] # 只保留第一个 image
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
# Extract user information
from_info = msg_json.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = (
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
)
# Extract chat/group information
if msg_json.get('chattype', '') == 'group':
message_data['chatid'] = msg_json.get('chatid', '')
# Try to get group name if available
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
message_data['msgid'] = msg_json.get('msgid', '')
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
return message_data
async def _handle_message(self, event: wecombotevent.WecomBotEvent): async def _handle_message(self, event: wecombotevent.WecomBotEvent):
""" """
@@ -712,39 +770,7 @@ class WecomBotClient:
return decorator return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key): async def download_url_to_base64(self, download_url, encoding_aes_key):
async with httpx.AsyncClient() as client: return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
response = await client.get(download_url)
if response.status_code != 200:
await self.logger.error(f'failed to get file: {response.text}')
return None
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
iv = aes_key[:16]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted_bytes)
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
if decrypted.startswith(b'\xff\xd8'): # JPEG
mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'): # PNG
mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
mime_type = 'image/gif'
elif decrypted.startswith(b'BM'): # BMP
mime_type = 'image/bmp'
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
mime_type = 'image/tiff'
else:
mime_type = 'application/octet-stream'
# 转 base64
base64_str = base64.b64encode(decrypted).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
async def run_task(self, host: str, port: int, *args, **kwargs): async def run_task(self, host: str, port: int, *args, **kwargs):
""" """

View File

@@ -0,0 +1,596 @@
"""WeChat Work AI Bot WebSocket long connection client.
Implements the WebSocket protocol for receiving messages and sending replies
via a persistent connection to wss://openws.work.weixin.qq.com, as an
alternative to the HTTP callback (webhook) mode.
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
"""
from __future__ import annotations
import asyncio
import json
import secrets
import time
import traceback
from typing import Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
# WebSocket frame command constants
CMD_SUBSCRIBE = 'aibot_subscribe'
CMD_HEARTBEAT = 'ping'
CMD_MSG_CALLBACK = 'aibot_msg_callback'
CMD_EVENT_CALLBACK = 'aibot_event_callback'
CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg'
def _generate_req_id(prefix: str) -> str:
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
ts = int(time.time() * 1000)
rand = secrets.token_hex(4)
return f'{prefix}_{ts}_{rand}'
class WecomBotWsClient:
"""WeChat Work AI Bot WebSocket long connection client.
Provides message receiving, streaming reply, proactive message sending,
and event callback handling over a persistent WebSocket connection.
"""
def __init__(
self,
bot_id: str,
secret: str,
logger: EventLogger,
encoding_aes_key: str = '',
ws_url: str = DEFAULT_WS_URL,
heartbeat_interval: float = 30.0,
max_reconnect_attempts: int = -1,
reconnect_base_delay: float = 1.0,
reconnect_max_delay: float = 30.0,
):
self.bot_id = bot_id
self.secret = secret
self.logger = logger
self.encoding_aes_key = encoding_aes_key
self.ws_url = ws_url
self.heartbeat_interval = heartbeat_interval
self.max_reconnect_attempts = max_reconnect_attempts
self.reconnect_base_delay = reconnect_base_delay
self.reconnect_max_delay = reconnect_max_delay
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._session: Optional[aiohttp.ClientSession] = None
self._running = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._missed_pong_count = 0
self._max_missed_pong = 2
self._reconnect_attempts = 0
# Message handler registry (same pattern as WecomBotClient)
self._message_handlers: dict[str, list[Callable]] = {}
# Message deduplication
self._msg_id_map: dict[str, int] = {}
# Pending ACK futures: req_id -> Future[dict]
self._pending_acks: dict[str, asyncio.Future] = {}
# Per-req_id serial reply queues
self._reply_queues: dict[str, asyncio.Queue] = {}
self._reply_workers: dict[str, asyncio.Task] = {}
self._reply_ack_timeout = 5.0
# Stream ID tracking for WebSocket mode
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# ── Public API ──────────────────────────────────────────────────
async def connect(self):
"""Connect to WebSocket server with automatic reconnection.
This method blocks until disconnect() is called or max reconnect
attempts are exhausted.
"""
self._running = True
self._reconnect_attempts = 0
while self._running:
try:
await self._connect_once()
except Exception:
if not self._running:
break
await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')
if not self._running:
break
# Reconnect with exponential backoff
if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:
await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')
break
self._reconnect_attempts += 1
delay = min(
self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),
self.reconnect_max_delay,
)
await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')
await asyncio.sleep(delay)
async def disconnect(self):
"""Gracefully disconnect from the WebSocket server."""
self._running = False
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
for task in self._reply_workers.values():
if not task.done():
task.cancel()
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def on_message(self, msg_type: str) -> Callable:
"""Decorator to register a message handler.
Same interface as WecomBotClient.on_message for compatibility.
Args:
msg_type: 'single', 'group', or specific message type.
"""
def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def reply_stream(
self,
req_id: str,
stream_id: str,
content: str,
finish: bool = False,
) -> Optional[dict]:
"""Send a streaming reply frame.
Args:
req_id: The req_id from the original message frame (must be passed through).
stream_id: The stream ID for this streaming session.
content: The content to send (supports Markdown).
finish: Whether this is the final chunk.
Returns:
The ACK frame dict, or None on failure.
"""
body = {
'msgtype': 'stream',
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
}
return await self._send_reply(req_id, body)
async def reply_text(self, req_id: str, content: str) -> Optional[dict]:
"""Send a non-streaming text reply.
Args:
req_id: The req_id from the original message frame.
content: The text content to reply.
Returns:
The ACK frame dict, or None on failure.
"""
body = {
'msgtype': 'markdown',
'markdown': {
'content': content,
},
}
return await self._send_reply(req_id, body)
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
"""Proactively send a message to a specified chat.
Args:
chat_id: The chat ID (userid for single chat, chatid for group chat).
content: The message content.
msgtype: Message type, 'markdown' by default.
Returns:
The ACK frame dict, or None on failure.
"""
req_id = _generate_req_id(CMD_SEND_MSG)
body: dict[str, Any] = {
'chatid': chat_id,
'msgtype': msgtype,
}
if msgtype == 'markdown':
body['markdown'] = {'content': content}
elif msgtype == 'text':
body['text'] = {'content': content}
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID.
Compatible interface with WecomBotClient.push_stream_chunk.
Args:
msg_id: The original message ID.
content: The cumulative content from the pipeline.
is_final: Whether this is the final chunk.
Returns:
True if the stream session exists and chunk was sent.
"""
key = self._stream_ids.get(msg_id)
if not key:
return False
req_id, stream_id = key.split('|', 1)
try:
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
return True
await self.reply_stream(req_id, stream_id, content, finish=is_final)
self._stream_last_content[msg_id] = content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
return True
except Exception:
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
return False
async def set_message(self, msg_id: str, content: str):
"""Fallback: send content as a final stream chunk or direct reply.
Compatible interface with WecomBotClient.set_message.
"""
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
if not handled:
await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')
# ── Connection lifecycle ────────────────────────────────────────
async def _connect_once(self):
"""Establish a single WebSocket connection, authenticate, and listen."""
await self.logger.info(f'Connecting to {self.ws_url}...')
self._session = aiohttp.ClientSession()
try:
self._ws = await self._session.ws_connect(self.ws_url)
self._missed_pong_count = 0
self._reconnect_attempts = 0
await self.logger.info('WebSocket connected, sending auth...')
await self._send_auth()
# Wait for auth response
auth_ok = await self._wait_for_auth()
if not auth_ok:
await self.logger.error('Authentication failed')
return
await self.logger.info('Authenticated successfully')
# Start heartbeat
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
await self._listen_loop()
finally:
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
self._clear_pending_acks('Connection closed')
finally:
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def _send_auth(self):
"""Send the authentication frame."""
frame = {
'cmd': CMD_SUBSCRIBE,
'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},
'body': {
'bot_id': self.bot_id,
'secret': self.secret,
},
}
await self._send_frame(frame)
async def _wait_for_auth(self) -> bool:
"""Wait for and validate the authentication response."""
try:
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
if msg.type in (aiohttp.WSMsgType.TEXT,):
frame = json.loads(msg.data)
req_id = frame.get('headers', {}).get('req_id', '')
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
return True
await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}')
return False
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
await self.logger.error(f'WebSocket closed during auth: {msg.type}')
return False
await self.logger.error(f'Unexpected message type during auth: {msg.type}')
return False
except asyncio.TimeoutError:
await self.logger.error('Auth response timeout')
return False
async def _heartbeat_loop(self):
"""Periodically send heartbeat pings."""
try:
while self._running and self._ws and not self._ws.closed:
await asyncio.sleep(self.heartbeat_interval)
if not self._running or not self._ws or self._ws.closed:
break
if self._missed_pong_count >= self._max_missed_pong:
await self.logger.warning(
f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'
)
await self._ws.close()
break
self._missed_pong_count += 1
frame = {
'cmd': CMD_HEARTBEAT,
'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},
}
try:
await self._send_frame(frame)
except Exception:
break
except asyncio.CancelledError:
pass
async def _listen_loop(self):
"""Listen for incoming WebSocket frames and dispatch them."""
async for msg in self._ws:
if not self._running:
break
if msg.type == aiohttp.WSMsgType.TEXT:
try:
frame = json.loads(msg.data)
await self._handle_frame(frame)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
except Exception:
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
elif msg.type == aiohttp.WSMsgType.BINARY:
try:
frame = json.loads(msg.data)
await self._handle_frame(frame)
except Exception:
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
await self.logger.warning(f'WebSocket connection closed: {msg.type}')
break
# ── Frame handling ──────────────────────────────────────────────
async def _handle_frame(self, frame: dict):
"""Route an incoming frame to the appropriate handler."""
cmd = frame.get('cmd', '')
# Message push
if cmd == CMD_MSG_CALLBACK:
asyncio.create_task(self._handle_message_callback(frame))
return
# Event push
if cmd == CMD_EVENT_CALLBACK:
asyncio.create_task(self._handle_event_callback(frame))
return
# No cmd → response/ACK frame, dispatch by req_id prefix
req_id = frame.get('headers', {}).get('req_id', '')
# Check pending ACKs first
if req_id in self._pending_acks:
future = self._pending_acks.pop(req_id)
if not future.done():
future.set_result(frame)
return
# Heartbeat response
if req_id.startswith(CMD_HEARTBEAT):
if frame.get('errcode') == 0:
self._missed_pong_count = 0
return
# Unknown frame
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
# Parse message using shared logic
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
if not message_data:
return
# Generate stream_id for this message and store the mapping
stream_id = _generate_req_id('stream')
msg_id = message_data.get('msgid', '')
if msg_id:
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
event = wecombotevent.WecomBotEvent(message_data)
await self._dispatch_event(event)
except Exception:
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
async def _handle_event_callback(self, frame: dict):
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
try:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_info = body.get('event', {})
event_type = event_info.get('eventtype', '')
message_data = {
'msgtype': 'event',
'type': body.get('chattype', 'single'),
'event': event_info,
'eventtype': event_type,
'msgid': body.get('msgid', ''),
'aibotid': body.get('aibotid', ''),
'req_id': req_id,
}
from_info = body.get('from', {})
message_data['userid'] = from_info.get('userid', '')
message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')
if body.get('chatid'):
message_data['chatid'] = body.get('chatid', '')
event = wecombotevent.WecomBotEvent(message_data)
# Dispatch to event-specific handlers
if event_type in self._message_handlers:
for handler in self._message_handlers[event_type]:
await handler(event)
# Also dispatch to generic 'event' handlers
if 'event' in self._message_handlers:
for handler in self._message_handlers['event']:
await handler(event)
except Exception:
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
"""Dispatch a message event to registered handlers with deduplication."""
try:
message_id = event.message_id
if message_id in self._msg_id_map:
self._msg_id_map[message_id] += 1
return
self._msg_id_map[message_id] = 1
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
except Exception:
await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')
# ── Reply sending with serial queue ─────────────────────────────
async def _send_reply(
self,
req_id: str,
body: dict,
cmd: str = CMD_RESPOND_MSG,
) -> Optional[dict]:
"""Send a reply frame and wait for ACK.
Replies with the same req_id are serialized to maintain ordering.
"""
if not self._ws or self._ws.closed:
return None
frame = {
'cmd': cmd,
'headers': {'req_id': req_id},
'body': body,
}
# Ensure serial delivery per req_id
if req_id not in self._reply_queues:
self._reply_queues[req_id] = asyncio.Queue()
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
future: asyncio.Future = asyncio.get_event_loop().create_future()
await self._reply_queues[req_id].put((frame, future))
return await future
async def _reply_queue_worker(self, req_id: str):
"""Process reply queue items serially for a given req_id."""
queue = self._reply_queues[req_id]
try:
while self._running:
try:
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError:
# Queue idle, clean up worker
break
try:
ack = await self._send_and_wait_ack(frame)
if not future.done():
future.set_result(ack)
except Exception as e:
if not future.done():
future.set_exception(e)
except asyncio.CancelledError:
pass
finally:
self._reply_queues.pop(req_id, None)
self._reply_workers.pop(req_id, None)
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
"""Send a frame and wait for the corresponding ACK."""
req_id = frame['headers']['req_id']
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending_acks[req_id] = ack_future
try:
await self._send_frame(frame)
result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)
if result.get('errcode', 0) != 0:
await self.logger.warning(
f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}'
)
return result
except asyncio.TimeoutError:
self._pending_acks.pop(req_id, None)
await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')
return None
async def _send_frame(self, frame: dict):
"""Send a JSON frame over the WebSocket connection."""
if self._ws and not self._ws.closed:
await self._ws.send_str(json.dumps(frame, ensure_ascii=False))
def _clear_pending_acks(self, reason: str):
"""Reject all pending ACK futures on disconnection."""
for req_id, future in self._pending_acks.items():
if not future.done():
future.set_exception(ConnectionError(reason))
self._pending_acks.clear()

View File

@@ -4,6 +4,7 @@ import base64
import binascii import binascii
import httpx import httpx
import traceback import traceback
from urllib.parse import quote
from quart import Quart from quart import Quart
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any from typing import Callable, Dict, Any
@@ -67,6 +68,31 @@ class WecomClient:
await self.logger.error(f'获取accesstoken失败:{response.json()}') await self.logger.error(f'获取accesstoken失败:{response.json()}')
raise Exception(f'未获取access token: {data}') raise Exception(f'未获取access token: {data}')
async def get_user_info(self, userid: str) -> dict:
"""
Get user information by user ID using the application secret.
Args:
userid: The user ID to look up.
Returns:
dict: User information including 'name' field.
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_user_info(userid)
if data.get('errcode', 0) != 0:
await self.logger.error(f'获取用户信息失败:{data}')
return {}
return data
async def get_users(self): async def get_users(self):
if not self.check_access_token_for_contacts(): if not self.check_access_token_for_contacts():
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts) self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)

View File

@@ -10,6 +10,7 @@ from typing import Callable
from .wecomcsevent import WecomCSEvent from .wecomcsevent import WecomCSEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles import aiofiles
import time
class WecomCSClient: class WecomCSClient:
@@ -34,6 +35,10 @@ class WecomCSClient:
self.unified_mode = unified_mode self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
# Customer info cache: {external_userid: (info_dict, timestamp)}
self._customer_cache: dict[str, tuple[dict, float]] = {}
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
# 只有在非统一模式下才注册独立路由 # 只有在非统一模式下才注册独立路由
if not self.unified_mode: if not self.unified_mode:
self.app.add_url_rule( self.app.add_url_rule(
@@ -378,3 +383,53 @@ class WecomCSClient:
async def get_media_id(self, image: platform_message.Image): async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image) media_id = await self.upload_to_work(image=image)
return media_id return media_id
async def get_customer_info(self, external_userid: str) -> dict | None:
"""
Get customer information by external_userid with caching.
Uses a 1-minute cache to avoid repeated API calls for the same user.
Args:
external_userid: The external user ID of the customer.
Returns:
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
"""
# Check cache first
current_time = time.time()
if external_userid in self._customer_cache:
cached_info, cached_time = self._customer_cache[external_userid]
if current_time - cached_time < self._cache_ttl:
return cached_info
# Cache miss or expired, fetch from API
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
payload = {
'external_userid_list': [external_userid],
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_customer_info(external_userid)
if data.get('errcode', 0) != 0:
if self.logger:
await self.logger.warning(f'Failed to get customer info: {data}')
return None
customer_list = data.get('customer_list', [])
if customer_list:
customer_info = customer_list[0]
# Store in cache
self._customer_cache[external_userid] = (customer_info, current_time)
return customer_info
return None

View File

@@ -13,9 +13,9 @@ from .. import group
@group.group_class('files', '/api/v1/files') @group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup): class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE) @self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response: async def _(image_key: str) -> quart.Response:
if '/' in image_key or '\\' in image_key: if '..' in image_key or '\\' in image_key:
return quart.Response(status=404) return quart.Response(status=404)
if not await self.ap.storage_mgr.storage_provider.exists(image_key): if not await self.ap.storage_mgr.storage_provider.exists(image_key):

View File

@@ -0,0 +1,372 @@
import asyncio
import json
import httpx
import quart
import sqlalchemy
from ... import group
from ......core import taskmgr
from ......entity.persistence import metadata as persistence_metadata
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
LANGRAG_PLUGIN_NAME = 'LangRAG'
LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'
DEFAULT_SPACE_URL = 'https://space.langbot.app'
# Old Retriever plugin_name -> New Connector plugin_name
EXTERNAL_PLUGIN_NAME_MAPPING = {
'DifyDatasetsRetriever': 'DifyDatasetsConnector',
'RAGFlowRetriever': 'RAGFlowConnector',
'FastGPTRetriever': 'FastGPTConnector',
}
# Per-plugin: which old retriever_config fields belong to creation_settings.
# Remaining fields go to retrieval_settings.
# None means ALL fields go to creation_settings (no retrieval_schema).
EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},
'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
}
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
class KnowledgeMigrationRouterGroup(group.RouterGroup):
async def _get_migration_flag(self) -> bool:
"""Check if rag_plugin_migration_needed flag is set."""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_metadata.Metadata).where(
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
)
)
row = result.first()
return row is not None and row.value == 'true'
async def _set_migration_flag(self, value: str):
"""Set rag_plugin_migration_needed flag."""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_metadata.Metadata)
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
.values(value=value)
)
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return result.scalar()
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def _install_plugin_from_marketplace(
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
) -> None:
"""Install a single plugin from the marketplace."""
p_author, p_name = plugin_id.split('/', 1)
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
resp.raise_for_status()
p_data = resp.json().get('data', {}).get('plugin', {})
p_version = p_data.get('latest_version')
if not p_version:
raise Exception(f'Could not determine latest version for {plugin_id}')
await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
{
'plugin_author': p_author,
'plugin_name': p_name,
'plugin_version': p_version,
},
task_context=task_context,
)
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
"""Execute RAG migration: install required plugins and restore backup data."""
warnings = []
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
needed_plugins: dict[str, str] = {
LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,
}
has_external = await self._table_exists('external_knowledge_bases')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
)
for row in result.fetchall():
plugin_author = row[0] or ''
plugin_name = row[1] or ''
mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
plugin_id = f'{plugin_author}/{mapped_name}'
if plugin_id not in needed_plugins:
needed_plugins[plugin_id] = mapped_name
self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')
if install_plugin:
# Step 1: Install all required plugins from marketplace
task_context.trace('Installing required plugins...', action='install-plugin')
space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')
for plugin_id in needed_plugins:
try:
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
except Exception as e:
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
# Step 2: Wait for all plugins to become available as knowledge engines
task_context.trace(
f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',
action='wait-plugin',
)
max_retries = 30
engine_id_set: set[str] = set()
for i in range(max_retries):
try:
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except Exception:
pass
if all(pid in engine_id_set for pid in needed_plugins):
self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')
task_context.trace('All required plugins are ready.')
break
if i == max_retries - 1:
still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]
warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'
self.ap.logger.warning(f'RAG migration: {warning}')
warnings.append(warning)
task_context.trace(warning)
await asyncio.sleep(2)
else:
try:
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
except Exception:
engine_id_set = set()
# Step 3: Restore internal knowledge bases from backup
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
)
rows = result.fetchall()
columns = result.keys()
for row in rows:
row_dict = dict(zip(columns, row))
kb_uuid = row_dict.get('uuid')
name = row_dict.get('name', 'Untitled')
description = row_dict.get('description', '')
emoji = row_dict.get('emoji', '\U0001f4da')
embedding_model_uuid = row_dict.get('embedding_model_uuid', '')
top_k = row_dict.get('top_k', 5)
created_at = row_dict.get('created_at')
updated_at = row_dict.get('updated_at')
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=updated_at,
plugin_id=LANGRAG_PLUGIN_ID,
collection_id=kb_uuid,
creation_settings=creation_settings,
retrieval_settings=retrieval_settings,
)
)
try:
config = {'embedding_model_uuid': embedding_model_uuid}
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')
except Exception as e:
warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'
warnings.append(warning)
task_context.trace(warning)
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 4: Restore external knowledge bases
task_context.trace('Restoring external knowledge bases...', action='restore-external')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
)
rows = result.fetchall()
columns = result.keys()
self.ap.logger.info(
f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'
)
task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')
for row in rows:
row_dict = dict(zip(columns, row))
kb_uuid = row_dict.get('uuid')
name = row_dict.get('name', 'Untitled')
description = row_dict.get('description', '')
emoji = row_dict.get('emoji', '\U0001f517')
plugin_author = row_dict.get('plugin_author', '')
plugin_name = row_dict.get('plugin_name', '')
retriever_config = row_dict.get('retriever_config', {})
created_at = row_dict.get('created_at')
mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'
self.ap.logger.info(
f'RAG migration: processing external KB "{name}" ({kb_uuid}), '
f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'
)
if isinstance(retriever_config, str):
try:
retriever_config = json.loads(retriever_config)
except (json.JSONDecodeError, TypeError):
retriever_config = {}
creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)
if creation_fields is None:
creation_settings_dict = retriever_config
retrieval_settings_dict = {}
else:
creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
uuid=kb_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=created_at,
plugin_id=external_plugin_id,
collection_id=kb_uuid,
creation_settings=json.dumps(creation_settings_dict),
retrieval_settings=json.dumps(retrieval_settings_dict),
)
)
if external_plugin_id not in engine_id_set:
warning = (
f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} '
f'is not installed yet. Install the connector plugin to use it.'
)
warnings.append(warning)
task_context.trace(warning)
else:
try:
await self.ap.plugin_connector.rag_on_kb_create(
external_plugin_id, kb_uuid, creation_settings_dict
)
task_context.trace(f'Restored external KB: {name} ({kb_uuid})')
except Exception as e:
warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'
warnings.append(warning)
task_context.trace(warning)
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 5: Clear migration flag
await self._set_migration_flag('false')
task_context.trace('RAG migration completed.', action='done')
if warnings:
task_context.trace(f'Completed with {len(warnings)} warning(s).')
async def initialize(self) -> None:
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
internal_kb_count = 0
external_kb_count = 0
if needed:
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
)
internal_kb_count = result.scalar() or 0
if await self._table_exists('external_knowledge_bases'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
)
external_kb_count = result.scalar() or 0
return self.success(
data={
'needed': needed,
'internal_kb_count': internal_kb_count,
'external_kb_count': external_kb_count,
}
)
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
data = await quart.request.get_json(silent=True) or {}
install_plugin = data.get('install_plugin', True)
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
kind='rag-migration',
name='rag-migration-execute',
label='Migrating knowledge bases to plugin architecture',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
needed = await self._get_migration_flag()
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
await self._set_migration_flag('false')
return self.success()

View File

@@ -70,12 +70,17 @@ class BotService:
'lark', 'lark',
]: ]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
webhook_url = f'/bots/{bot_uuid}' webhook_url = f'/bots/{bot_uuid}'
adapter_runtime_values['webhook_url'] = webhook_url adapter_runtime_values['webhook_url'] = webhook_url
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}' adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
adapter_runtime_values['extra_webhook_full_url'] = (
f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''
)
else: else:
adapter_runtime_values['webhook_url'] = None adapter_runtime_values['webhook_url'] = None
adapter_runtime_values['webhook_full_url'] = None adapter_runtime_values['webhook_full_url'] = None
adapter_runtime_values['extra_webhook_full_url'] = None
persistence_bot['adapter_runtime_values'] = adapter_runtime_values persistence_bot['adapter_runtime_values'] = adapter_runtime_values

View File

@@ -105,11 +105,16 @@ class LLMModelsService:
) )
) )
pipeline = result.first() pipeline = result.first()
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '': if pipeline is not None:
pipeline_config = pipeline.config model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
pipeline_config['ai']['local-agent']['model'] = model_data['uuid'] if not model_config.get('primary', ''):
pipeline_data = {'config': pipeline_config} pipeline_config = pipeline.config
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data) pipeline_config['ai']['local-agent']['model'] = {
'primary': model_data['uuid'],
'fallbacks': [],
}
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
return model_data['uuid'] return model_data['uuid']

View File

@@ -30,6 +30,7 @@ class MonitoringService:
level: str = 'info', level: str = 'info',
platform: str | None = None, platform: str | None = None,
user_id: str | None = None, user_id: str | None = None,
user_name: str | None = None,
runner_name: str | None = None, runner_name: str | None = None,
variables: str | None = None, variables: str | None = None,
role: str = 'user', role: str = 'user',
@@ -49,6 +50,7 @@ class MonitoringService:
'level': level, 'level': level,
'platform': platform, 'platform': platform,
'user_id': user_id, 'user_id': user_id,
'user_name': user_name,
'runner_name': runner_name, 'runner_name': runner_name,
'variables': variables, 'variables': variables,
'role': role, 'role': role,
@@ -152,6 +154,7 @@ class MonitoringService:
pipeline_name: str, pipeline_name: str,
platform: str | None = None, platform: str | None = None,
user_id: str | None = None, user_id: str | None = None,
user_name: str | None = None,
) -> None: ) -> None:
"""Record a new session""" """Record a new session"""
session_data = { session_data = {
@@ -166,6 +169,7 @@ class MonitoringService:
'is_active': True, 'is_active': True,
'platform': platform, 'platform': platform,
'user_id': user_id, 'user_id': user_id,
'user_name': user_name,
} }
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(

View File

@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher from ..platform.webhook_pusher import WebhookPusher
from ..provider.session import sessionmgr as llm_session_mgr from ..provider.session import sessionmgr as llm_session_mgr
from ..provider.modelmgr import modelmgr as llm_model_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
from ..config import manager as config_mgr from ..config import manager as config_mgr
from ..command import cmdmgr from ..command import cmdmgr
@@ -30,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service from ..api.http.service import monitoring as monitoring_service
from ..discover import engine as discover_engine from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr from ..storage import mgr as storagemgr
from ..utils import logcache from ..utils import logcache

View File

@@ -74,20 +74,26 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
current = cfg current = cfg
for i, key in enumerate(keys): for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current: if not isinstance(current, dict):
break break
if i == len(keys) - 1: if i == len(keys) - 1:
# At the final key - check if it's a scalar value # At the final key
if isinstance(current[key], (dict, list)): if key in current:
# Skip dict and list types if isinstance(current[key], (dict, list)):
pass # Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else: else:
# Valid scalar value - convert and set it # Key doesn't exist yet - create it as string
converted_value = convert_value(env_value, current[key]) current[key] = env_value
current[key] = converted_value
else: else:
# Navigate deeper # Navigate deeper - create intermediate dict if needed
if key not in current:
current[key] = {}
current = current[key] current = current[key]
return cfg return cfg
@@ -146,16 +152,50 @@ class LoadConfigStage(stage.BootingStage):
await ap.instance_config.dump_config() await ap.instance_config.dump_config()
# load or generate instance id # load or generate instance id
ap.instance_id = await config.load_json_config( # Priority:
'data/labels/instance_id.json', # 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
template_data={ # 2. data/labels/instance_id.json (if file exists)
'instance_id': f'instance_{str(uuid.uuid4())}', # 3. Generate new and save to file
'instance_create_ts': int(time.time()), config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
},
completion=False,
)
constants.instance_id = ap.instance_id.data['instance_id'] if config_instance_id:
# Use the instance_id from config.yaml
constants.instance_id = config_instance_id
# Still load/create the file for backward compat, but don't use its value
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,
)
else:
# Try loading file-based instance id
instance_id_path = os.path.join('data', 'labels', 'instance_id.json')
if os.path.exists(instance_id_path):
# File exists, read it
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': '',
'instance_create_ts': 0,
},
completion=False,
)
constants.instance_id = ap.instance_id.data['instance_id']
else:
# Neither config nor file, generate new and save to file
new_id = f'instance_{str(uuid.uuid4())}'
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': new_id,
'instance_create_ts': int(time.time()),
},
completion=False,
)
constants.instance_id = new_id
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
print(f'LangBot instance id: {constants.instance_id}') print(f'LangBot instance id: {constants.instance_id}')

View File

@@ -20,6 +20,7 @@ class MonitoringMessage(Base):
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
@@ -64,6 +65,7 @@ class MonitoringSession(Base):
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True) is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
class MonitoringError(Base): class MonitoringError(Base):

View File

@@ -1,5 +1,3 @@
import json
import sqlalchemy import sqlalchemy
from .. import migration from .. import migration
@@ -9,20 +7,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
"""Migrate to unified Knowledge Engine plugin architecture. """Migrate to unified Knowledge Engine plugin architecture.
Changes: Changes:
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases - Backup existing knowledge_bases data to knowledge_bases_backup
- Migrate existing top_k values into retrieval_settings JSON - Clear knowledge_bases table and add new plugin architecture columns
- Migrate existing embedding_model_uuid into creation_settings JSON - Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped) - Preserve external_knowledge_bases table as-is for future migration
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated) - Set rag_plugin_migration_needed flag in metadata if old data exists
""" """
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
has_internal_data = await self._backup_knowledge_bases()
has_external_data = await self._check_external_knowledge_bases()
await self._clear_knowledge_bases()
await self._add_columns_to_knowledge_bases() await self._add_columns_to_knowledge_bases()
await self._migrate_top_k_to_retrieval_settings()
await self._migrate_embedding_model_uuid_to_creation_settings()
await self._drop_old_columns() await self._drop_old_columns()
await self._drop_external_knowledge_bases_table() if has_internal_data or has_external_data:
await self._set_migration_flag()
async def _get_table_columns(self, table_name: str) -> list[str]: async def _get_table_columns(self, table_name: str) -> list[str]:
"""Get column names from a table (works for both SQLite and PostgreSQL).""" """Get column names from a table (works for both SQLite and PostgreSQL)."""
@@ -57,6 +57,50 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
) )
return result.first() is not None return result.first() is not None
async def _backup_knowledge_bases(self) -> bool:
"""Backup knowledge_bases data. Returns True if data was backed up."""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))
count = result.scalar()
if count == 0:
return False
# Drop backup table if it already exists (from a previous failed migration)
if await self._table_exists('knowledge_bases_backup'):
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')
)
self.ap.logger.info(
'Backed up %d knowledge base(s) to knowledge_bases_backup table.',
count,
)
return True
async def _check_external_knowledge_bases(self) -> bool:
"""Check if external_knowledge_bases table exists and has data.
The table is preserved as-is (not dropped) for future migration.
"""
if not await self._table_exists('external_knowledge_bases'):
return False
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
)
count = result.scalar()
if count > 0:
self.ap.logger.info(
'Found %d external knowledge base(s) in external_knowledge_bases table. '
'Table preserved for future migration.',
count,
)
return count > 0
async def _clear_knowledge_bases(self):
"""Clear all rows from knowledge_bases table (preserve table structure)."""
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))
async def _add_columns_to_knowledge_bases(self): async def _add_columns_to_knowledge_bases(self):
"""Add new RAG plugin architecture columns to knowledge_bases table.""" """Add new RAG plugin architecture columns to knowledge_bases table."""
columns = await self._get_table_columns('knowledge_bases') columns = await self._get_table_columns('knowledge_bases')
@@ -74,73 +118,6 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
) )
# For existing knowledge bases without knowledge_engine_plugin_id,
# set collection_id = uuid (same default as new KBs)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;')
)
async def _migrate_top_k_to_retrieval_settings(self):
"""Migrate existing top_k values into retrieval_settings JSON."""
columns = await self._get_table_columns('knowledge_bases')
if 'top_k' not in columns:
return
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;'
)
)
rows = result.fetchall()
for row in rows:
kb_uuid = row[0]
top_k = row[1]
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams(
rs=retrieval_settings, uuid=kb_uuid
)
)
async def _migrate_embedding_model_uuid_to_creation_settings(self):
"""Migrate existing embedding_model_uuid into creation_settings JSON."""
columns = await self._get_table_columns('knowledge_bases')
if 'embedding_model_uuid' not in columns:
return
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases '
"WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';"
)
)
rows = result.fetchall()
for row in rows:
kb_uuid = row[0]
emb_uuid = row[1]
existing_settings = row[2]
if existing_settings and isinstance(existing_settings, str):
try:
settings = json.loads(existing_settings)
except (json.JSONDecodeError, TypeError):
settings = {}
elif isinstance(existing_settings, dict):
settings = existing_settings
else:
settings = {}
if 'embedding_model_uuid' not in settings:
settings['embedding_model_uuid'] = emb_uuid
new_settings = json.dumps(settings)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;'
).bindparams(cs=new_settings, uuid=kb_uuid)
)
async def _drop_old_columns(self): async def _drop_old_columns(self):
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only). """Drop embedding_model_uuid and top_k columns (PostgreSQL only).
@@ -162,22 +139,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
) )
async def _drop_external_knowledge_bases_table(self): async def _set_migration_flag(self):
"""Drop the external_knowledge_bases table if it exists.""" """Set rag_plugin_migration_needed flag in metadata table."""
if await self._table_exists('external_knowledge_bases'): # Check if the key already exists
# Log existing external KBs before dropping, so users are aware of data loss result = await self.ap.persistence_mgr.execute_async(
rows = await self.ap.persistence_mgr.execute_async( sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
sqlalchemy.text('SELECT * FROM external_knowledge_bases;') )
row = result.first()
if row is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';")
) )
existing = rows.fetchall() else:
if existing: await self.ap.persistence_mgr.execute_async(
self.ap.logger.warning( sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
'Dropping external_knowledge_bases table with %d existing record(s). ' )
'These external KB configurations will be removed: %s', self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
len(existing),
[dict(row._mapping) for row in existing],
)
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;'))
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -0,0 +1,74 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(21)
class DBMigrateMergeExceptionHandling(migration.DBMigration):
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
and add failure-hint field.
Conversion logic:
- block-failed-request-output=true -> exception-handling: hide
- hide-exception=true -> exception-handling: show-hint
- hide-exception=false -> exception-handling: show-error
"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'output' not in config:
config['output'] = {}
if 'misc' not in config['output']:
config['output']['misc'] = {}
misc = config['output']['misc']
# Determine new exception-handling value from legacy fields
hide_exception = misc.get('hide-exception', True)
block_failed = misc.get('block-failed-request-output', False)
if block_failed:
exception_handling = 'hide'
elif hide_exception:
exception_handling = 'show-hint'
else:
exception_handling = 'show-error'
misc['exception-handling'] = exception_handling
# Add failure-hint with default value
misc['failure-hint'] = 'Request failed.'
# Remove legacy fields
misc.pop('hide-exception', None)
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,73 @@
import sqlalchemy
from .. import migration
@migration.migration_class(22)
class DBMigrateMonitoringUserId(migration.DBMigration):
"""Add user_id and user_name columns to monitoring_sessions table
This migration adds the missing user_id column and also ensures user_name
column exists (in case migration 21 failed or was skipped).
"""
async def _table_exists(self, table_name: str) -> bool:
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
)
return bool(result.scalar())
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
)
return result.first() is not None
async def _get_table_columns(self, table_name: str) -> list[str]:
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
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 = :table_name;'
).bindparams(table_name=table_name)
)
return [row[0] for row in result.fetchall()]
else:
if not table_name.isidentifier():
raise ValueError(f'Invalid table name: {table_name}')
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
return [row[1] for row in result.fetchall()]
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
"""Add a column to a table if it does not already exist."""
columns = await self._get_table_columns(table_name)
if column_name in columns:
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
return
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
)
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
async def upgrade(self):
# Check if monitoring_sessions table exists
if not await self._table_exists('monitoring_sessions'):
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
return
# Add user_id column to monitoring_sessions table
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
# Add user_name column to monitoring_messages table (in case migration 21 failed)
if await self._table_exists('monitoring_messages'):
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
async def downgrade(self):
pass

View File

@@ -0,0 +1,102 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(23)
class DBMigrateModelFallbackConfig(migration.DBMigration):
"""Convert model field from plain UUID string to object with primary/fallbacks"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
changed = False
# Convert model from string to object
model_value = local_agent.get('model', '')
if isinstance(model_value, str):
local_agent['model'] = {
'primary': model_value,
'fallbacks': [],
}
changed = True
# Remove leftover fallback-models field if present
if 'fallback-models' in local_agent:
del local_agent['fallback-models']
changed = True
if not changed:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self):
"""Downgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
current_version = self.ap.ver_mgr.get_current_version()
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
if 'ai' not in config or 'local-agent' not in config['ai']:
continue
local_agent = config['ai']['local-agent']
# Convert model from object back to string
model_value = local_agent.get('model', '')
if isinstance(model_value, dict):
local_agent['model'] = model_value.get('primary', '')
else:
continue
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)

View File

@@ -0,0 +1,49 @@
from .. import migration
import sqlalchemy
import json
@migration.migration_class(24)
class DBMigrateWecomBotWebSocketMode(migration.DBMigration):
"""Add enable-webhook field to existing wecombot adapter configs.
Existing wecombot bots were all using webhook mode, so we set
enable-webhook=true to preserve their behavior after the new
WebSocket long connection mode is introduced as default.
"""
async def upgrade(self):
"""Upgrade"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'")
)
bots = result.fetchall()
for bot_row in bots:
bot_uuid = bot_row[0]
adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]
if 'enable-webhook' in adapter_config:
continue
# Determine mode based on existing config: if webhook fields are present, keep webhook mode
has_webhook_config = bool(
adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')
)
adapter_config['enable-webhook'] = has_webhook_config
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# metadata type -> coercion function
_COERCE_MAP = {
'integer': lambda v: int(v),
'number': lambda v: float(v),
'float': lambda v: float(v),
}
def _coerce_bool(v):
if isinstance(v, bool):
return v
if isinstance(v, str):
if v.lower() == 'true':
return True
if v.lower() == 'false':
return False
raise ValueError(f'Cannot convert string {v!r} to bool')
return bool(v)
def _coerce_value(value, expected_type: str):
"""Convert a single value to the expected type.
Returns the converted value, or the original value if no conversion needed.
"""
if value is None:
return value
if expected_type == 'boolean':
if isinstance(value, bool):
return value
return _coerce_bool(value)
coerce_fn = _COERCE_MAP.get(expected_type)
if coerce_fn is None:
return value
# Already the correct type
if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):
return value
if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
return coerce_fn(value)
def coerce_pipeline_config(
config: dict,
*metadata_list: dict,
) -> None:
"""Coerce pipeline config values according to metadata type definitions.
Walks each metadata dict (trigger, safety, ai, output) and converts
config values in-place so that strings coming from the JSON column are
cast to their declared types (integer, number/float, boolean).
Args:
config: The pipeline config dict to modify in-place.
*metadata_list: Metadata dicts loaded from the YAML templates.
"""
for meta in metadata_list:
section_name = meta.get('name')
if not section_name or section_name not in config:
continue
section = config[section_name]
if not isinstance(section, dict):
continue
for stage_def in meta.get('stages', []):
stage_name = stage_def.get('name')
if not stage_name or stage_name not in section:
continue
stage_config = section[stage_name]
if not isinstance(stage_config, dict):
continue
for field_def in stage_def.get('config', []):
field_name = field_def.get('name')
field_type = field_def.get('type')
if not field_name or not field_type or field_name not in stage_config:
continue
old_value = stage_config[field_name]
try:
new_value = _coerce_value(old_value, field_type)
if new_value is not old_value:
stage_config[field_name] = new_value
except (ValueError, TypeError) as e:
logger.warning(
'Failed to coerce config %s.%s.%s (%r) to %s: %s',
section_name,
stage_name,
field_name,
old_value,
field_type,
e,
)

View File

@@ -34,6 +34,15 @@ class MonitoringHelper:
# Check if session exists, if not, record session start # Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Try to record message # Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.) # Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'): if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
@@ -57,6 +66,7 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value') if hasattr(query.launcher_type, 'value')
else str(query.launcher_type), else str(query.launcher_type),
user_id=query.sender_id, user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name, runner_name=runner_name,
variables=None, # Will be updated in record_query_success variables=None, # Will be updated in record_query_success
) )
@@ -80,6 +90,7 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value') if hasattr(query.launcher_type, 'value')
else str(query.launcher_type), else str(query.launcher_type),
user_id=query.sender_id, user_id=query.sender_id,
user_name=sender_name,
) )
return message_id return message_id
@@ -128,6 +139,15 @@ class MonitoringHelper:
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Extract response content from resp_message_chain # Extract response content from resp_message_chain
if hasattr(query, 'resp_message_chain') and query.resp_message_chain: if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
# Serialize the last response message chain # Serialize the last response message chain
@@ -162,6 +182,7 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value') if hasattr(query.launcher_type, 'value')
else str(query.launcher_type), else str(query.launcher_type),
user_id=query.sender_id, user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name, runner_name=runner_name,
role='assistant', role='assistant',
) )
@@ -183,6 +204,15 @@ class MonitoringHelper:
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
if hasattr(query, 'message_event'):
if hasattr(query.message_event, 'sender'):
if hasattr(query.message_event.sender, 'nickname'):
sender_name = query.message_event.sender.nickname
elif hasattr(query.message_event.sender, 'member_name'):
sender_name = query.message_event.sender.member_name
# Record error message # Record error message
message_id = await ap.monitoring_service.record_message( message_id = await ap.monitoring_service.record_message(
bot_id=bot_id, bot_id=bot_id,
@@ -197,6 +227,7 @@ class MonitoringHelper:
if hasattr(query.launcher_type, 'value') if hasattr(query.launcher_type, 'value')
else str(query.launcher_type), else str(query.launcher_type),
user_id=query.sender_id, user_id=query.sender_id,
user_name=sender_name,
runner_name=runner_name, runner_name=runner_name,
) )

View File

@@ -13,6 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
from ..utils import importutil from ..utils import importutil
from .config_coercion import coerce_pipeline_config
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
@@ -420,6 +421,14 @@ class PipelineManager:
elif isinstance(pipeline_entity, dict): elif isinstance(pipeline_entity, dict):
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity) pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
)
# initialize stage containers according to pipeline_entity.stages # initialize stage containers according to pipeline_entity.stages
stage_containers: list[StageInstContainer] = [] stage_containers: list[StageInstContainer] = []
for stage_name in pipeline_entity.stages: for stage_name in pipeline_entity.stages:

View File

@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
# When not local-agent, llm_model is None # When not local-agent, llm_model is None
try: llm_model = None
llm_model = ( if selected_runner == 'local-agent':
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) # Read model config — new format is { primary: str, fallbacks: [str] },
if selected_runner == 'local-agent' # but handle legacy plain string for backward compatibility
else None model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
) if isinstance(model_config, str):
except ValueError: # Legacy format: plain UUID string
self.ap.logger.warning( primary_uuid = model_config
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured' fallback_uuids = []
) else:
llm_model = None primary_uuid = model_config.get('primary', '')
fallback_uuids = model_config.get('fallbacks', [])
if primary_uuid:
try:
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
# Resolve fallback model UUIDs
if fallback_uuids:
valid_fallbacks = []
for fb_uuid in fallback_uuids:
try:
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
valid_fallbacks.append(fb_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
if valid_fallbacks:
query.variables['_fallback_model_uuids'] = valid_fallbacks
conversation = await self.ap.sess_mgr.get_conversation( conversation = await self.ap.sess_mgr.get_conversation(
query, query,
@@ -61,20 +80,28 @@ class PreProcessor(stage.PipelineStage):
query.prompt = conversation.prompt.copy() query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy() query.messages = conversation.messages.copy()
if selected_runner == 'local-agent' and llm_model: if selected_runner == 'local-agent':
query.use_funcs = [] query.use_funcs = []
query.use_llm_model_uuid = llm_model.model_entity.uuid if llm_model:
query.use_llm_model_uuid = llm_model.model_entity.uuid
if llm_model.model_entity.abilities.__contains__('func_call'): if llm_model.model_entity.abilities.__contains__('func_call'):
# Get bound plugins and MCP servers for filtering tools # Get bound plugins and MCP servers for filtering tools
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
# If primary model doesn't support func_call but fallback models exist,
# load tools anyway since fallback models may support them
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
sender_name = '' sender_name = ''
if isinstance(query.message_event, platform_events.GroupMessage): if isinstance(query.message_event, platform_events.GroupMessage):
@@ -149,6 +176,16 @@ class PreProcessor(stage.PipelineStage):
query.variables['user_message_text'] = plain_text query.variables['user_message_text'] = plain_text
query.user_message = provider_message.Message(role='user', content=content_list) query.user_message = provider_message.Message(role='user', content=content_list)
# Extract knowledge base UUIDs into query variables so plugins can modify them
# during PromptPreProcessing before the runner performs retrieval.
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
if not kb_uuids:
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
# =========== 触发事件 PromptPreProcessing # =========== 触发事件 PromptPreProcessing
event = events.PromptPreProcessing( event = events.PromptPreProcessing(

View File

@@ -149,12 +149,19 @@ class ChatMessageHandler(handler.MessageHandler):
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') 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'] exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
if exception_handling == 'show-error':
user_notice = f'{e}'
elif exception_handling == 'show-hint':
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
else: # hide
user_notice = None
yield entities.StageProcessResult( yield entities.StageProcessResult(
result_type=entities.ResultType.INTERRUPT, result_type=entities.ResultType.INTERRUPT,
new_query=query, new_query=query,
user_notice='请求失败' if hide_exception_info else f'{e}', user_notice=user_notice,
error_notice=f'{e}', error_notice=f'{e}',
debug_notice=traceback.format_exc(), debug_notice=traceback.format_exc(),
) )

View File

@@ -282,6 +282,8 @@ class PlatformManager:
return runtime_bot return runtime_bot
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None: async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
return self.websocket_proxy_bot
for bot in self.bots: for bot in self.bots:
if bot.bot_entity.uuid == bot_uuid: if bot.bot_entity.uuid == bot_uuid:
return bot return bot

View File

@@ -575,6 +575,127 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
_processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {}
_processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096
_processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400
@classmethod
def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None:
if now is None:
now = time.time()
expire_before = now - cls._processed_thread_quote_cache_ttl_seconds
while cls._processed_thread_quote_cache:
oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))
if oldest_ts >= expire_before:
break
cls._processed_thread_quote_cache.pop(oldest_key, None)
while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:
oldest_key = next(iter(cls._processed_thread_quote_cache))
cls._processed_thread_quote_cache.pop(oldest_key, None)
@classmethod
def _mark_thread_quote_processed(cls, thread_id: str) -> None:
now = time.time()
cls._prune_processed_thread_quote_cache(now)
cls._processed_thread_quote_cache[thread_id] = now
@classmethod
def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]:
"""
Extract the message ID to quote from the given message.
Rules:
- First thread reply in a topic: return parent_id and mark topic as processed
- Follow-up thread replies in the same topic: return None
- Non-thread message: return parent_id if valid (non-empty, different from message_id)
Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth.
"""
parent_id = getattr(message, 'parent_id', None)
if not parent_id:
return None
message_id = getattr(message, 'message_id', None)
if parent_id == message_id:
return None
thread_id = getattr(message, 'thread_id', None)
if thread_id:
cls._prune_processed_thread_quote_cache()
if thread_id in cls._processed_thread_quote_cache:
return None
cls._mark_thread_quote_processed(thread_id)
return parent_id
@staticmethod
def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]:
"""
Build EventMessage from SDK typed Message item.
Returns None if body or content is missing.
"""
body = getattr(message_item, 'body', None)
if not body:
return None
content = getattr(body, 'content', None)
if not content:
return None
event_data = {
'message_id': message_item.message_id,
'message_type': message_item.msg_type,
'content': content,
'create_time': message_item.create_time,
'mentions': getattr(message_item, 'mentions', []) or [],
}
# Preserve thread-related fields
if hasattr(message_item, 'parent_id') and message_item.parent_id:
event_data['parent_id'] = message_item.parent_id
if hasattr(message_item, 'root_id') and message_item.root_id:
event_data['root_id'] = message_item.root_id
if hasattr(message_item, 'thread_id') and message_item.thread_id:
event_data['thread_id'] = message_item.thread_id
if hasattr(message_item, 'chat_id') and message_item.chat_id:
event_data['chat_id'] = message_item.chat_id
return EventMessage(event_data)
@staticmethod
async def _fetch_quoted_message(
quote_message_id: str,
api_client: lark_oapi.Client,
) -> typing.Optional[platform_message.MessageChain]:
"""
Fetch the quoted message and convert to MessageChain.
Returns None if:
- API call fails
- Response items is empty
- Message item normalization fails
"""
request = GetMessageRequest.builder().message_id(quote_message_id).build()
response = await api_client.im.v1.message.aget(request)
if not response.success():
return None
items = getattr(response.data, 'items', None)
if not items:
return None
message_item = items[0]
event_message = LarkEventConverter._build_event_message_from_message_item(message_item)
if event_message is None:
return None
quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client)
return quote_chain
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
event: platform_events.MessageEvent, event: platform_events.MessageEvent,
@@ -587,6 +708,23 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
) -> platform_events.Event: ) -> platform_events.Event:
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client) message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
# Check for quote/reply message
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
if quote_message_id:
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
if quote_chain:
# Filter out Source component from quoted chain, keep only content
quote_origin = platform_message.MessageChain(
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
)
if quote_origin:
message_chain.append(
platform_message.Quote(
message_id=quote_message_id,
origin=quote_origin,
)
)
if event.event.message.chat_type == 'p2p': if event.event.message.chat_type == 'p2p':
return platform_events.FriendMessage( return platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
@@ -770,6 +908,32 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.request_tenant_access_token(tenant_key) 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 return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
"""
Get topic-scoped launcher_id for thread-aware session isolation.
For group thread messages, returns "{group_id}_{thread_id}"
to ensure conversation context stays stable per topic.
Returns None for non-thread messages or P2P messages.
"""
source_event = getattr(event.source_platform_object, 'event', None)
if not source_event:
return None
message = getattr(source_event, 'message', None)
if not message:
return None
thread_id = getattr(message, 'thread_id', None)
if not thread_id:
return None
if isinstance(event, platform_events.GroupMessage):
return f'{event.group.id}_{thread_id}'
return None
def build_api_client(self, config): def build_api_client(self, config):
app_id = config['app_id'] app_id = config['app_id']
app_secret = config['app_secret'] app_secret = config['app_secret']

View File

@@ -0,0 +1,577 @@
"""OpenClaw WeChat adapter for LangBot.
Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage)
to integrate personal WeChat accounts with LangBot.
Reference: https://github.com/epiral/weixin-bot
"""
from __future__ import annotations
import asyncio
import base64
import traceback
import typing
import pydantic
import sqlalchemy
from langbot.libs.openclaw_weixin_api.client import (
DEFAULT_BASE_URL,
SESSION_EXPIRED_ERRCODE,
OpenClawWeixinClient,
)
from langbot.libs.openclaw_weixin_api.types import (
MessageItem,
WeixinMessage,
)
from langbot.pkg.entity.persistence import bot as persistence_bot
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
"""Convert LangBot MessageChain to a list of OpenClaw message item dicts."""
items = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}})
elif isinstance(component, platform_message.Image):
# OpenClaw WeChat only supports text messages without CDN upload.
# For images, we send a placeholder text with the URL if available.
if component.url:
items.append(
{
'type': MessageItem.TEXT,
'text_item': {'text': f'[Image: {component.url}]'},
}
)
elif component.base64:
items.append(
{
'type': MessageItem.TEXT,
'text_item': {'text': '[Image]'},
}
)
elif isinstance(component, platform_message.File):
if component.name:
items.append(
{
'type': MessageItem.TEXT,
'text_item': {'text': f'[File: {component.name}]'},
}
)
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain))
return items
@staticmethod
async def target2yiri(
msg: WeixinMessage,
) -> platform_message.MessageChain:
"""Convert an OpenClaw WeixinMessage to LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
if not msg.item_list:
return platform_message.MessageChain(components)
for item in msg.item_list:
if item.type == MessageItem.TEXT and item.text_item and item.text_item.text:
text = item.text_item.text
# Handle quoted messages
if item.ref_msg:
ref_parts = []
if item.ref_msg.title:
ref_parts.append(item.ref_msg.title)
if item.ref_msg.message_item:
ref_item = item.ref_msg.message_item
if ref_item.text_item and ref_item.text_item.text:
ref_parts.append(ref_item.text_item.text)
if ref_parts:
components.append(
platform_message.Quote(
sender_id='',
origin=platform_message.MessageChain(
[platform_message.Plain(text=' | '.join(ref_parts))]
),
)
)
components.append(platform_message.Plain(text=text))
elif item.type == MessageItem.IMAGE and item.image_item:
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
else:
components.append(platform_message.Unknown(text='[Image]'))
elif item.type == MessageItem.VOICE and item.voice_item:
# Voice with speech-to-text: use the transcribed text
if item.voice_item.text:
components.append(platform_message.Plain(text=item.voice_item.text))
else:
components.append(platform_message.Unknown(text='[Voice]'))
# TODO: enable after full testing
# elif item.type == MessageItem.VOICE and item.voice_item:
# if item.voice_item.text:
# components.append(platform_message.Plain(text=item.voice_item.text))
# elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes:
# b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8')
# components.append(
# platform_message.Voice(
# base64=b64,
# length=item.voice_item.playtime or 0,
# )
# )
# else:
# components.append(
# platform_message.Voice(
# length=item.voice_item.playtime or 0,
# )
# )
elif item.type == MessageItem.FILE and item.file_item:
components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]'))
# TODO: enable after full testing
# elif item.type == MessageItem.FILE and item.file_item:
# file_name = item.file_item.file_name or ''
# file_size = int(item.file_item.len) if item.file_item.len else 0
# if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes:
# b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8')
# components.append(
# platform_message.File(
# name=file_name,
# size=file_size,
# base64=b64,
# )
# )
# else:
# components.append(
# platform_message.File(
# name=file_name,
# size=file_size,
# )
# )
elif item.type == MessageItem.VIDEO and item.video_item:
components.append(platform_message.Unknown(text='[Video]'))
# TODO: enable after full testing
# elif item.type == MessageItem.VIDEO and item.video_item:
# if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes:
# b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8')
# components.append(
# platform_message.File(
# name='video.mp4',
# size=item.video_item.video_size or 0,
# base64=b64,
# )
# )
# else:
# components.append(
# platform_message.File(
# name='video.mp4',
# size=item.video_item.video_size or 0,
# )
# )
else:
components.append(platform_message.Unknown(text='[Unknown message type]'))
return platform_message.MessageChain(components)
class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Converts OpenClaw WeChat messages to LangBot events."""
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
@staticmethod
async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]:
"""Convert an inbound WeixinMessage to a LangBot event."""
if msg.message_type != WeixinMessage.TYPE_USER:
return None
from_user_id = msg.from_user_id or ''
if not from_user_id:
return None
message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg)
if not message_chain:
return None
timestamp = (msg.create_time_ms or 0) / 1000.0
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=from_user_id,
nickname=from_user_id,
remark='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""LangBot adapter for OpenClaw WeChat (long-poll based)."""
name: str = 'openclaw-weixin'
client: OpenClawWeixinClient = pydantic.Field(exclude=True)
config: dict
message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter()
event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter()
# context_token cache: from_user_id -> context_token
_context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict)
_polling: bool = pydantic.PrivateAttr(default=False)
_poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None)
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
client = OpenClawWeixinClient(
base_url=config.get('base_url', DEFAULT_BASE_URL),
token=config.get('token', ''),
)
super().__init__(
config=config,
logger=logger,
client=client,
bot_account_id='',
listeners={},
name='openclaw-weixin',
)
def set_bot_uuid(self, bot_uuid: str):
"""Called by BotManager to provide the bot's UUID for config persistence."""
self._bot_uuid = bot_uuid
async def _persist_config(self) -> None:
"""Persist current self.config to the database so token survives restart."""
if not self._bot_uuid:
return
try:
ap = self.logger.ap
await ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot)
.where(persistence_bot.Bot.uuid == self._bot_uuid)
.values(adapter_config=self.config)
)
except Exception as e:
await self.logger.warning(f'Failed to persist adapter config: {e}')
async def _do_login(self) -> None:
"""Run the QR code login flow via client.login() and update config."""
adapter_logger = self.logger
async def _on_qrcode(qr_base64: str, _qr_url: str):
await adapter_logger.info(
f'Please scan the QR code to login WeChat: {_qr_url}',
images=[platform_message.Image(base64=qr_base64)],
)
login_result = await self.client.login(
on_qrcode=_on_qrcode,
)
# client.login() already updates client.token and client.base_url
self.config['token'] = login_result.token
self.config['base_url'] = login_result.base_url
if login_result.account_id:
self.config['account_id'] = login_result.account_id
await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}')
# Persist token to database so it survives restart
await self._persist_config()
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
):
"""Send a message to a user."""
context_token = self._context_tokens.get(target_id, '')
for component in message:
try:
if isinstance(component, platform_message.Plain):
if component.text:
await self.client.send_text(target_id, component.text, context_token)
elif isinstance(component, platform_message.Image):
img_bytes, _ = await component.get_bytes()
await self.client.send_image(target_id, img_bytes, context_token)
elif isinstance(component, platform_message.File):
file_bytes = await self._get_component_bytes(component)
if file_bytes:
await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token)
elif isinstance(component, platform_message.Voice):
voice_bytes = await self._get_component_bytes(component)
if voice_bytes:
await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token)
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
await self.send_message(target_type, target_id, node.message_chain)
except Exception:
await self.logger.error(
f'Failed to send component {type(component).__name__}: {traceback.format_exc()}'
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a received message."""
source_msg = message_source.source_platform_object
if isinstance(source_msg, WeixinMessage):
target_id = source_msg.from_user_id or ''
if target_id:
await self.send_message('friend', target_id, message)
async def is_muted(self, group_id: int) -> bool:
return False
@staticmethod
async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]:
"""Extract raw bytes from a File or Voice component."""
b64_val = getattr(component, 'base64', None)
url_val = getattr(component, 'url', None)
path_val = getattr(component, 'path', None)
if b64_val:
return base64.b64decode(b64_val)
elif url_val and url_val.startswith(('http://', 'https://')):
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url_val) as resp:
if resp.status == 200:
return await resp.read()
elif path_val:
import asyncio
with open(path_val, 'rb') as f:
return await asyncio.to_thread(f.read)
return None
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
self.listeners.pop(event_type, None)
async def run_async(self):
"""Start the adapter. If no token is configured, trigger QR code login first."""
base_url = self.config.get('base_url', DEFAULT_BASE_URL)
token = self.config.get('token', '')
await self.logger.info('OpenClaw WeChat adapter starting...')
# QR code login flow when no token is provided
if not token:
await self.logger.info('No token configured, starting QR code login...')
try:
await self._do_login()
except Exception as e:
await self.logger.error(f'QR code login failed: {e}')
raise
# Rebuild client with the (possibly updated) config
self.client = OpenClawWeixinClient(
base_url=self.config.get('base_url', base_url),
token=self.config.get('token', token),
)
self.bot_account_id = self.config.get('account_id', 'openclaw-weixin')
self._polling = True
# Start the long-poll loop
self._poll_task = asyncio.create_task(self._poll_loop())
await self.logger.info('OpenClaw WeChat adapter running')
try:
await self._poll_task
except asyncio.CancelledError:
pass
async def _poll_loop(self):
"""Long-poll loop: call getUpdates continuously.
Error handling follows the weixin-bot SDK pattern:
- Exponential backoff (1s -> 10s max) on failures
- Session expired (errcode -14) triggers automatic re-login
"""
get_updates_buf = ''
poll_timeout = float(self.config.get('poll_timeout', 35))
backoff_delay = 1.0
max_backoff = 10.0
while self._polling:
try:
resp = await self.client.get_updates(
get_updates_buf=get_updates_buf,
timeout=poll_timeout + 5,
)
if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0:
poll_timeout = resp.longpolling_timeout_ms / 1000.0
is_api_error = (resp.ret is not None and resp.ret != 0) or (
resp.errcode is not None and resp.errcode != 0
)
if is_api_error:
is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE
if is_session_expired:
await self.logger.error('OpenClaw WeChat session expired, attempting re-login...')
try:
await self._do_login()
# Rebuild client with new credentials
self.client = OpenClawWeixinClient(
base_url=self.config.get('base_url', DEFAULT_BASE_URL),
token=self.config.get('token', ''),
)
self._context_tokens.clear()
get_updates_buf = ''
backoff_delay = 1.0
continue
except Exception:
await self.logger.error(f'Re-login failed: {traceback.format_exc()}')
break
await self.logger.error(
f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}'
)
await asyncio.sleep(backoff_delay)
backoff_delay = min(backoff_delay * 2, max_backoff)
continue
backoff_delay = 1.0
if resp.get_updates_buf:
get_updates_buf = resp.get_updates_buf
for msg in resp.msgs:
try:
await self._handle_inbound_message(msg)
except Exception:
await self.logger.error(f'Error handling message: {traceback.format_exc()}')
except asyncio.CancelledError:
break
except Exception:
await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}')
await asyncio.sleep(backoff_delay)
backoff_delay = min(backoff_delay * 2, max_backoff)
async def _handle_inbound_message(self, msg: WeixinMessage):
"""Process a single inbound message from getUpdates."""
if msg.context_token and msg.from_user_id:
self._context_tokens[msg.from_user_id] = msg.context_token
# Download CDN media (files, images) before converting to LangBot events
await self._download_media_items(msg)
event = await OpenClawWeixinEventConverter.target2yiri(msg)
if event is None:
return
if type(event) in self.listeners:
await self.listeners[type(event)](event, self)
async def _download_media_items(self, msg: WeixinMessage):
"""Download CDN media for image items in the message."""
if not msg.item_list:
return
for item in msg.item_list:
try:
if item.type == MessageItem.IMAGE and item.image_item:
if (
item.image_item.media
and item.image_item.media.encrypt_query_param
and item.image_item.media.aes_key
):
img_bytes = await self.client.download_media(item.image_item.media)
item.image_item._downloaded_bytes = img_bytes
# TODO: enable after full testing
# elif item.type == MessageItem.FILE and item.file_item and item.file_item.media:
# if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key:
# file_bytes = await self.client.download_media(item.file_item.media)
# item.file_item._downloaded_bytes = file_bytes
#
# elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media:
# if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key:
# voice_bytes = await self.client.download_media(item.voice_item.media)
# item.voice_item._downloaded_bytes = voice_bytes
#
# elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media:
# if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key:
# video_bytes = await self.client.download_media(item.video_item.media)
# item.video_item._downloaded_bytes = video_bytes
except Exception:
await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}')
async def kill(self) -> bool:
"""Stop the adapter."""
self._polling = False
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
await self.client.close()
await self.logger.info('OpenClaw WeChat adapter stopped')
return True

View File

@@ -0,0 +1,57 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: openclaw-weixin
label:
en_US: OpenClaw WeChat
zh_Hans: OpenClaw 微信
description:
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
icon: wechat.png
spec:
config:
- name: base_url
label:
en_US: API Base URL
zh_Hans: API 基础地址
description:
en_US: The base URL of the OpenClaw WeChat backend API
zh_Hans: OpenClaw 微信后端 API 的基础地址
type: string
required: true
default: "https://ilinkai.weixin.qq.com"
- name: token
label:
en_US: Token
zh_Hans: 令牌
description:
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。
type: string
required: false
default: ""
- name: account_id
label:
en_US: Account ID
zh_Hans: 账号标识
description:
en_US: A label for this WeChat account (used for display purposes)
zh_Hans: 此微信账号的标识(用于显示)
type: string
required: false
default: "openclaw-weixin"
- name: poll_timeout
label:
en_US: Poll Timeout (seconds)
zh_Hans: 轮询超时(秒)
description:
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
type: integer
required: false
default: 35
execution:
python:
path: ./openclaw_weixin.py
attr: OpenClawWeixinAdapter

View File

@@ -42,6 +42,25 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
photo_bytes = f.read() photo_bytes = f.read()
components.append({'type': 'photo', 'photo': photo_bytes}) components.append({'type': 'photo', 'photo': photo_bytes})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
# Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
file_name = getattr(component, 'name', None) or 'file'
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
elif isinstance(component, platform_message.Forward): elif isinstance(component, platform_message.Forward):
for node in component.node_list: for node in component.node_list:
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot)) components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
@@ -104,6 +123,27 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
) )
) )
if message.document:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.document.get_file()
file_name = message.document.file_name or 'document'
file_size = message.document.file_size or 0
file_format = message.document.mime_type or 'application/octet-stream'
file_bytes = None
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.File(
name=file_name,
size=file_size,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
)
)
return platform_message.MessageChain(message_components) return platform_message.MessageChain(message_components)
@@ -179,7 +219,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
application = ApplicationBuilder().token(config['token']).build() application = ApplicationBuilder().token(config['token']).build()
bot = application.bot bot = application.bot
application.add_handler( application.add_handler(
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback) MessageHandler(
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
telegram_callback,
)
) )
super().__init__( super().__init__(
config=config, config=config,
@@ -218,6 +261,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
continue continue
args['photo'] = telegram.InputFile(photo) args['photo'] = telegram.InputFile(photo)
await self.bot.send_photo(**args) await self.bot.send_photo(**args)
elif component_type == 'document':
doc = component.get('document')
if doc is None:
continue
filename = component.get('filename', 'file')
args['document'] = telegram.InputFile(doc, filename=filename)
await self.bot.send_document(**args)
async def reply_message( async def reply_message(
self, self,

View File

@@ -37,16 +37,24 @@ class WebSocketSession:
id: str id: str
message_lists: dict[str, list[WebSocketMessage]] = {} message_lists: dict[str, list[WebSocketMessage]] = {}
"""消息列表 {pipeline_uuid: [messages]}""" """消息列表 {pipeline_uuid: [messages]}"""
stream_message_indexes: dict[str, dict[str, int]] = {}
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
def __init__(self, id: str): def __init__(self, id: str):
self.id = id self.id = id
self.message_lists = {} self.message_lists = {}
self.stream_message_indexes = {}
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]: def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
if pipeline_uuid not in self.message_lists: if pipeline_uuid not in self.message_lists:
self.message_lists[pipeline_uuid] = [] self.message_lists[pipeline_uuid] = []
return self.message_lists[pipeline_uuid] return self.message_lists[pipeline_uuid]
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
if pipeline_uuid not in self.stream_message_indexes:
self.stream_message_indexes[pipeline_uuid] = {}
return self.stream_message_indexes[pipeline_uuid]
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""WebSocket适配器 - 支持双向实时通信""" """WebSocket适配器 - 支持双向实时通信"""
@@ -89,20 +97,46 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
target_id: str, target_id: str,
message: platform_message.MessageChain, message: platform_message.MessageChain,
) -> dict: ) -> dict:
"""发送消息 - 这里用于主动推送消息到前端""" """发送消息 - 这里用于主动推送消息到前端
message_data = {
'type': 'bot_message',
'target_type': target_type,
'target_id': target_id,
'content': str(message),
'message_chain': [component.__dict__ for component in message],
'timestamp': datetime.now().isoformat(),
}
# 推送到所有相关连接 对于 WebSocket 适配器,我们需要将消息广播到正确的 pipeline 连接。
await self.outbound_message_queue.put(message_data) target_id 可能是 launcher_id如 websocket_xxx或 pipeline_uuid。
我们需要尝试两种方式来确保消息能够送达。
"""
# 获取当前的 pipeline_uuid
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if target_type == 'group' else 'person'
return message_data # 选择会话
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
# 生成唯一消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
message_data = WebSocketMessage(
id=msg_id,
role='assistant',
content=str(message),
message_chain=[component.__dict__ for component in message],
timestamp=datetime.now().isoformat(),
is_final=True,
)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
# 直接广播到当前pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'session_type': session_type,
'data': message_data.model_dump(),
},
session_type=session_type,
)
return message_data.model_dump()
async def reply_message( async def reply_message(
self, self,
@@ -169,10 +203,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
message_list = session.get_message_list(pipeline_uuid) message_list = session.get_message_list(pipeline_uuid)
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
# 检查是否是新的流式消息通过bot_message对象判断 # Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
# 如果列表为空或者最后一条消息已经is_final=True则创建新消息 # Use it as the primary key to avoid overwriting an old card from a previous reply.
if not message_list or message_list[-1].is_final: resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None
message_is_final = is_final and bot_message.tool_calls is None
if existing_index is None or existing_index >= len(message_list):
# 创建新消息 # 创建新消息
msg_id = len(message_list) + 1 msg_id = len(message_list) + 1
message_data = WebSocketMessage( message_data = WebSocketMessage(
@@ -181,27 +221,31 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
content=str(message), content=str(message),
message_chain=[component.__dict__ for component in message], message_chain=[component.__dict__ for component in message],
timestamp=datetime.now().isoformat(), timestamp=datetime.now().isoformat(),
is_final=is_final and bot_message.tool_calls is None, is_final=message_is_final,
) )
# 只有在is_final时才保存到历史记录 # 立即添加到历史记录即使is_final=False以便后续块可以更新它
if is_final and bot_message.tool_calls is None: message_list.append(message_data)
message_list.append(message_data) if resp_message_id:
stream_message_indexes[resp_message_id] = len(message_list) - 1
else: else:
# 更新最后一条消息 # 更新同一条流式消息
msg_id = message_list[-1].id old_message = message_list[existing_index]
msg_id = old_message.id
message_data = WebSocketMessage( message_data = WebSocketMessage(
id=msg_id, id=msg_id,
role='assistant', role='assistant',
content=str(message), content=str(message),
message_chain=[component.__dict__ for component in message], message_chain=[component.__dict__ for component in message],
timestamp=message_list[-1].timestamp, # 保持原始时间戳 timestamp=old_message.timestamp, # 保持原始时间戳
is_final=is_final and bot_message.tool_calls is None, is_final=message_is_final,
) )
# 如果是final更新历史记录中的最后一条 # 更新历史记录中的对应消息
if is_final and bot_message.tool_calls is None: message_list[existing_index] = message_data
message_list[-1] = message_data
if message_is_final and resp_message_id:
stream_message_indexes.pop(resp_message_id, None)
# 直接广播到所有该pipeline的连接包含session_type信息 # 直接广播到所有该pipeline的连接包含session_type信息
await ws_connection_manager.broadcast_to_pipeline( await ws_connection_manager.broadcast_to_pipeline(
@@ -410,6 +454,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if session_type == 'person': if session_type == 'person':
if pipeline_uuid in self.websocket_person_session.message_lists: if pipeline_uuid in self.websocket_person_session.message_lists:
self.websocket_person_session.message_lists[pipeline_uuid] = [] self.websocket_person_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
else: else:
if pipeline_uuid in self.websocket_group_session.message_lists: if pipeline_uuid in self.websocket_group_session.message_lists:
self.websocket_group_session.message_lists[pipeline_uuid] = [] self.websocket_group_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

@@ -148,51 +148,54 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
pass pass
if type(event) is platform_events.FriendMessage: if type(event) is platform_events.FriendMessage:
payload = { return event.source_platform_object
'MsgType': 'text',
'Content': '',
'FromUserName': event.sender.id,
'ToUserName': bot_account_id,
'CreateTime': int(datetime.datetime.now().timestamp()),
'AgentID': event.sender.nickname,
}
wecom_event = WecomEvent.from_payload(payload=payload)
if not wecom_event:
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
return wecom_event
@staticmethod @staticmethod
async def target2yiri(event: WecomEvent): async def target2yiri(event: WecomEvent, bot: WecomClient = None):
""" """
将 WecomEvent 转换为平台的 FriendMessage 对象。 将 WecomEvent 转换为平台的 FriendMessage 对象。
Args: Args:
event (WecomEvent): 企业微信事件。 event (WecomEvent): 企业微信事件。
bot (WecomClient): 企业微信客户端,用于获取用户信息。
Returns: Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。 platform_events.FriendMessage: 转换后的 FriendMessage 对象。
""" """
# Try to get the user's real name from the WeCom API
nickname = str(event.user_id)
if bot and event.user_id:
try:
user_info = await bot.get_user_info(event.user_id)
if user_info and user_info.get('name'):
nickname = user_info.get('name')
except Exception:
pass # Fall back to user_id as nickname
# 转换消息链 # 转换消息链
if event.type == 'text': if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id) yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'u{event.user_id}',
nickname=str(event.agent_id), nickname=nickname,
remark='', remark='',
) )
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp) return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
elif event.type == 'image': elif event.type == 'image':
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'u{event.user_id}',
nickname=str(event.agent_id), nickname=nickname,
remark='', remark='',
) )
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id) yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp) return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
@@ -210,7 +213,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'secret', 'secret',
'token', 'token',
'EncodingAESKey', 'EncodingAESKey',
'contacts_secret',
] ]
missing_keys = [key for key in required_keys if key not in config] missing_keys = [key for key in required_keys if key not in config]
@@ -223,7 +225,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
secret=config['secret'], secret=config['secret'],
token=config['token'], token=config['token'],
EncodingAESKey=config['EncodingAESKey'], EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'], contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
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'), api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
@@ -248,18 +250,17 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
): ):
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot) Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
content_list = await WecomMessageConverter.yiri2target(message, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot)
fixed_user_id = Wecom_event.user_id # user_id is the original FromUserName from WecomEvent
# 删掉开头的u user_id = Wecom_event.user_id
fixed_user_id = fixed_user_id[1:]
for content in content_list: for content in content_list:
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content']) await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image': elif content['type'] == 'image':
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id']) await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice': elif content['type'] == 'voice':
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id']) await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file': elif content['type'] == 'file':
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id']) await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
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):
content_list = await WecomMessageConverter.yiri2target(message, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomEvent): async def on_message(event: WecomEvent):
self.bot_account_id = event.receiver_id self.bot_account_id = event.receiver_id
try: try:
return await callback(await self.event_converter.target2yiri(event), self) return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except Exception: except Exception:
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}') await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')

View File

@@ -39,13 +39,6 @@ spec:
type: string type: string
required: true required: true
default: "" default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
type: string
required: true
default: ""
- name: api_base_url - name: api_base_url
label: label:
en_US: API Base URL en_US: API Base URL

View File

@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
from ..logger import EventLogger from ..logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -23,14 +24,18 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
return content return content
@staticmethod @staticmethod
async def target2yiri(event: WecomBotEvent): async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
yiri_msg_list = [] yiri_msg_list = []
if event.type == 'group': if event.type == 'group':
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id)) yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now())) yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
if event.content: if event.content:
yiri_msg_list.append(platform_message.Plain(text=event.content)) content = event.content
if bot_name:
content = content.replace(f'@{bot_name}', '').strip()
yiri_msg_list.append(platform_message.Plain(text=content))
images = [] images = []
if event.images: if event.images:
@@ -133,13 +138,15 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_name: str = ''):
self.bot_name = bot_name
@staticmethod @staticmethod
async def yiri2target(event: platform_events.MessageEvent): async def yiri2target(event: platform_events.MessageEvent):
return event.source_platform_object return event.source_platform_object
@staticmethod async def target2yiri(self, event: WecomBotEvent):
async def target2yiri(event: WecomBotEvent): message_chain = await WecomBotMessageConverter.target2yiri(event, bot_name=self.bot_name)
message_chain = await WecomBotMessageConverter.target2yiri(event)
if event.type == 'single': if event.type == 'single':
return platform_events.FriendMessage( return platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
@@ -176,34 +183,53 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: WecomBotClient bot: typing.Union[WecomBotClient, WecomBotWsClient]
bot_account_id: str bot_account_id: str
message_converter: WecomBotMessageConverter = WecomBotMessageConverter() message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
event_converter: WecomBotEventConverter = WecomBotEventConverter() event_converter: WecomBotEventConverter
config: dict config: dict
bot_uuid: str = None bot_uuid: str = None
_ws_mode: bool = False
bot_name: str = ''
listeners: dict = {}
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId'] enable_webhook = config.get('enable-webhook', False)
missing_keys = [key for key in required_keys if key not in config] bot_name = config.get('robot_name', '')
if missing_keys:
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
bot = WecomBotClient( if not enable_webhook:
Token=config['Token'], bot = WecomBotWsClient(
EnCodingAESKey=config['EncodingAESKey'], bot_id=config['BotId'],
Corpid=config['Corpid'], secret=config['Secret'],
logger=logger, logger=logger,
unified_mode=True, encoding_aes_key=config.get('EncodingAESKey', ''),
) )
bot_account_id = config['BotId'] else:
# Webhook callback mode
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
missing_keys = [key for key in required_keys if key not in config or not config[key]]
if missing_keys:
raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')
bot = WecomBotClient(
Token=config['Token'],
EnCodingAESKey=config['EncodingAESKey'],
Corpid=config['Corpid'],
logger=logger,
unified_mode=True,
)
bot_account_id = config.get('BotId', '')
event_converter = WecomBotEventConverter(bot_name=bot_name)
super().__init__( super().__init__(
config=config, config=config,
logger=logger, logger=logger,
bot=bot, bot=bot,
bot_account_id=bot_account_id, bot_account_id=bot_account_id,
bot_name=bot_name,
event_converter=event_converter,
) )
self.listeners = {}
async def reply_message( async def reply_message(
self, self,
@@ -212,7 +238,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
): ):
content = await self.message_converter.yiri2target(message) content = await self.message_converter.yiri2target(message)
await self.bot.set_message(message_source.source_platform_object.message_id, content) _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
if req_id:
await self.bot.reply_text(req_id, content)
else:
await self.bot.set_message(event.message_id, content)
else:
await self.bot.set_message(message_source.source_platform_object.message_id, content)
async def reply_message_chunk( async def reply_message_chunk(
self, self,
@@ -222,31 +258,23 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
is_final: bool = False, is_final: bool = False,
): ):
"""将流水线增量输出写入企业微信 stream 会话。
Args:
message_source: 流水线提供的原始消息事件。
bot_message: 当前片段对应的模型元信息(未使用)。
message: 需要回复的消息链。
quote_origin: 是否引用原消息(企业微信暂不支持)。
is_final: 标记当前片段是否为最终回复。
Returns:
dict: 包含 `stream` 键,标识写入是否成功。
Example:
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
"""
# 转换为纯文本(智能机器人当前协议仅支持文本流)
content = await self.message_converter.yiri2target(message) content = await self.message_converter.yiri2target(message)
msg_id = message_source.source_platform_object.message_id msg_id = message_source.source_platform_object.message_id
_ws_mode = not self.config.get('enable-webhook', False)
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑 if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final: if not success and is_final:
# 未命中流式队列时使用旧有 set_message 兜底 event = message_source.source_platform_object
await self.bot.set_message(msg_id, content) req_id = event.get('req_id', '')
return {'stream': success} if req_id:
await self.bot.reply_text(req_id, content)
return {'stream': success}
else:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
await self.bot.set_message(msg_id, content)
return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
"""智能机器人侧默认开启流式能力。 """智能机器人侧默认开启流式能力。
@@ -259,7 +287,21 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return True return True
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
pass _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
content = await self.message_converter.yiri2target(message)
await self.bot.send_message(target_id, content)
else:
pass
async def on_message(self, event: WecomBotEvent):
try:
lb_event = await self.event_converter.target2yiri(event)
if lb_event:
await self.listeners[type(lb_event)](lb_event, self)
except Exception:
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
print(traceback.format_exc())
def register_listener( def register_listener(
self, self,
@@ -268,18 +310,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
], ],
): ):
async def on_message(event: WecomBotEvent): self.listeners[event_type] = callback
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
print(traceback.format_exc())
try: try:
if event_type == platform_events.FriendMessage: if event_type == platform_events.FriendMessage:
self.bot.on_message('single')(on_message) self.bot.on_message('single')(self.on_message)
elif event_type == platform_events.GroupMessage: elif event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message) self.bot.on_message('group')(self.on_message)
except Exception: except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
@@ -288,29 +325,28 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.bot_uuid = bot_uuid self.bot_uuid = bot_uuid
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 请求。 _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
Args: return None
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request) return await self.bot.handle_unified_webhook(request)
async def run_async(self): async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用 _ws_mode = not self.config.get('enable-webhook', False)
# 保持运行但不启动独立端口 if _ws_mode:
await self.bot.connect()
else:
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await keep_alive() await keep_alive()
async def kill(self) -> bool: async def kill(self) -> bool:
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
await self.bot.disconnect()
return True
return False return False
async def unregister_listener( async def unregister_listener(

View File

@@ -11,35 +11,71 @@ metadata:
icon: wecombot.png icon: wecombot.png
spec: spec:
config: config:
- name: BotId
label:
en_US: BotId
zh_Hans: 机器人ID (BotId)
type: string
required: true
default: ""
- name: robot_name
label:
en_US: Robot Name
zh_Hans: 机器人名称
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
type: boolean
required: true
default: false
- name: Secret
label:
en_US: Secret
zh_Hans: 机器人密钥 (Secret)
description:
en_US: Required for WebSocket long connection mode
zh_Hans: 使用 WS 长连接模式时必填
type: string
required: false
default: ""
- name: Corpid - name: Corpid
label: label:
en_US: Corpid en_US: Corpid
zh_Hans: 企业ID zh_Hans: 企业ID
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
type: string type: string
required: true required: false
default: "" default: ""
- name: Token - name: Token
label: label:
en_US: Token en_US: Token
zh_Hans: 令牌 (Token) zh_Hans: 令牌 (Token)
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
type: string type: string
required: true required: false
default: "" default: ""
- name: EncodingAESKey - name: EncodingAESKey
label: label:
en_US: EncodingAESKey en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey) zh_Hans: 消息加解密密钥 (EncodingAESKey)
type: string description:
required: true en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
default: "" zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
- name: BotId
label:
en_US: BotId
zh_Hans: 机器人ID
type: string type: string
required: false required: false
default: "" default: ""
execution: execution:
python: python:
path: ./wecombot.py path: ./wecombot.py
attr: WecomBotAdapter attr: WecomBotAdapter

View File

@@ -81,22 +81,33 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
return event.source_platform_object return event.source_platform_object
@staticmethod @staticmethod
async def target2yiri(event: WecomCSEvent): async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
""" """
将 WecomEvent 转换为平台的 FriendMessage 对象。 将 WecomEvent 转换为平台的 FriendMessage 对象。
Args: Args:
event (WecomEvent): 企业微信客服事件。 event (WecomEvent): 企业微信客服事件。
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
Returns: Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。 platform_events.FriendMessage: 转换后的 FriendMessage 对象。
""" """
# Try to get customer nickname from WeChat API
nickname = str(event.user_id)
if bot and event.user_id:
try:
customer_info = await bot.get_customer_info(event.user_id)
if customer_info and customer_info.get('nickname'):
nickname = customer_info.get('nickname')
except Exception:
pass # Fall back to user_id as nickname
# 转换消息链 # 转换消息链
if event.type == 'text': if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id) yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'u{event.user_id}',
nickname=str(event.user_id), nickname=nickname,
remark='', remark='',
) )
@@ -106,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
elif event.type == 'image': elif event.type == 'image':
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'u{event.user_id}',
nickname=str(event.user_id), nickname=nickname,
remark='', remark='',
) )
@@ -187,7 +198,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomCSEvent): async def on_message(event: WecomCSEvent):
self.bot_account_id = event.receiver_id self.bot_account_id = event.receiver_id
try: try:
return await callback(await self.event_converter.target2yiri(event), self) return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except Exception: except Exception:
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}') await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')

View File

@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_LLM_MODELS) @self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse: async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models""" """Get llm models, returns list of UUID strings"""
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False) llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={ data={
'llm_models': llm_models, 'llm_models': [m['uuid'] for m in llm_models],
}, },
) )
@@ -337,7 +337,14 @@ 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]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
# but it's a required field for LLMTool validation. We need to provide a placeholder
# function when reconstructing the LLMTool objects from serialized data.
async def _placeholder_func(**kwargs):
pass
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
result = await llm_model.provider.invoke_llm( result = await llm_model.provider.invoke_llm(
query=None, query=None,
@@ -524,6 +531,7 @@ class RuntimeConnectionHandler(handler.Handler):
filters = data.get('filters') filters = data.get('filters')
search_type = data.get('search_type', 'vector') search_type = data.get('search_type', 'vector')
query_text = data.get('query_text', '') query_text = data.get('query_text', '')
vector_weight = data.get('vector_weight')
try: try:
results = await self.ap.rag_runtime_service.vector_search( results = await self.ap.rag_runtime_service.vector_search(
collection_id, collection_id,
@@ -532,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
filters, filters,
search_type, search_type,
query_text, query_text,
vector_weight=vector_weight,
) )
return handler.ActionResponse.success(data={'results': results}) return handler.ActionResponse.success(data={'results': results})
except Exception as e: except Exception as e:
@@ -548,6 +557,18 @@ class RuntimeConnectionHandler(handler.Handler):
except Exception as e: except Exception as e:
return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
@self.action(PluginToRuntimeAction.VECTOR_LIST)
async def vector_list(data: dict[str, Any]) -> handler.ActionResponse:
collection_id = data['collection_id']
filters = data.get('filters')
limit = data.get('limit', 20)
offset = data.get('offset', 0)
try:
items, total = await self.ap.rag_runtime_service.vector_list(collection_id, filters, limit, offset)
return handler.ActionResponse.success(data={'items': items, 'total': total})
except Exception as e:
return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM) @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse: async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
storage_path = data['storage_path'] storage_path = data['storage_path']
@@ -558,6 +579,16 @@ class RuntimeConnectionHandler(handler.Handler):
except Exception as e: except Exception as e:
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path) return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
@self.action(PluginToRuntimeAction.LIST_PARSERS)
async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to list available parser plugins."""
mime_type = data.get('mime_type')
try:
parsers = await self.ap.knowledge_service.list_parsers(mime_type)
return handler.ActionResponse.success(data={'parsers': parsers})
except Exception as e:
return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)
@self.action(PluginToRuntimeAction.INVOKE_PARSER) @self.action(PluginToRuntimeAction.INVOKE_PARSER)
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse: async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to invoke a parser plugin.""" """Plugin requests host to invoke a parser plugin."""
@@ -582,6 +613,139 @@ class RuntimeConnectionHandler(handler.Handler):
except Exception as e: except Exception as e:
return _make_rag_error_response(e, 'ParserError') return _make_rag_error_response(e, 'ParserError')
# ================= Knowledge Base Query APIs =================
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
knowledge_bases = []
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
knowledge_bases.append(
{
'uuid': kb.get_uuid(),
'name': kb.get_name(),
'description': kb.knowledge_base_entity.description or '',
}
)
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from any knowledge base (unrestricted)."""
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
)
try:
entries = await kb.retrieve(
query_text,
settings={
'top_k': top_k,
'filters': filters,
},
)
results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List knowledge bases configured for the current query's pipeline."""
query_id = data['query_id']
if query_id not in self.ap.query_pool.cached_queries:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query = self.ap.query_pool.cached_queries[query_id]
kb_uuids = []
if query.pipeline_config:
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
kb_uuids = local_agent_config.get('knowledge-bases', [])
# Backward compatibility
if not kb_uuids:
old_kb_uuid = local_agent_config.get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
knowledge_bases = []
for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if kb:
knowledge_bases.append(
{
'uuid': kb.get_uuid(),
'name': kb.get_name(),
'description': kb.knowledge_base_entity.description or '',
}
)
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from a knowledge base within the pipeline's scope."""
query_id = data['query_id']
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
if query_id not in self.ap.query_pool.cached_queries:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
query = self.ap.query_pool.cached_queries[query_id]
# Validate kb_id is in pipeline's allowed list
allowed_kb_uuids = []
if query.pipeline_config:
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
allowed_kb_uuids = local_agent_config.get('knowledge-bases', [])
if not allowed_kb_uuids:
old_kb_uuid = local_agent_config.get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
allowed_kb_uuids = [old_kb_uuid]
if kb_id not in allowed_kb_uuids:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} is not configured for this pipeline',
)
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
)
try:
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
entries = await kb.retrieve(
query_text,
settings={
'top_k': top_k,
'filters': filters,
'session_name': session_name,
'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id),
},
)
results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
@self.action(CommonAction.PING) @self.action(CommonAction.PING)
async def ping(data: dict[str, Any]) -> handler.ActionResponse: async def ping(data: dict[str, Any]) -> handler.ActionResponse:
"""Ping""" """Ping"""
@@ -888,7 +1052,7 @@ class RuntimeConnectionHandler(handler.Handler):
result = await self.call_action( result = await self.call_action(
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT, LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data}, {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
timeout=300, # Ingestion can be slow timeout=1200, # Ingestion can be slow for large documents
) )
return result return result

View File

@@ -288,10 +288,10 @@ class AnthropicMessages(requester.ProviderAPIRequester):
think_started = False think_started = False
think_ended = False think_ended = False
finish_reason = False finish_reason = False
content = ''
tool_name = '' tool_name = ''
tool_id = '' tool_id = ''
async for chunk in await self.client.messages.create(**args): async for chunk in await self.client.messages.create(**args):
content = ''
tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'} tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'}
if isinstance( if isinstance(
chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent

View File

@@ -441,6 +441,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
is_final = False is_final = False
think_start = False think_start = False
think_end = False think_end = False
yielded_final = False
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
@@ -493,13 +494,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if answer: if answer:
basic_mode_pending_chunk = answer basic_mode_pending_chunk = answer
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final): if (
not yielded_final
and (is_final or message_idx % 8 == 0)
and (basic_mode_pending_chunk != '' or is_final)
):
# content, _ = self._process_thinking_content(basic_mode_pending_chunk) # content, _ = self._process_thinking_content(basic_mode_pending_chunk)
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role='assistant', role='assistant',
content=basic_mode_pending_chunk, content=basic_mode_pending_chunk,
is_final=is_final, is_final=is_final,
) )
if is_final:
yielded_final = True
if chunk is None: if chunk is None:
raise errors.DifyAPIError('Dify API 没有返回任何响应请检查网络连接和API配置') raise errors.DifyAPIError('Dify API 没有返回任何响应请检查网络连接和API配置')

View File

@@ -4,6 +4,7 @@ import json
import copy import copy
import typing import typing
from .. import runner from .. import runner
from ..modelmgr import requester as modelmgr_requester
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 import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context import langbot_plugin.api.entities.builtin.rag.context as rag_context
@@ -26,29 +27,114 @@ Respond in the same language as the user's input.
@runner.runner_class('local-agent') @runner.runner_class('local-agent')
class LocalAgentRunner(runner.RequestRunner): class LocalAgentRunner(runner.RequestRunner):
"""本地Agent请求运行器""" """Local agent request runner"""
class ToolCallTracker: async def _get_model_candidates(
"""工具调用追踪器""" self,
query: pipeline_query.Query,
) -> list[modelmgr_requester.RuntimeLLMModel]:
"""Build ordered list of models to try: primary model + fallback models."""
candidates = []
def __init__(self): # Primary model
self.active_calls: dict[str, dict] = {} if query.use_llm_model_uuid:
self.completed_calls: list[provider_message.ToolCall] = [] try:
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
candidates.append(primary)
except ValueError:
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
# Fallback models
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
for fb_uuid in fallback_uuids:
try:
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
candidates.append(fb_model)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
return candidates
async def _invoke_with_fallback(
self,
query: pipeline_query.Query,
candidates: list[modelmgr_requester.RuntimeLLMModel],
messages: list,
funcs: list,
remove_think: bool,
) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
last_error = None
for model in candidates:
try:
msg = await model.provider.invoke_llm(
query,
model,
messages,
funcs if model.model_entity.abilities.__contains__('func_call') else [],
extra_args=model.model_entity.extra_args,
remove_think=remove_think,
)
return msg, model
except Exception as e:
last_error = e
self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')
raise last_error or RuntimeError('No model candidates available')
async def _invoke_stream_with_fallback(
self,
query: pipeline_query.Query,
candidates: list[modelmgr_requester.RuntimeLLMModel],
messages: list,
funcs: list,
remove_think: bool,
) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:
"""Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).
Fallback is only possible before any chunks have been yielded to the client.
Once streaming starts, the model is committed.
"""
last_error = None
for model in candidates:
try:
stream = model.provider.invoke_llm_stream(
query,
model,
messages,
funcs if model.model_entity.abilities.__contains__('func_call') else [],
extra_args=model.model_entity.extra_args,
remove_think=remove_think,
)
# Attempt to get the first chunk to verify the stream works
first_chunk = await stream.__anext__()
async def _chain_stream(first, rest):
yield first
async for chunk in rest:
yield chunk
return _chain_stream(first_chunk, stream), model
except StopAsyncIteration:
# Empty stream — treat as success (model returned nothing)
async def _empty_stream():
return
yield # make it a generator
return _empty_stream(), model
except Exception as e:
last_error = e
self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')
raise last_error or RuntimeError('No model candidates available')
async def run( async def run(
self, query: pipeline_query.Query self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""运行请求""" """Run request"""
pending_tool_calls = [] pending_tool_calls = []
# Get knowledge bases list (new field) # Get knowledge bases list from query variables (set by PreProcessor,
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) # may have been modified by plugins during PromptPreProcessing)
kb_uuids = query.variables.get('_knowledge_base_uuids', [])
# Fallback to old field for backward compatibility
if not kb_uuids:
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
if old_kb_uuid and old_kb_uuid != '__none__':
kb_uuids = [old_kb_uuid]
user_message = copy.deepcopy(query.user_message) user_message = copy.deepcopy(query.user_message)
@@ -74,7 +160,14 @@ class LocalAgentRunner(runner.RequestRunner):
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
continue continue
result = await kb.retrieve(user_message_text) result = await kb.retrieve(
user_message_text,
settings={
'bot_uuid': query.bot_uuid or '',
'sender_id': str(query.sender_id),
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
},
)
if result: if result:
all_results.extend(result) all_results.extend(result)
@@ -113,51 +206,51 @@ class LocalAgentRunner(runner.RequestRunner):
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think') remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) # Build ordered candidate list (primary + fallbacks)
candidates = await self._get_model_candidates(query)
if not candidates:
raise RuntimeError('No LLM model configured for local-agent runner')
self.ap.logger.debug( self.ap.logger.debug(
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}' f'localagent req: query={query.query_id} req_messages={req_messages} '
f'candidates={[m.model_entity.name for m in candidates]}'
) )
if not is_stream: if not is_stream:
# 非流式输出,直接请求 # Non-streaming: invoke with fallback
msg, use_llm_model = await self._invoke_with_fallback(
msg = await use_llm_model.provider.invoke_llm(
query, query,
use_llm_model, candidates,
req_messages, req_messages,
query.use_funcs, query.use_funcs,
extra_args=use_llm_model.model_entity.extra_args, remove_think,
remove_think=remove_think,
) )
yield msg yield msg
final_msg = msg final_msg = msg
else: else:
# 流式输出,需要处理工具调用 # Streaming: invoke with fallback
tool_calls_map: dict[str, provider_message.ToolCall] = {} tool_calls_map: dict[str, provider_message.ToolCall] = {}
msg_idx = 0 msg_idx = 0
accumulated_content = '' # 从开始累积的所有内容 accumulated_content = ''
last_role = 'assistant' last_role = 'assistant'
msg_sequence = 1 msg_sequence = 1
async for msg in use_llm_model.provider.invoke_llm_stream(
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
query, query,
use_llm_model, candidates,
req_messages, req_messages,
query.use_funcs, query.use_funcs,
extra_args=use_llm_model.model_entity.extra_args, remove_think,
remove_think=remove_think, )
): async for msg in stream_src:
msg_idx = msg_idx + 1 msg_idx = msg_idx + 1
# 记录角色
if msg.role: if msg.role:
last_role = msg.role last_role = msg.role
# 累积内容
if msg.content: if msg.content:
accumulated_content += msg.content accumulated_content += msg.content
# 处理工具调用
if msg.tool_calls: if msg.tool_calls:
for tool_call in msg.tool_calls: for tool_call in msg.tool_calls:
if tool_call.id not in tool_calls_map: if tool_call.id not in tool_calls_map:
@@ -169,21 +262,18 @@ class LocalAgentRunner(runner.RequestRunner):
), ),
) )
if tool_call.function and tool_call.function.arguments: if tool_call.function and tool_call.function.arguments:
# 流式处理中工具调用参数可能分多个chunk返回需要追加而不是覆盖
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
# continue
# 每8个chunk或最后一个chunk时输出所有累积的内容
if msg_idx % 8 == 0 or msg.is_final: if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1 msg_sequence += 1
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, # 输出所有累积内容 content=accumulated_content,
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
is_final=msg.is_final, is_final=msg.is_final,
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
) )
# 创建最终消息用于后续处理
final_msg = provider_message.MessageChunk( final_msg = provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, content=accumulated_content,
@@ -198,7 +288,8 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(final_msg) req_messages.append(final_msg)
# 持续请求,只要还有待处理的工具调用就继续处理调用 # Once a model succeeds, commit to it for the tool call loop
# (no fallback mid-conversation — different models may interpret tool results differently)
while pending_tool_calls: while pending_tool_calls:
for tool_call in pending_tool_calls: for tool_call in pending_tool_calls:
try: try:
@@ -239,7 +330,6 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(msg) req_messages.append(msg)
except Exception as e: except Exception as e:
# 工具调用出错,添加一个报错信息到 req_messages
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
yield err_msg yield err_msg
@@ -247,39 +337,38 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(err_msg) req_messages.append(err_msg)
self.ap.logger.debug( self.ap.logger.debug(
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}' f'localagent req: query={query.query_id} req_messages={req_messages} '
f'use_llm_model={use_llm_model.model_entity.name}'
) )
if is_stream: if is_stream:
tool_calls_map = {} tool_calls_map = {}
msg_idx = 0 msg_idx = 0
accumulated_content = '' # 从开始累积的所有内容 accumulated_content = ''
last_role = 'assistant' last_role = 'assistant'
msg_sequence = first_end_sequence msg_sequence = first_end_sequence
async for msg in use_llm_model.provider.invoke_llm_stream( tool_stream_src = use_llm_model.provider.invoke_llm_stream(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
query.use_funcs, query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
extra_args=use_llm_model.model_entity.extra_args, extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think, remove_think=remove_think,
): )
async for msg in tool_stream_src:
msg_idx += 1 msg_idx += 1
# 记录角色
if msg.role: if msg.role:
last_role = msg.role last_role = msg.role
# 第一次请求工具调用时的内容 # Prepend first-round content on first chunk of tool-call round
if msg_idx == 1: if msg_idx == 1:
accumulated_content = first_content if first_content is not None else accumulated_content accumulated_content = first_content if first_content is not None else accumulated_content
# 累积内容
if msg.content: if msg.content:
accumulated_content += msg.content accumulated_content += msg.content
# 处理工具调用
if msg.tool_calls: if msg.tool_calls:
for tool_call in msg.tool_calls: for tool_call in msg.tool_calls:
if tool_call.id not in tool_calls_map: if tool_call.id not in tool_calls_map:
@@ -291,15 +380,13 @@ class LocalAgentRunner(runner.RequestRunner):
), ),
) )
if tool_call.function and tool_call.function.arguments: if tool_call.function and tool_call.function.arguments:
# 流式处理中工具调用参数可能分多个chunk返回需要追加而不是覆盖
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
# 每8个chunk或最后一个chunk时输出所有累积的内容
if msg_idx % 8 == 0 or msg.is_final: if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1 msg_sequence += 1
yield provider_message.MessageChunk( yield provider_message.MessageChunk(
role=last_role, role=last_role,
content=accumulated_content, # 输出所有累积内容 content=accumulated_content,
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
is_final=msg.is_final, is_final=msg.is_final,
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
@@ -312,12 +399,12 @@ class LocalAgentRunner(runner.RequestRunner):
msg_sequence=msg_sequence, msg_sequence=msg_sequence,
) )
else: else:
# 处理完所有调用,再次请求 # Non-streaming: use committed model directly (no fallback in tool loop)
msg = await use_llm_model.provider.invoke_llm( msg = await use_llm_model.provider.invoke_llm(
query, query,
use_llm_model, use_llm_model,
req_messages, req_messages,
query.use_funcs, query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
extra_args=use_llm_model.model_entity.extra_args, extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think, remove_think=remove_think,
) )

View File

@@ -321,13 +321,19 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
if not plugin_id: if not plugin_id:
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.') raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
# Session context (e.g. session_name) stays in retrieval_settings
# for plugins that need it. Do NOT move them into filters, as filters
# are passed directly to vector_search by some plugins (e.g. LangRAG)
# and would cause empty results when the metadata field doesn't exist.
filters = settings.pop('filters', {})
retrieval_context = { retrieval_context = {
'query': query, 'query': query,
'knowledge_base_id': kb.uuid, 'knowledge_base_id': kb.uuid,
'collection_id': kb.collection_id or kb.uuid, 'collection_id': kb.collection_id or kb.uuid,
'retrieval_settings': settings, 'retrieval_settings': settings,
'creation_settings': kb.creation_settings or {}, 'creation_settings': kb.creation_settings or {},
'filters': settings.pop('filters', {}), 'filters': filters,
} }
result = await self.ap.plugin_connector.call_rag_retrieve( result = await self.ap.plugin_connector.call_rag_retrieve(

View File

@@ -41,6 +41,7 @@ class RAGRuntimeService:
filters: dict[str, Any] | None = None, filters: dict[str, Any] | None = None,
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
vector_weight: float | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Handle VECTOR_SEARCH action.""" """Handle VECTOR_SEARCH action."""
return await self.ap.vector_db_mgr.search( return await self.ap.vector_db_mgr.search(
@@ -50,6 +51,7 @@ class RAGRuntimeService:
filter=filters, filter=filters,
search_type=search_type, search_type=search_type,
query_text=query_text, query_text=query_text,
vector_weight=vector_weight,
) )
async def vector_delete( async def vector_delete(
@@ -75,6 +77,31 @@ class RAGRuntimeService:
count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters) count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters)
return count return count
async def vector_list(
self,
collection_id: str,
filters: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""Handle VECTOR_LIST action.
Args:
collection_id: The collection to list from.
filters: Optional metadata filters.
limit: Maximum number of items to return.
offset: Number of items to skip.
Returns:
Tuple of (items, total).
"""
return await self.ap.vector_db_mgr.list_by_filter(
collection_name=collection_id,
filter=filters,
limit=limit,
offset=offset,
)
async def get_file_stream(self, storage_path: str) -> bytes: async def get_file_stream(self, storage_path: str) -> bytes:
"""Handle GET_KNOWLEDEGE_FILE_STREAM action. """Handle GET_KNOWLEDEGE_FILE_STREAM action.

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 19 required_database_version = 24
"""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

View File

@@ -49,17 +49,25 @@ def normalize_filter(
def strip_unsupported_fields( def strip_unsupported_fields(
triples: list[tuple[str, str, Any]], triples: list[tuple[str, str, Any]],
supported_fields: set[str], supported_fields: set[str],
field_aliases: dict[str, str] | None = None,
) -> list[tuple[str, str, Any]]: ) -> list[tuple[str, str, Any]]:
"""Return only triples whose field is in *supported_fields*. """Return only triples whose field is in *supported_fields*.
If *field_aliases* is provided, aliased field names are mapped to the
canonical backend name before the support check. For example,
``{'uuid': 'chunk_uuid'}`` allows callers to use ``uuid`` which is
transparently rewritten to ``chunk_uuid``.
Dropped fields are logged at WARNING level so the caller knows they were Dropped fields are logged at WARNING level so the caller knows they were
silently ignored (useful for Milvus / pgvector which only store a fixed silently ignored (useful for Milvus / pgvector which only store a fixed
schema). schema).
""" """
aliases = field_aliases or {}
kept: list[tuple[str, str, Any]] = [] kept: list[tuple[str, str, Any]] = []
for field, op, value in triples: for field, op, value in triples:
if field in supported_fields: resolved = aliases.get(field, field)
kept.append((field, op, value)) if resolved in supported_fields:
kept.append((resolved, op, value))
else: else:
logger.warning( logger.warning(
'Filter field %r is not supported by this backend and will be ignored (supported: %s)', 'Filter field %r is not supported by this backend and will be ignored (supported: %s)',

View File

@@ -97,10 +97,11 @@ class VectorDBManager:
filter: dict | None = None, filter: dict | None = None,
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
vector_weight: float | None = None,
) -> list[dict]: ) -> list[dict]:
"""Proxy: Search vectors. """Proxy: Search vectors.
Returns a list of dicts with keys: 'id', 'score', 'metadata'. Returns a list of dicts with keys: 'id', 'distance', 'metadata'.
The underlying VectorDatabase.search returns Chroma-style format: The underlying VectorDatabase.search returns Chroma-style format:
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] } { 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
""" """
@@ -111,6 +112,7 @@ class VectorDBManager:
search_type=search_type, search_type=search_type,
query_text=query_text, query_text=query_text,
filter=filter, filter=filter,
vector_weight=vector_weight,
) )
if not results or 'ids' not in results or not results['ids']: if not results or 'ids' not in results or not results['ids']:
@@ -130,7 +132,7 @@ class VectorDBManager:
parsed_results.append( parsed_results.append(
{ {
'id': id_val, 'id': id_val,
'score': r_dists[i] if r_dists and i < len(r_dists) else 0.0, 'distance': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {}, 'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
} }
) )
@@ -157,3 +159,17 @@ class VectorDBManager:
Number of deleted vectors (best-effort; some backends return 0). Number of deleted vectors (best-effort; some backends return 0).
""" """
return await self.vector_db.delete_by_filter(collection_name, filter) return await self.vector_db.delete_by_filter(collection_name, filter)
async def list_by_filter(
self,
collection_name: str,
filter: dict | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Proxy: List vectors by metadata filter with pagination.
Returns:
Tuple of (items, total).
"""
return await self.vector_db.list_by_filter(collection_name, filter, limit, offset)

View File

@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: dict[str, Any] | None = None, filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection. """Search for the most similar vectors in the specified collection.
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
{"file_id": "abc"} {"file_id": "abc"}
{"created_at": {"$gte": 1700000000}} {"created_at": {"$gte": 1700000000}}
{"file_type": {"$in": ["pdf", "docx"]}} {"file_type": {"$in": ["pdf", "docx"]}}
vector_weight: Weight for vector search in hybrid mode (0.01.0).
``None`` means use equal weights (backward compatible).
""" """
pass pass
@@ -92,6 +95,28 @@ class VectorDatabase(abc.ABC):
""" """
pass pass
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""List vectors matching the given metadata filter with pagination.
Args:
collection: Collection name.
filter: Optional metadata filter dict in canonical format.
limit: Maximum number of items to return.
offset: Number of items to skip.
Returns:
Tuple of (items, total) where items is a list of dicts with
keys 'id', 'document', 'metadata', and total is the best-effort
count of all matching vectors (-1 if unknown).
"""
return [], -1
@abc.abstractmethod @abc.abstractmethod
async def get_or_create_collection(self, collection: str): async def get_or_create_collection(self, collection: str):
"""Get or create collection.""" """Get or create collection."""

View File

@@ -2,11 +2,14 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
from chromadb import PersistentClient from chromadb import PersistentClient
from langbot.pkg.vector.vdb import VectorDatabase from langbot.pkg.vector.vdb import VectorDatabase, SearchType
from langbot.pkg.core import app from langbot.pkg.core import app
import chromadb import chromadb
import chromadb.errors import chromadb.errors
# RRF smoothing constant (standard value from the literature)
_RRF_K = 60
class ChromaVectorDatabase(VectorDatabase): class ChromaVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application, base_path: str = './data/chroma'): def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
@@ -14,6 +17,10 @@ class ChromaVectorDatabase(VectorDatabase):
self.client = PersistentClient(path=base_path) self.client = PersistentClient(path=base_path)
self._collections = {} self._collections = {}
@classmethod
def supported_search_types(cls) -> list[SearchType]:
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
async def get_or_create_collection(self, collection: str) -> chromadb.Collection: async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
if collection not in self._collections: if collection not in self._collections:
self._collections[collection] = await asyncio.to_thread( self._collections[collection] = await asyncio.to_thread(
@@ -34,8 +41,8 @@ class ChromaVectorDatabase(VectorDatabase):
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas) kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
if documents is not None: if documents is not None:
kwargs['documents'] = documents kwargs['documents'] = documents
await asyncio.to_thread(col.add, **kwargs) await asyncio.to_thread(col.upsert, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.") self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
async def search( async def search(
self, self,
@@ -45,8 +52,28 @@ class ChromaVectorDatabase(VectorDatabase):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: dict[str, Any] | None = None, filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
col = await self.get_or_create_collection(collection) col = await self.get_or_create_collection(collection)
if search_type == SearchType.FULL_TEXT:
return await self._full_text_search(col, collection, k, query_text, filter)
elif search_type == SearchType.HYBRID:
return await self._hybrid_search(
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
)
# Default: vector search
return await self._vector_search(col, collection, query_embedding, k, filter)
async def _vector_search(
self,
col: chromadb.Collection,
collection: str,
query_embedding: list[float],
k: int,
filter: dict[str, Any] | None,
) -> dict[str, Any]:
query_kwargs: dict[str, Any] = dict( query_kwargs: dict[str, Any] = dict(
query_embeddings=query_embedding, query_embeddings=query_embedding,
n_results=k, n_results=k,
@@ -55,9 +82,154 @@ class ChromaVectorDatabase(VectorDatabase):
if filter: if filter:
query_kwargs['where'] = filter query_kwargs['where'] = filter
results = await asyncio.to_thread(col.query, **query_kwargs) results = await asyncio.to_thread(col.query, **query_kwargs)
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.") self.ap.logger.info(
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
return results return results
async def _full_text_search(
self,
col: chromadb.Collection,
collection: str,
k: int,
query_text: str,
filter: dict[str, Any] | None,
) -> dict[str, Any]:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
get_kwargs: dict[str, Any] = dict(
where_document={'$contains': query_text},
include=['metadatas', 'documents'],
limit=k,
)
if filter:
get_kwargs['where'] = filter
results = await asyncio.to_thread(col.get, **get_kwargs)
# col.get returns flat lists; wrap into column-major format.
# Distances are all 0.0 because Chroma's local $contains is a boolean
# filter with no relevance scoring. Chroma's BM25 sparse embedding
# function (ChromaBm25EmbeddingFunction) can generate scored sparse
# vectors, but sparse vector *indexing* is only available on Chroma
# Cloud, not locally. For ranked results, use hybrid mode or apply a
# reranker in a downstream stage.
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
distances = [0.0] * len(ids)
self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.")
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}
async def _hybrid_search(
self,
col: chromadb.Collection,
collection: str,
query_embedding: list[float],
k: int,
query_text: str,
filter: dict[str, Any] | None,
vector_weight: float | None = None,
) -> dict[str, Any]:
# Fall back to pure vector search when no text is provided
if not query_text:
return await self._vector_search(col, collection, query_embedding, k, filter)
# Run vector search and full-text search in parallel
vector_task = self._vector_search(col, collection, query_embedding, k, filter)
text_task = self._full_text_search(col, collection, k, query_text, filter)
vector_results, text_results = await asyncio.gather(vector_task, text_task)
vector_ids = vector_results.get('ids', [[]])[0]
text_ids = text_results.get('ids', [[]])[0]
if not vector_ids and not text_ids:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
# RRF fusion
weights = None
if vector_weight is not None:
weights = [vector_weight, 1.0 - vector_weight]
self.ap.logger.info(
f"Chroma hybrid fusion config in '{collection}': "
f'vector_weight={vector_weight}, weights={weights or [1.0, 1.0]}, '
f'vector_hits={len(vector_ids)}, text_hits={len(text_ids)}'
)
fused = self._rrf_fuse([vector_ids, text_ids], k, weights=weights)
if not fused:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
fused_ids = [doc_id for doc_id, _ in fused]
# Fetch full metadata and documents for fused results
fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])
# col.get returns results in arbitrary order; re-order to match fused ranking
fetched_map: dict[str, tuple] = {}
for i, fid in enumerate(fetched.get('ids', [])):
meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]
doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]
fetched_map[fid] = (meta, doc)
ordered_ids = []
ordered_metas = []
ordered_docs = []
ordered_dists = []
# Normalize RRF scores to 0~1 distances via min-max scaling.
# Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive
# ``1 - score`` would compress all distances into a narrow 0.96~0.98
# band with almost no discriminative power. Min-max normalization
# spreads them across the full 0~1 range (0.0 = best match).
max_score = fused[0][1]
min_score = fused[-1][1]
score_range = max_score - min_score
for doc_id, score in fused:
if doc_id in fetched_map:
meta, doc = fetched_map[doc_id]
ordered_ids.append(doc_id)
ordered_metas.append(meta)
ordered_docs.append(doc)
if score_range > 0:
ordered_dists.append(1.0 - (score - min_score) / score_range)
else:
ordered_dists.append(0.0)
self.ap.logger.info(
f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results "
f'(vector={len(vector_ids)}, text={len(text_ids)}).'
)
return {
'ids': [ordered_ids],
'metadatas': [ordered_metas],
'distances': [ordered_dists],
'documents': [ordered_docs],
}
@staticmethod
def _rrf_fuse(result_lists: list[list[str]], k: int, weights: list[float] | None = None) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion over multiple ranked ID lists.
Returns a list of (doc_id, rrf_score) sorted by descending score,
truncated to *k* entries.
Args:
result_lists: Ranked ID lists from different search methods.
k: Number of results to return.
weights: Per-list weights. ``None`` means equal weight (1.0 each).
"""
if weights is None:
weights = [1.0] * len(result_lists)
scores: dict[str, float] = {}
for list_idx, ranked_ids in enumerate(result_lists):
w = weights[list_idx]
for rank, doc_id in enumerate(ranked_ids):
scores[doc_id] = scores.get(doc_id, 0.0) + w / (_RRF_K + rank + 1)
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:k]
async def delete_by_file_id(self, collection: str, file_id: str) -> None: async def delete_by_file_id(self, collection: str, file_id: str) -> None:
col = await self.get_or_create_collection(collection) col = await self.get_or_create_collection(collection)
await asyncio.to_thread(col.delete, where={'file_id': file_id}) await asyncio.to_thread(col.delete, where={'file_id': file_id})
@@ -69,6 +241,41 @@ class ChromaVectorDatabase(VectorDatabase):
self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' by filter") self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' by filter")
return 0 # Chroma delete does not return a count return 0 # Chroma delete does not return a count
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
col = await self.get_or_create_collection(collection)
get_kwargs: dict[str, Any] = dict(
include=['metadatas', 'documents'],
limit=limit,
offset=offset,
)
if filter:
get_kwargs['where'] = filter
results = await asyncio.to_thread(col.get, **get_kwargs)
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
items = []
for i, vid in enumerate(ids):
items.append(
{
'id': vid,
'document': documents[i] if i < len(documents) else None,
'metadata': metadatas[i] if i < len(metadatas) else {},
}
)
# Chroma col.count() gives total in collection; filtered count not available
total = await asyncio.to_thread(col.count) if not filter else -1
return items, total
async def delete_collection(self, collection: str): async def delete_collection(self, collection: str):
if collection in self._collections: if collection in self._collections:
del self._collections[collection] del self._collections[collection]

View File

@@ -11,11 +11,14 @@ from langbot.pkg.core import app
# silently dropped with a warning. # silently dropped with a warning.
_MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'} _MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
# Callers use canonical metadata key 'uuid' but Milvus stores it as 'chunk_uuid'.
_MILVUS_FIELD_ALIASES = {'uuid': 'chunk_uuid'}
def _build_milvus_expr(filter_dict: dict[str, Any]) -> str: def _build_milvus_expr(filter_dict: dict[str, Any]) -> str:
"""Translate canonical filter dict into a Milvus boolean expression string.""" """Translate canonical filter dict into a Milvus boolean expression string."""
triples = normalize_filter(filter_dict) triples = normalize_filter(filter_dict)
triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS) triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS, _MILVUS_FIELD_ALIASES)
if not triples: if not triples:
return '' return ''
@@ -252,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: dict[str, Any] | None = None, filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection """Search for similar vectors in Milvus collection
@@ -340,6 +344,62 @@ class MilvusVectorDatabase(VectorDatabase):
self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' by filter") self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' by filter")
return 0 # Milvus delete does not return a count return 0 # Milvus delete does not return a count
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
collection = self._normalize_collection_name(collection)
await self.get_or_create_collection(collection)
query_kwargs: dict[str, Any] = dict(
collection_name=collection,
output_fields=['text', 'file_id', 'chunk_uuid'],
limit=limit,
offset=offset,
)
if filter:
expr = _build_milvus_expr(filter)
if expr:
query_kwargs['filter'] = expr
results = await asyncio.to_thread(self.client.query, **query_kwargs)
items = []
for row in results:
items.append(
{
'id': row.get('id', ''),
'document': row.get('text'),
'metadata': {
'text': row.get('text', ''),
'file_id': row.get('file_id', ''),
'uuid': row.get('chunk_uuid', ''),
},
}
)
# Milvus query with count(*)
total = -1
try:
count_kwargs: dict[str, Any] = dict(
collection_name=collection,
output_fields=['count(*)'],
)
if filter:
expr = _build_milvus_expr(filter)
if expr:
count_kwargs['filter'] = expr
count_result = await asyncio.to_thread(self.client.query, **count_kwargs)
if count_result:
total = count_result[0].get('count(*)', -1)
except Exception:
pass
return items, total
async def delete_collection(self, collection: str): async def delete_collection(self, collection: str):
"""Delete a Milvus collection """Delete a Milvus collection

View File

@@ -13,6 +13,9 @@ Base = declarative_base()
# pgvector schema only stores these metadata fields. # pgvector schema only stores these metadata fields.
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'} _PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
# Callers use canonical metadata key 'uuid' but pgvector stores it as 'chunk_uuid'.
_PG_FIELD_ALIASES = {'uuid': 'chunk_uuid'}
# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry). # Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry).
_PG_COLUMN_MAP = { _PG_COLUMN_MAP = {
'text': 'text', 'text': 'text',
@@ -37,7 +40,7 @@ class PgVectorEntry(Base):
def _build_pg_conditions(filter_dict: dict[str, Any]) -> list: def _build_pg_conditions(filter_dict: dict[str, Any]) -> list:
"""Translate canonical filter dict into a list of SQLAlchemy conditions.""" """Translate canonical filter dict into a list of SQLAlchemy conditions."""
triples = normalize_filter(filter_dict) triples = normalize_filter(filter_dict)
triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS) triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS, _PG_FIELD_ALIASES)
conditions = [] conditions = []
for field, op, value in triples: for field, op, value in triples:
@@ -189,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: dict[str, Any] | None = None, filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance """Search for similar vectors using cosine distance
@@ -309,6 +313,65 @@ class PgVectorDatabase(VectorDatabase):
self.ap.logger.error(f'Error deleting from pgvector by filter: {e}') self.ap.logger.error(f'Error deleting from pgvector by filter: {e}')
raise raise
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
from sqlalchemy import select, func
stmt = (
select(
PgVectorEntry.id,
PgVectorEntry.text,
PgVectorEntry.file_id,
PgVectorEntry.chunk_uuid,
)
.filter(PgVectorEntry.collection == collection)
.offset(offset)
.limit(limit)
)
count_stmt = (
select(func.count()).select_from(PgVectorEntry).filter(PgVectorEntry.collection == collection)
)
if filter:
for cond in _build_pg_conditions(filter):
stmt = stmt.filter(cond)
count_stmt = count_stmt.filter(cond)
result = await session.execute(stmt)
rows = result.fetchall()
count_result = await session.execute(count_stmt)
total = count_result.scalar() or 0
items = []
for row in rows:
items.append(
{
'id': row.id,
'document': row.text or '',
'metadata': {
'text': row.text or '',
'file_id': row.file_id or '',
'uuid': row.chunk_uuid or '',
},
}
)
return items, total
except Exception as e:
self.ap.logger.error(f'Error listing from pgvector: {e}')
raise
async def delete_collection(self, collection: str): async def delete_collection(self, collection: str):
"""Delete all vectors in a collection """Delete all vectors in a collection

View File

@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: dict[str, Any] | None = None, filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
exists = await self.client.collection_exists(collection) exists = await self.client.collection_exists(collection)
if not exists: if not exists:
@@ -150,6 +151,97 @@ class QdrantVectorDatabase(VectorDatabase):
self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' by filter") self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' by filter")
return 0 # Qdrant delete does not return a count return 0 # Qdrant delete does not return a count
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
exists = await self.client.collection_exists(collection)
if not exists:
return [], 0
qdrant_filter = _build_qdrant_filter(filter) if filter else None
# Qdrant scroll uses cursor-based pagination (offset = point ID),
# not numeric skip. To support numeric offset we scroll through
# `offset + limit` items and discard the first `offset`.
remaining_to_skip = offset
remaining_to_collect = limit
cursor: int | str | None = None
collected: list[dict[str, Any]] = []
while remaining_to_skip > 0 or remaining_to_collect > 0:
batch_size = remaining_to_skip + remaining_to_collect if remaining_to_skip > 0 else remaining_to_collect
scroll_kwargs: dict[str, Any] = dict(
collection_name=collection,
limit=min(batch_size, 256),
with_payload=True if remaining_to_skip == 0 else False,
with_vectors=False,
)
if qdrant_filter:
scroll_kwargs['scroll_filter'] = qdrant_filter
if cursor is not None:
scroll_kwargs['offset'] = cursor
points, next_cursor = await self.client.scroll(**scroll_kwargs)
if not points:
break
for point in points:
if remaining_to_skip > 0:
remaining_to_skip -= 1
continue
if remaining_to_collect <= 0:
break
# Re-fetch payload if we skipped it during the skip phase
payload = point.payload or {}
collected.append(
{
'id': str(point.id),
'document': payload.get('text') or payload.get('document'),
'metadata': payload,
}
)
remaining_to_collect -= 1
if next_cursor is None:
break
cursor = next_cursor
# If we skipped without payload, re-fetch the collected items' payloads
# (only needed when offset > 0 and items were collected in a skip batch)
if offset > 0 and collected:
refetch_ids = [item['id'] for item in collected if not item.get('metadata')]
if refetch_ids:
fetched_points = await self.client.retrieve(
collection_name=collection,
ids=refetch_ids,
with_payload=True,
with_vectors=False,
)
payload_map = {str(p.id): p.payload or {} for p in fetched_points}
for item in collected:
if item['id'] in payload_map:
payload = payload_map[item['id']]
item['metadata'] = payload
item['document'] = payload.get('text') or payload.get('document')
# Use count() for accurate total (supports filter)
total = -1
try:
count_result = await self.client.count(
collection_name=collection,
count_filter=qdrant_filter,
exact=True,
)
total = count_result.count
except Exception:
pass
return collected, total
async def delete_collection(self, collection: str): async def delete_collection(self, collection: str):
try: try:
await self.client.delete_collection(collection) await self.client.delete_collection(collection)

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from decimal import Decimal
import re
from typing import Any, Dict, List from typing import Any, Dict, List
@@ -101,8 +103,28 @@ class SeekDBVectorDatabase(VectorDatabase):
} }
) )
def _normalize_collection_name(self, collection: str) -> str:
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
if normalized != collection:
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
return normalized
def _json_safe(self, value: Any) -> Any:
"""Convert SeekDB result values into JSON-serializable Python primitives."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: self._json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [self._json_safe(v) for v in value]
if isinstance(value, tuple):
return [self._json_safe(v) for v in value]
return value
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any: 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.""" """Internal method to get or create a collection with proper configuration."""
collection = self._normalize_collection_name(collection)
if collection in self._collections: if collection in self._collections:
return self._collections[collection] return self._collections[collection]
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
if not embeddings_list: if not embeddings_list:
return return
collection = self._normalize_collection_name(collection)
# Ensure collection exists with correct dimension # Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0]) vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size) coll = await self._get_or_create_collection_internal(collection, vector_size)
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
search_type: str = 'vector', search_type: str = 'vector',
query_text: str = '', query_text: str = '',
filter: Dict[str, Any] | None = None, filter: Dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection. """Search for the most similar vectors in the specified collection.
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
Returns: Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys Dictionary with 'ids', 'metadatas', 'distances' keys
""" """
collection = self._normalize_collection_name(collection)
# Check if collection exists # Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection) exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists: if not exists:
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
query_cfg['where'] = filter query_cfg['where'] = filter
knn_cfg['where'] = filter knn_cfg['where'] = filter
# Apply vector_weight via pyseekdb's native boost parameter
if vector_weight is not None:
knn_cfg['boost'] = vector_weight
query_cfg['boost'] = 1.0 - vector_weight
self.ap.logger.info(
f"SeekDB hybrid fusion config in '{collection}': "
f'vector_weight={vector_weight}, '
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
f'query_boost={query_cfg.get("boost", 1.0)}'
)
results = await asyncio.to_thread( results = await asyncio.to_thread(
coll.hybrid_search, coll.hybrid_search,
query=query_cfg, query=query_cfg,
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k, n_results=k,
include=['documents', 'metadatas'], include=['documents', 'metadatas'],
) )
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
else: else:
# Default: vector search via query() # Default: vector search via query()
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding} query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
query_kwargs['where'] = filter query_kwargs['where'] = filter
results = await asyncio.to_thread(coll.query, **query_kwargs) results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
self.ap.logger.info( self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results" f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
) )
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name collection: Collection name
file_id: File ID to delete file_id: File ID to delete
""" """
collection = self._normalize_collection_name(collection)
# Check if collection exists # Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection) exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists: if not exists:
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name collection: Collection name
filter: Chroma-style ``where`` filter dict filter: Chroma-style ``where`` filter dict
""" """
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection) exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists: if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion") self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
@@ -340,12 +382,59 @@ class SeekDBVectorDatabase(VectorDatabase):
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter") self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter")
return 0 # SeekDB delete does not return a count return 0 # SeekDB delete does not return a count
async def list_by_filter(
self,
collection: str,
filter: Dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[Dict[str, Any]], int]:
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return [], 0
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]
get_kwargs: Dict[str, Any] = dict(
include=['metadatas', 'documents'],
limit=limit,
offset=offset,
)
if filter:
get_kwargs['where'] = filter
results = await asyncio.to_thread(coll.get, **get_kwargs)
results = self._json_safe(results)
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
items = []
for i, vid in enumerate(ids):
items.append(
{
'id': vid,
'document': documents[i] if i < len(documents) else None,
'metadata': metadatas[i] if i < len(metadatas) else {},
}
)
total = await asyncio.to_thread(coll.count) if not filter else -1
return items, total
async def delete_collection(self, collection: str): async def delete_collection(self, collection: str):
"""Delete the entire collection. """Delete the entire collection.
Args: Args:
collection: Collection name collection: Collection name
""" """
collection = self._normalize_collection_name(collection)
# Remove from cache # Remove from cache
if collection in self._collections: if collection in self._collections:
del self._collections[collection] del self._collections[collection]

View File

@@ -2,6 +2,7 @@ admins: []
api: api:
port: 5300 port: 5300
webhook_prefix: 'http://127.0.0.1:5300' webhook_prefix: 'http://127.0.0.1:5300'
extra_webhook_prefix: ''
command: command:
enable: true enable: true
prefix: prefix:
@@ -15,6 +16,7 @@ proxy:
http: '' http: ''
https: '' https: ''
system: system:
instance_id: ''
edition: community edition: community
recovery_key: '' recovery_key: ''
allow_modify_login_info: true allow_modify_login_info: true

View File

@@ -41,7 +41,10 @@
"runner": "local-agent" "runner": "local-agent"
}, },
"local-agent": { "local-agent": {
"model": "", "model": {
"primary": "",
"fallbacks": []
},
"max-round": 10, "max-round": 10,
"prompt": [ "prompt": [
{ {
@@ -95,11 +98,12 @@
"max": 0 "max": 0
}, },
"misc": { "misc": {
"hide-exception": true, "exception-handling": "show-hint",
"failure-hint": "Request failed.",
"at-sender": true, "at-sender": true,
"quote-origin": true, "quote-origin": true,
"track-function-calls": false, "track-function-calls": false,
"remove-think": false "remove-think": false
} }
} }
} }

View File

@@ -59,8 +59,11 @@ stages:
label: label:
en_US: Model en_US: Model
zh_Hans: 模型 zh_Hans: 模型
type: llm-model-selector type: model-fallback-selector
required: true required: true
default:
primary: ''
fallbacks: []
- name: max-round - name: max-round
label: label:
en_US: Max Round en_US: Max Round

View File

@@ -78,13 +78,39 @@ stages:
en_US: Misc en_US: Misc
zh_Hans: 杂项 zh_Hans: 杂项
config: config:
- name: hide-exception - name: exception-handling
label: label:
en_US: Hide Exception en_US: Exception Handling Strategy
zh_Hans: 不输出异常信息给用户 zh_Hans: 异常处理策略
type: boolean description:
en_US: Controls how error messages are displayed to the user when an AI request fails
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
type: select
required: true required: true
default: true default: show-hint
options:
- name: show-error
label:
en_US: Show Full Error
zh_Hans: 显示完整报错信息
- name: show-hint
label:
en_US: Show Failure Hint
zh_Hans: 仅文字提示
- name: hide
label:
en_US: Hide All
zh_Hans: 不显示任何异常信息
- name: failure-hint
label:
en_US: Failure Hint Text
zh_Hans: 失败提示文本
description:
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
type: string
required: false
default: 'Request failed.'
- name: at-sender - name: at-sender
label: label:
en_US: At Sender en_US: At Sender
@@ -119,3 +145,4 @@ stages:
type: boolean type: boolean
required: true required: true
default: false default: false

View File

@@ -2,77 +2,5 @@
"说明": "mask将替换敏感词中的每一个字若mask_word值不为空则将敏感词整个替换为mask_word的值", "说明": "mask将替换敏感词中的每一个字若mask_word值不为空则将敏感词整个替换为mask_word的值",
"mask": "*", "mask": "*",
"mask_word": "", "mask_word": "",
"words": [ "words": []
"习近平",
"胡锦涛",
"江泽民",
"温家宝",
"李克强",
"李长春",
"毛泽东",
"邓小平",
"周恩来",
"马克思",
"社会主义",
"共产党",
"共产主义",
"大陆官方",
"北京政权",
"中华帝国",
"中国政府",
"共狗",
"六四事件",
"天安门",
"六四",
"政治局常委",
"两会",
"共青团",
"学潮",
"八九",
"二十大",
"民进党",
"台独",
"台湾独立",
"台湾国",
"国民党",
"台湾民国",
"中华民国",
"pornhub",
"Pornhub",
"[Yy]ou[Pp]orn",
"porn",
"Porn",
"[Xx][Vv]ideos",
"[Rr]ed[Tt]ube",
"[Xx][Hh]amster",
"[Ss]pank[Ww]ire",
"[Ss]pank[Bb]ang",
"[Tt]ube8",
"[Yy]ou[Jj]izz",
"[Bb]razzers",
"[Nn]aughty[ ]?[Aa]merica",
"作爱",
"做爱",
"性交",
"性爱",
"自慰",
"阴茎",
"淫妇",
"肛交",
"交配",
"性关系",
"性活动",
"色情",
"色图",
"涩图",
"裸体",
"小穴",
"淫荡",
"性爱",
"翻墙",
"VPN",
"科学上网",
"挂梯子",
"GFW"
]
} }

View File

@@ -91,14 +91,15 @@ class TestWebhookDisplayPrefix:
def test_default_webhook_prefix(self): def test_default_webhook_prefix(self):
"""Test that the default webhook display prefix is correctly set""" """Test that the default webhook display prefix is correctly set"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Should have the default value # Should have the default value
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300' assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
assert cfg['api']['extra_webhook_prefix'] == ''
def test_webhook_prefix_env_override(self): def test_webhook_prefix_env_override(self):
"""Test overriding webhook_prefix via environment variable""" """Test overriding webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set environment variable # Set environment variable
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080' os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
@@ -112,7 +113,7 @@ class TestWebhookDisplayPrefix:
def test_webhook_prefix_with_custom_domain(self): def test_webhook_prefix_with_custom_domain(self):
"""Test webhook_prefix with custom domain""" """Test webhook_prefix with custom domain"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set to a custom domain # Set to a custom domain
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com' os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
@@ -126,7 +127,7 @@ class TestWebhookDisplayPrefix:
def test_webhook_prefix_with_subdirectory(self): def test_webhook_prefix_with_subdirectory(self):
"""Test webhook_prefix with subdirectory path""" """Test webhook_prefix with subdirectory path"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set to a URL with subdirectory # Set to a URL with subdirectory
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot' os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
@@ -138,6 +139,37 @@ class TestWebhookDisplayPrefix:
# Cleanup # Cleanup
del os.environ['API__WEBHOOK_PREFIX'] del os.environ['API__WEBHOOK_PREFIX']
def test_extra_webhook_prefix_default_empty(self):
"""Test that extra_webhook_prefix defaults to empty string"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
bot_uuid = 'test-bot-uuid'
webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '')
webhook_url = f'/bots/{bot_uuid}'
assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid'
# extra should be empty when not configured
assert extra_webhook_prefix == ''
def test_extra_webhook_prefix_env_override(self):
"""Test overriding extra_webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
bot_uuid = 'test-bot-uuid'
extra_prefix = result['api']['extra_webhook_prefix']
webhook_url = f'/bots/{bot_uuid}'
assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid'
# Cleanup
del os.environ['API__EXTRA_WEBHOOK_PREFIX']
if __name__ == '__main__': if __name__ == '__main__':
pytest.main([__file__, '-v']) pytest.main([__file__, '-v'])

View File

@@ -194,7 +194,7 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
pipeline_config={ pipeline_config={
'ai': { 'ai': {
'runner': {'runner': 'local-agent'}, 'runner': {'runner': 'local-agent'},
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'}, 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
}, },
'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}}, 'trigger': {'misc': {'combine-quote-message': False}},
@@ -219,7 +219,7 @@ def sample_pipeline_config():
return { return {
'ai': { 'ai': {
'runner': {'runner': 'local-agent'}, 'runner': {'runner': 'local-agent'},
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'}, 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
}, },
'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}}, 'trigger': {'misc': {'combine-quote-message': False}},

View File

@@ -0,0 +1,113 @@
"""Unit tests for config_coercion module"""
from __future__ import annotations
import pytest
from langbot.pkg.pipeline.config_coercion import _coerce_value, coerce_pipeline_config
class TestCoerceValue:
"""Tests for _coerce_value function"""
def test_none_passthrough(self):
assert _coerce_value(None, 'integer') is None
assert _coerce_value(None, 'boolean') is None
def test_string_to_integer(self):
assert _coerce_value('120', 'integer') == 120
assert _coerce_value('0', 'integer') == 0
assert _coerce_value('-5', 'integer') == -5
def test_integer_passthrough(self):
assert _coerce_value(42, 'integer') == 42
def test_string_to_float(self):
assert _coerce_value('3.14', 'number') == 3.14
assert _coerce_value('3.14', 'float') == 3.14
def test_int_to_float(self):
assert _coerce_value(3, 'number') == 3.0
assert isinstance(_coerce_value(3, 'number'), float)
def test_float_passthrough(self):
assert _coerce_value(3.14, 'float') == 3.14
def test_string_to_bool(self):
assert _coerce_value('true', 'boolean') is True
assert _coerce_value('True', 'boolean') is True
assert _coerce_value('false', 'boolean') is False
assert _coerce_value('False', 'boolean') is False
def test_bool_passthrough(self):
assert _coerce_value(True, 'boolean') is True
assert _coerce_value(False, 'boolean') is False
def test_invalid_bool_string_raises(self):
with pytest.raises(ValueError):
_coerce_value('notabool', 'boolean')
def test_unknown_type_passthrough(self):
assert _coerce_value('hello', 'string') == 'hello'
assert _coerce_value('hello', 'unknown') == 'hello'
def test_invalid_integer_raises(self):
with pytest.raises(ValueError):
_coerce_value('abc', 'integer')
class TestCoercePipelineConfig:
"""Tests for coerce_pipeline_config function"""
def _make_meta(self, section_name: str, stage_name: str, fields: list[dict]) -> dict:
return {
'name': section_name,
'stages': [{'name': stage_name, 'config': fields}],
}
def test_coerce_integer_in_config(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 120
def test_coerce_boolean_in_config(self):
config = {'output': {'misc': {'at-sender': 'true'}}}
meta = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta)
assert config['output']['misc']['at-sender'] is True
def test_missing_section_skipped(self):
config = {'ai': {}}
meta = self._make_meta('trigger', 'misc', [{'name': 'x', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_missing_field_skipped(self):
config = {'trigger': {'misc': {}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'nonexistent', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_invalid_value_logs_warning(self, caplog):
config = {'trigger': {'misc': {'timeout': 'abc'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
import logging
with caplog.at_level(logging.WARNING):
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 'abc' # unchanged
assert 'Failed to coerce' in caplog.text
def test_empty_metadata(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
coerce_pipeline_config(config) # no metadata args, should not raise
def test_multiple_metadata(self):
config = {
'trigger': {'misc': {'timeout': '120'}},
'output': {'misc': {'at-sender': 'false'}},
}
meta_trigger = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
meta_output = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta_trigger, meta_output)
assert config['trigger']['misc']['timeout'] == 120
assert config['output']['misc']['at-sender'] is False

517
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.11, <4.0" requires-python = ">=3.11, <4.0"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -964,6 +964,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
] ]
[[package]]
name = "cuda-bindings"
version = "12.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" },
{ url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" },
{ url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" },
]
[[package]]
name = "cuda-pathfinder"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" },
]
[[package]] [[package]]
name = "dashscope" name = "dashscope"
version = "1.25.10" version = "1.25.10"
@@ -1088,7 +1112,7 @@ wheels = [
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "blinker" }, { name = "blinker" },
@@ -1098,9 +1122,9 @@ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
] ]
[[package]] [[package]]
@@ -1729,6 +1753,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
] ]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]] [[package]]
name = "jsonpatch" name = "jsonpatch"
version = "1.33" version = "1.33"
@@ -1799,7 +1832,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.8.7" version = "4.9.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -1895,7 +1928,7 @@ requires-dist = [
{ name = "botocore", specifier = ">=1.42.39" }, { name = "botocore", specifier = ">=1.42.39" },
{ name = "certifi", specifier = ">=2025.4.26" }, { name = "certifi", specifier = ">=2025.4.26" },
{ name = "chardet", specifier = ">=5.2.0" }, { name = "chardet", specifier = ">=5.2.0" },
{ name = "chromadb", specifier = ">=0.4.24" }, { name = "chromadb", specifier = ">=1.0.0,<2.0.0" },
{ name = "colorlog", specifier = "~=6.6.0" }, { name = "colorlog", specifier = "~=6.6.0" },
{ name = "cryptography", specifier = ">=44.0.3" }, { name = "cryptography", specifier = ">=44.0.3" },
{ name = "dashscope", specifier = ">=1.25.10" }, { name = "dashscope", specifier = ">=1.25.10" },
@@ -1904,7 +1937,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.2.7" }, { name = "langbot-plugin", specifier = "==0.3.5" },
{ name = "langchain", specifier = ">=0.2.0" }, { name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" }, { name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" }, { name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1927,7 +1960,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" }, { name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" }, { name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" }, { name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", specifier = "==1.0.0b7" }, { name = "pyseekdb", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" }, { name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-socks", specifier = ">=2.7.1" }, { name = "python-socks", specifier = ">=2.7.1" },
{ name = "python-telegram-bot", specifier = ">=22.0" }, { name = "python-telegram-bot", specifier = ">=22.0" },
@@ -1960,7 +1993,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.2.7" version = "0.3.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -1978,28 +2011,28 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9e/a0/babd76596e5de38149da67b8da20e0519cc5f10080de9dc2b16919486f29/langbot_plugin-0.2.7.tar.gz", hash = "sha256:5c8ad1820283901a33356f79a56c84b4744712a463e1c7aecc6e9defe4db4446", size = 162458, upload-time = "2026-02-25T06:00:52.512Z" } sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/2a/6575cf5d5babb7a9400a8aca243e4b8341d83b673e5e9c0394c0393f1c3e/langbot_plugin-0.2.7-py3-none-any.whl", hash = "sha256:17344e61537a5bb97fc77cd83812b5db926f29005e92fefbcbaca5bb47bf55f0", size = 133476, upload-time = "2026-02-25T06:00:50.988Z" }, { url = "https://files.pythonhosted.org/packages/cd/93/fdd4eb54434a358a3917aec74190e2e1b64351a5bb955677f634d29fc4fd/langbot_plugin-0.3.5-py3-none-any.whl", hash = "sha256:4d31f92338e1e2dc343ae00982e4facbe7abae84f4d1c4e1375cdcac9d7155d7", size = 146575, upload-time = "2026-03-25T13:53:16.987Z" },
] ]
[[package]] [[package]]
name = "langchain" name = "langchain"
version = "1.2.7" version = "1.2.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
{ name = "langgraph" }, { name = "langgraph" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/47/f2/478ca9f3455b5d66402066d287eae7e8d6c722acfb8553937e06af708334/langchain-1.2.7.tar.gz", hash = "sha256:ba40e8d5b069a22f7085f54f405973da3d87cfdebf116282e77c692271432ecb", size = 556837, upload-time = "2026-01-23T15:22:10.817Z" } sdist = { url = "https://files.pythonhosted.org/packages/d8/1d/1af2fc0ac084d4781778b7846b1aed62e05006bf2d73fdf84ac3a8f5225c/langchain-1.2.12.tar.gz", hash = "sha256:ed705b5b293799f7e3e394387f398a1b71707542758283206c8c21415759d991", size = 566444, upload-time = "2026-03-11T22:21:00.712Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/c8/9ce37ae34870834c7d00bb14ff4876b700db31b928635e3307804dc41d74/langchain-1.2.7-py3-none-any.whl", hash = "sha256:1d643c8ca569bcde2470b853807f74f0768b3982d25d66d57db21a166aabda72", size = 108827, upload-time = "2026-01-23T15:22:09.771Z" }, { url = "https://files.pythonhosted.org/packages/ca/51/09bb1cfb0b57ae9440ca56cc576e4dc792f83d030eef7637d2c516dcb0a0/langchain-1.2.12-py3-none-any.whl", hash = "sha256:60eff184b8f92c2610f5a4c9a97ad339a891adb01901e83e4df8e6c9c69cf852", size = 112373, upload-time = "2026-03-11T22:20:59.508Z" },
] ]
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.2.7" version = "1.2.18"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
@@ -2011,9 +2044,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { name = "uuid-utils" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } sdist = { url = "https://files.pythonhosted.org/packages/18/b7/8bbd0d99a6441b35d891e4b79e7d24c67722cdd363893ae650f24808cf5a/langchain_core-1.2.18.tar.gz", hash = "sha256:ffe53eec44636d092895b9fe25d28af3aaf79060e293fa7cda2a5aaa50c80d21", size = 836725, upload-time = "2026-03-09T20:40:07.229Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, { url = "https://files.pythonhosted.org/packages/1f/d8/9418564ed4ab4f150668b25cf8c188266267d829362e9c9106946afa628b/langchain_core-1.2.18-py3-none-any.whl", hash = "sha256:cccb79523e0045174ab826054e555fddc973266770e427588c8f1ec9d9d6212b", size = 503048, upload-time = "2026-03-09T20:40:06.115Z" },
] ]
[[package]] [[package]]
@@ -2030,7 +2063,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph" name = "langgraph"
version = "1.0.7" version = "1.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -2040,9 +2073,9 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "xxhash" }, { name = "xxhash" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/72/5b/f72655717c04e33d3b62f21b166dc063d192b53980e9e3be0e2a117f1c9f/langgraph-1.0.7.tar.gz", hash = "sha256:0cfdfee51e6e8cfe503ecc7367c73933437c505b03fa10a85c710975c8182d9a", size = 497098, upload-time = "2026-01-22T16:57:47.303Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/1a/6dbad0c87fb39a58e5ced85297511cc4bcad06cc420b20898eecafece2a2/langgraph-1.1.1.tar.gz", hash = "sha256:cd6282efc657c955b41bff6bd9693de58137ad18f7e7f16b4d17c7d2118d53e1", size = 544040, upload-time = "2026-03-11T22:14:47.845Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/0e/fe80144e3e4048e5d19ccdb91ac547c1a7dc3da8dbd1443e210048194c14/langgraph-1.0.7-py3-none-any.whl", hash = "sha256:9d68e8f8dd8f3de2fec45f9a06de05766d9b075b78fb03171779893b7a52c4d2", size = 157353, upload-time = "2026-01-22T16:57:45.997Z" }, { url = "https://files.pythonhosted.org/packages/dc/c1/572187bb61a534050ef2d5030e7abe46b19694ec106604fe12ddcb8672c7/langgraph-1.1.1-py3-none-any.whl", hash = "sha256:d0cc8d347131cbfc010e65aad9b0f1afbd0e151f470c288bec1f3df8336c50c6", size = 167502, upload-time = "2026-03-11T22:14:46.121Z" },
] ]
[[package]] [[package]]
@@ -2060,15 +2093,15 @@ wheels = [
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "1.0.7" version = "1.0.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
{ name = "langgraph-checkpoint" }, { name = "langgraph-checkpoint" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/711aecd1a50999456850dc328f3cad72b4372d8218838d8d5326f80cb76f/langgraph_prebuilt-1.0.7.tar.gz", hash = "sha256:38e097e06de810de4d0e028ffc0e432bb56d1fb417620fb1dfdc76c5e03e4bf9", size = 163692, upload-time = "2026-01-22T16:45:22.801Z" } sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/49/5e37abb3f38a17a3487634abc2a5da87c208cc1d14577eb8d7184b25c886/langgraph_prebuilt-1.0.7-py3-none-any.whl", hash = "sha256:e14923516504405bb5edc3977085bc9622c35476b50c1808544490e13871fe7c", size = 35324, upload-time = "2026-01-22T16:45:21.784Z" }, { url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" },
] ]
[[package]] [[package]]
@@ -2816,6 +2849,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281, upload-time = "2023-05-07T15:00:25.094Z" }, { url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281, upload-time = "2023-05-07T15:00:25.094Z" },
] ]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.10.0" version = "1.10.0"
@@ -2904,6 +2946,140 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" },
] ]
[[package]]
name = "nvidia-cublas-cu12"
version = "12.8.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
]
[[package]]
name = "nvidia-cuda-cupti-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
]
[[package]]
name = "nvidia-cuda-nvrtc-cu12"
version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
]
[[package]]
name = "nvidia-cuda-runtime-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
]
[[package]]
name = "nvidia-cudnn-cu12"
version = "9.10.2.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
]
[[package]]
name = "nvidia-cufft-cu12"
version = "11.3.3.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
]
[[package]]
name = "nvidia-cufile-cu12"
version = "1.13.1.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
]
[[package]]
name = "nvidia-curand-cu12"
version = "10.3.9.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
]
[[package]]
name = "nvidia-cusolver-cu12"
version = "11.7.3.90"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
]
[[package]]
name = "nvidia-cusparse-cu12"
version = "12.5.8.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
]
[[package]]
name = "nvidia-cusparselt-cu12"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
]
[[package]]
name = "nvidia-nccl-cu12"
version = "2.27.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" },
]
[[package]]
name = "nvidia-nvjitlink-cu12"
version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
]
[[package]]
name = "nvidia-nvshmem-cu12"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" },
]
[[package]]
name = "nvidia-nvtx-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
]
[[package]] [[package]]
name = "oauthlib" name = "oauthlib"
version = "3.3.1" version = "3.3.1"
@@ -3924,12 +4100,16 @@ name = "pylibseekdb"
version = "1.1.0" version = "1.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/b8/c226744a7a1da9295725920a36867ee5665f2617972c7881d5ed4cbd45c8/pylibseekdb-1.1.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0a0ad03d87f1db1a7087ba89e398ce1ee00496e977d38c493104d0d517590968", size = 148743770, upload-time = "2026-01-30T05:26:14.275Z" },
{ url = "https://files.pythonhosted.org/packages/51/4d/57151735afc29039f4ed680256012a33dd719ba3fd84d7c33a9bd260fc8a/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e272bee013aabab152c4795676b3b0ba1107a8058f29a07d2a803168faea090c", size = 147132528, upload-time = "2026-01-30T03:40:10.878Z" }, { url = "https://files.pythonhosted.org/packages/51/4d/57151735afc29039f4ed680256012a33dd719ba3fd84d7c33a9bd260fc8a/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e272bee013aabab152c4795676b3b0ba1107a8058f29a07d2a803168faea090c", size = 147132528, upload-time = "2026-01-30T03:40:10.878Z" },
{ url = "https://files.pythonhosted.org/packages/88/d7/5583fbf27e89952cda52bb9b1919229bd652d02aafac156758ac862c48e7/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:116a28356532705ed262e2a7951ac8221ae8c97ade866fdab2df521dcca62530", size = 170696822, upload-time = "2026-01-30T03:40:18.417Z" }, { url = "https://files.pythonhosted.org/packages/88/d7/5583fbf27e89952cda52bb9b1919229bd652d02aafac156758ac862c48e7/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:116a28356532705ed262e2a7951ac8221ae8c97ade866fdab2df521dcca62530", size = 170696822, upload-time = "2026-01-30T03:40:18.417Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2b/150592287119f80cff9b025d59879a561a0cca80e71cecbf74a41af6220b/pylibseekdb-1.1.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:d6ae33353e833cb56a7ce2cdb0305b872cdac9467eb79c277f82479c529b38ef", size = 148734111, upload-time = "2026-01-30T05:26:56.906Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a3/b55087293115ecbe22313b40533fd67b0192c36e6bedb05aa7058a83a86a/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9e2f8240b08a93e347d32534e7c394b7a151b67555a384eb88d73d4b0f8b9d14", size = 147137592, upload-time = "2026-01-30T03:40:26.087Z" }, { url = "https://files.pythonhosted.org/packages/b8/a3/b55087293115ecbe22313b40533fd67b0192c36e6bedb05aa7058a83a86a/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9e2f8240b08a93e347d32534e7c394b7a151b67555a384eb88d73d4b0f8b9d14", size = 147137592, upload-time = "2026-01-30T03:40:26.087Z" },
{ url = "https://files.pythonhosted.org/packages/04/31/c0979960d790621dec277f64b5d6c70932f8bb9adb59029d7b481cfe9c30/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d8615471bac39b1980951cbce0d742fa7bec676f28eb95f4db687fdd1e9c71b", size = 170681044, upload-time = "2026-01-30T03:40:34.276Z" }, { url = "https://files.pythonhosted.org/packages/04/31/c0979960d790621dec277f64b5d6c70932f8bb9adb59029d7b481cfe9c30/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d8615471bac39b1980951cbce0d742fa7bec676f28eb95f4db687fdd1e9c71b", size = 170681044, upload-time = "2026-01-30T03:40:34.276Z" },
{ url = "https://files.pythonhosted.org/packages/33/7d/8acbf3eca93905c1b13b015a9e02b426fc69c10e7c162be96b35a2b1c7a4/pylibseekdb-1.1.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d5688a0fe6fc703e5a707cbe0e139d570f1d34daff1491304d6b43154f2e12d9", size = 148743750, upload-time = "2026-01-30T05:27:39.832Z" },
{ url = "https://files.pythonhosted.org/packages/c8/24/7f510ad13ad129a691fa965dc5bce874320b682674cbf12fc2e35310719b/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e53d171246239bd526d1a1f9b3abef1ad9b10597bc1c0a2acf7e65afbd7d844", size = 147136041, upload-time = "2026-01-30T03:40:41.782Z" }, { url = "https://files.pythonhosted.org/packages/c8/24/7f510ad13ad129a691fa965dc5bce874320b682674cbf12fc2e35310719b/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e53d171246239bd526d1a1f9b3abef1ad9b10597bc1c0a2acf7e65afbd7d844", size = 147136041, upload-time = "2026-01-30T03:40:41.782Z" },
{ url = "https://files.pythonhosted.org/packages/ed/eb/c5988e1ad72233a920f4e444d8d866c42363220b340d78a7525307922f35/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:66d01ee9c0ad4a2e88ea2420f9c4d1ee9bb011b70c553a654c8a4e230e920ad7", size = 170684140, upload-time = "2026-01-30T03:40:49.351Z" }, { url = "https://files.pythonhosted.org/packages/ed/eb/c5988e1ad72233a920f4e444d8d866c42363220b340d78a7525307922f35/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:66d01ee9c0ad4a2e88ea2420f9c4d1ee9bb011b70c553a654c8a4e230e920ad7", size = 170684140, upload-time = "2026-01-30T03:40:49.351Z" },
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4a619c3a1b937fb080aa977b1d4011a1e587255707d54856188e5359a4c/pylibseekdb-1.1.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:11d2fbc98dcb8ec97257b949184dc09d9ba693811e77457bba9c8f80d282c265", size = 148745880, upload-time = "2026-01-30T05:38:26.631Z" },
{ url = "https://files.pythonhosted.org/packages/0c/94/534359608571d08825ac21e709aa680b559989c905f99e273d82d5b17db2/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ff05ac4bb13a4b5f9dd03771ded866beed72562ea497f68a4ae897c226afc446", size = 147132460, upload-time = "2026-01-30T03:40:56.684Z" }, { url = "https://files.pythonhosted.org/packages/0c/94/534359608571d08825ac21e709aa680b559989c905f99e273d82d5b17db2/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ff05ac4bb13a4b5f9dd03771ded866beed72562ea497f68a4ae897c226afc446", size = 147132460, upload-time = "2026-01-30T03:40:56.684Z" },
{ url = "https://files.pythonhosted.org/packages/19/5e/7588a06918ac145fb69e57ae372b72d6fc713b9263c29eb7268f8a4edbef/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:065158b79192cce7635995a7599e99b21a3ff729cd6f68e31a65ed62f830bd3a", size = 170677921, upload-time = "2026-01-30T03:41:03.783Z" }, { url = "https://files.pythonhosted.org/packages/19/5e/7588a06918ac145fb69e57ae372b72d6fc713b9263c29eb7268f8a4edbef/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:065158b79192cce7635995a7599e99b21a3ff729cd6f68e31a65ed62f830bd3a", size = 170677921, upload-time = "2026-01-30T03:41:03.783Z" },
] ]
@@ -4043,20 +4223,21 @@ wheels = [
[[package]] [[package]]
name = "pyseekdb" name = "pyseekdb"
version = "1.0.0b7" version = "1.1.0.post3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx", marker = "python_full_version < '3.14'" },
{ name = "numpy" }, { name = "numpy" },
{ name = "onnxruntime" }, { name = "onnxruntime", marker = "python_full_version < '3.14'" },
{ name = "pylibseekdb", marker = "sys_platform == 'linux'" }, { name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "pymysql" }, { name = "pymysql" },
{ name = "sentence-transformers", marker = "python_full_version >= '3.14'" },
{ name = "tenacity" }, { name = "tenacity" },
{ name = "tokenizers" }, { name = "tokenizers", marker = "python_full_version < '3.14'" },
{ name = "tqdm" }, { name = "tqdm", marker = "python_full_version < '3.14'" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/6a/a0d4728de90e028a60a3583e6e96579087f0cf793e705ea7898a1490541c/pyseekdb-1.0.0b7-py3-none-any.whl", hash = "sha256:e32920636c345bc73adf03040f9bcb1ecc420d652cedae1558999cce19a67d52", size = 60927, upload-time = "2025-12-29T13:19:04.669Z" }, { url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
] ]
[[package]] [[package]]
@@ -4636,6 +4817,168 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
] ]
[[package]]
name = "safetensors"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
{ url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
{ url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
{ url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
{ url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
{ url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
{ url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
{ url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
{ url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
]
[[package]]
name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
{ url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
{ url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
{ url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
{ url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
{ url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
{ url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
{ url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
{ url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
{ url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
{ url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
{ url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
{ url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
{ url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
{ url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
{ url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
{ url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
{ url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]
[[package]]
name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/9f/dba4b3e18ebbe1eaa29d9f1764fbc7da0cd91937b83f2b7928d15c5d2d36/sentence_transformers-5.2.3-py3-none-any.whl", hash = "sha256:6437c62d4112b615ddebda362dfc16a4308d604c5b68125ed586e3e95d5b2e30", size = 494225, upload-time = "2026-02-17T14:05:18.596Z" },
]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "80.10.2" version = "80.10.2"
@@ -4854,6 +5197,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" },
] ]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
]
[[package]] [[package]]
name = "tiktoken" name = "tiktoken"
version = "0.12.0" version = "0.12.0"
@@ -4988,6 +5340,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
] ]
[[package]]
name = "torch"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufft-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufile-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-curand-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusolver-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvtx-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" },
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
{ url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" },
{ url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" },
{ url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" },
{ url = "https://files.pythonhosted.org/packages/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" },
{ url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" },
{ url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
{ url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
{ url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
{ url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
{ url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
{ url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
{ url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" },
{ url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" },
{ url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" },
{ url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" },
{ url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" },
{ url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.2" version = "4.67.2"
@@ -5000,6 +5418,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" }, { url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" },
] ]
[[package]]
name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
]
[[package]]
name = "triton"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" },
{ url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" },
{ url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.21.1" version = "0.21.1"
@@ -5421,14 +5872,14 @@ wheels = [
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.5" version = "3.1.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
] ]
[[package]] [[package]]

View File

@@ -102,5 +102,10 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.31.1" "typescript-eslint": "^8.31.1"
}, },
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e" "packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e",
} "pnpm": {
"overrides": {
"minimatch": "3.1.3"
}
}
}

34
web/pnpm-lock.yaml generated
View File

@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
minimatch: 3.1.3
dependencies: dependencies:
'@dnd-kit/core': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
@@ -345,7 +348,7 @@ packages:
dependencies: dependencies:
'@eslint/object-schema': 2.1.7 '@eslint/object-schema': 2.1.7
debug: 4.4.3 debug: 4.4.3
minimatch: 3.1.2 minimatch: 3.1.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: true dev: true
@@ -375,7 +378,7 @@ packages:
ignore: 5.3.2 ignore: 5.3.2
import-fresh: 3.3.1 import-fresh: 3.3.1
js-yaml: 4.1.1 js-yaml: 4.1.1
minimatch: 3.1.2 minimatch: 3.1.3
strip-json-comments: 3.1.1 strip-json-comments: 3.1.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -2260,7 +2263,7 @@ packages:
'@typescript-eslint/types': 8.54.0 '@typescript-eslint/types': 8.54.0
'@typescript-eslint/visitor-keys': 8.54.0 '@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3 debug: 4.4.3
minimatch: 9.0.5 minimatch: 3.1.3
semver: 7.7.3 semver: 7.7.3
tinyglobby: 0.2.15 tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -2678,12 +2681,6 @@ packages:
concat-map: 0.0.1 concat-map: 0.0.1
dev: true dev: true
/brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
dependencies:
balanced-match: 1.0.2
dev: true
/braces@3.0.3: /braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3345,7 +3342,7 @@ packages:
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 3.1.2 minimatch: 3.1.3
object.fromentries: 2.0.8 object.fromentries: 2.0.8
object.groupby: 1.0.3 object.groupby: 1.0.3
object.values: 1.2.1 object.values: 1.2.1
@@ -3376,7 +3373,7 @@ packages:
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
language-tags: 1.0.9 language-tags: 1.0.9
minimatch: 3.1.2 minimatch: 3.1.3
object.fromentries: 2.0.8 object.fromentries: 2.0.8
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1 string.prototype.includes: 2.0.1
@@ -3428,7 +3425,7 @@ packages:
estraverse: 5.3.0 estraverse: 5.3.0
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
minimatch: 3.1.2 minimatch: 3.1.3
object.entries: 1.1.9 object.entries: 1.1.9
object.fromentries: 2.0.8 object.fromentries: 2.0.8
object.values: 1.2.1 object.values: 1.2.1
@@ -3498,7 +3495,7 @@ packages:
is-glob: 4.0.3 is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1 json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2 lodash.merge: 4.6.2
minimatch: 3.1.2 minimatch: 3.1.3
natural-compare: 1.4.0 natural-compare: 1.4.0
optionator: 0.9.4 optionator: 0.9.4
transitivePeerDependencies: transitivePeerDependencies:
@@ -5113,19 +5110,12 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
dev: true dev: true
/minimatch@3.1.2: /minimatch@3.1.3:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==}
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
dev: true dev: true
/minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
dependencies:
brace-expansion: 2.0.2
dev: true
/minimist@1.2.8: /minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: true dev: true

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
IChooseAdapterEntity, IChooseAdapterEntity,
IPipelineEntity, IPipelineEntity,
@@ -113,115 +113,73 @@ export default function BotForm({
const [dynamicFormConfigList, setDynamicFormConfigList] = useState< const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[] IDynamicFormItemSchema[]
>([]); >([]);
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
useState<IDynamicFormItemSchema[]>([]);
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 [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
const [copied, setCopied] = useState<boolean>(false); const [copied, setCopied] = useState<boolean>(false);
const [extraCopied, setExtraCopied] = 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');
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Serialize adapter_config to a stable string so it can be used as a // Derive the filtered config list via useMemo instead of useEffect+setState
// useEffect dependency without triggering on every render. form.watch() // to avoid creating new array references that would cause DynamicFormComponent
// returns a new object reference each time, which would otherwise cause // to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
// the filtering effect below to loop indefinitely. // Only depend on the specific field we care about (enable-webhook) rather than
const adapterConfigJson = JSON.stringify(currentAdapterConfig); // the entire currentAdapterConfig object, which changes on every emission.
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
const filteredDynamicFormConfigList = useMemo(() => {
if (currentAdapter === 'lark' && enableWebhook === false) {
// Hide encrypt-key field when webhook is disabled
return dynamicFormConfigList.filter(
(config) => config.name !== 'encrypt-key',
);
}
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
return dynamicFormConfigList;
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
// Filter dynamic form config list based on enable-webhook status for Lark adapter // 复制到剪贴板的辅助函数
useEffect(() => { const copyToClipboard = (
if (currentAdapter === 'lark') { text: string,
const enableWebhook = currentAdapterConfig?.['enable-webhook']; setStatus: React.Dispatch<React.SetStateAction<boolean>>,
if (enableWebhook === false) { ) => {
// Hide encrypt-key field when webhook is disabled if (navigator.clipboard && navigator.clipboard.writeText) {
setFilteredDynamicFormConfigList( navigator.clipboard
dynamicFormConfigList.filter( .writeText(text)
(config) => config.name !== 'encrypt-key', .then(() => {
), setStatus(true);
); setTimeout(() => setStatus(false), 2000);
} else { })
// Show all fields when webhook is enabled or undefined .catch(() => {
setFilteredDynamicFormConfigList(dynamicFormConfigList); // 降级创建临时textarea复制
} fallbackCopy(text, setStatus);
});
} else { } else {
// For non-Lark adapters, show all fields fallbackCopy(text, setStatus);
setFilteredDynamicFormConfigList(dynamicFormConfigList);
} }
}, [currentAdapter, adapterConfigJson, dynamicFormConfigList]); };
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 const fallbackCopy = (
const copyToClipboard = () => { text: string,
console.log('[Copy] Attempting to copy from input element'); setStatus: React.Dispatch<React.SetStateAction<boolean>>,
) => {
const inputElement = webhookInputRef.current; const textarea = document.createElement('textarea');
if (!inputElement) { textarea.value = text;
console.error('[Copy] Input element not found'); textarea.style.position = 'fixed';
return; textarea.style.opacity = '0';
} document.body.appendChild(textarea);
textarea.select();
try { const successful = document.execCommand('copy');
// 确保input元素可见且未被禁用 document.body.removeChild(textarea);
inputElement.disabled = false; if (successful) {
inputElement.readOnly = false; setStatus(true);
setTimeout(() => setStatus(false), 2000);
// 聚焦并选中所有文本
inputElement.focus();
inputElement.select();
// 尝试使用现代API
if (navigator.clipboard && navigator.clipboard.writeText) {
console.log(
'[Copy] Using Clipboard API with input value:',
inputElement.value,
);
navigator.clipboard
.writeText(inputElement.value)
.then(() => {
console.log('[Copy] Clipboard API success');
inputElement.blur(); // 取消选中
inputElement.readOnly = true;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error(
'[Copy] Clipboard API failed, trying execCommand:',
err,
);
// 降级到execCommand
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
});
} else {
// 直接使用execCommand
console.log(
'[Copy] Using execCommand with input value:',
inputElement.value,
);
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
} catch (err) {
console.error('[Copy] Copy failed:', err);
inputElement.readOnly = true;
} }
}; };
@@ -246,6 +204,7 @@ export default function BotForm({
} else { } else {
setWebhookUrl(''); setWebhookUrl('');
} }
setExtraWebhookUrl(val.extra_webhook_full_url || '');
}) })
.catch((err) => { .catch((err) => {
toast.error( toast.error(
@@ -255,6 +214,7 @@ export default function BotForm({
} else { } else {
form.reset(); form.reset();
setWebhookUrl(''); setWebhookUrl('');
setExtraWebhookUrl('');
} }
}); });
} }
@@ -327,14 +287,20 @@ export default function BotForm({
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap); setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
} }
async function getBotConfig( async function getBotConfig(botId: string): Promise<
botId: string, z.infer<typeof formSchema> & {
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> { webhook_full_url?: string;
extra_webhook_full_url?: string;
}
> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
httpClient httpClient
.getBot(botId) .getBot(botId)
.then((res) => { .then((res) => {
const bot = res.bot; const bot = res.bot;
const runtimeValues = bot.adapter_runtime_values as
| Record<string, unknown>
| undefined;
resolve({ resolve({
adapter: bot.adapter, adapter: bot.adapter,
description: bot.description, description: bot.description,
@@ -342,10 +308,12 @@ export default function BotForm({
adapter_config: bot.adapter_config, adapter_config: bot.adapter_config,
enable: bot.enable ?? true, enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '', use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
webhook_full_url: bot.adapter_runtime_values webhook_full_url: runtimeValues?.webhook_full_url as
? ((bot.adapter_runtime_values as Record<string, unknown>) | string
.webhook_full_url as string) | undefined,
: undefined, extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
| string
| undefined,
}); });
}) })
.catch((err) => { .catch((err) => {
@@ -536,13 +504,11 @@ export default function BotForm({
{/* Webhook 地址显示(统一 Webhook 模式) */} {/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl && {webhookUrl &&
(currentAdapter !== 'lark' || (currentAdapter !== 'lark' || enableWebhook !== false) && (
currentAdapterConfig?.['enable-webhook'] !== false) && (
<FormItem> <FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel> <FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
ref={webhookInputRef}
value={webhookUrl} value={webhookUrl}
readOnly readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900" className="flex-1 bg-gray-50 dark:bg-gray-900"
@@ -555,7 +521,7 @@ export default function BotForm({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={copyToClipboard} onClick={() => copyToClipboard(webhookUrl, setCopied)}
> >
{copied ? ( {copied ? (
<Check className="h-4 w-4 text-green-600 mr-2" /> <Check className="h-4 w-4 text-green-600 mr-2" />
@@ -565,8 +531,37 @@ export default function BotForm({
{t('common.copy')} {t('common.copy')}
</Button> </Button>
</div> </div>
{extraWebhookUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraWebhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(extraWebhookUrl, setExtraCopied)
}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{t('common.copy')}
</Button>
</div>
)}
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t('bots.webhookUrlHint')} {extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</p> </p>
</FormItem> </FormItem>
)} )}
@@ -673,7 +668,7 @@ export default function BotForm({
</div> </div>
<DynamicFormComponent <DynamicFormComponent
itemConfigList={filteredDynamicFormConfigList} itemConfigList={filteredDynamicFormConfigList}
initialValues={form.watch('adapter_config')} initialValues={currentAdapterConfig}
onSubmit={(values) => { onSubmit={(values) => {
form.setValue('adapter_config', values); form.setValue('adapter_config', values);
}} }}

View File

@@ -6,6 +6,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react';
import { import {
MessageChainComponent, MessageChainComponent,
Plain, Plain,
@@ -27,6 +28,7 @@ interface SessionInfo {
is_active: boolean; is_active: boolean;
platform?: string | null; platform?: string | null;
user_id?: string | null; user_id?: string | null;
user_name?: string | null;
} }
interface SessionMessage { interface SessionMessage {
@@ -60,8 +62,29 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const [messages, setMessages] = useState<SessionMessage[]>([]); const [messages, setMessages] = useState<SessionMessage[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false); const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_');
if (idx === -1) return null;
const type = sessionId.slice(0, idx);
if (type === 'person' || type === 'group') return type;
return null;
};
const abbreviateId = (id: string): string => {
if (id.length <= 10) return id;
return `${id.slice(0, 4)}..${id.slice(-4)}`;
};
const copyUserId = (userId: string) => {
navigator.clipboard.writeText(userId).then(() => {
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
});
};
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setLoadingSessions(true); setLoadingSessions(true);
try { try {
@@ -338,24 +361,36 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
> >
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2"> <span className="text-sm font-medium truncate mr-2">
{session.user_id || session.session_id.slice(0, 12)} {session.user_name ||
session.user_id ||
session.session_id.slice(0, 12)}
</span> </span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0"> <span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)} {formatRelativeTime(session.last_activity)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parseSessionType(session.session_id) && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{parseSessionType(session.session_id)}
</span>
)}
{session.platform && ( {session.platform && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]"> <span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{session.platform} {session.platform}
</span> </span>
)} )}
{session.user_id && (
<span className="truncate text-[10px]">
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && ( {session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400"> <span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /> <span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span> </span>
)} )}
<span>{session.pipeline_name}</span> <span className="truncate">{session.pipeline_name}</span>
</div> </div>
</button> </button>
); );
@@ -377,15 +412,42 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between"> <div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{selectedSession?.user_id || selectedSessionId.slice(0, 20)} {selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && ( {selectedSession?.platform && (
<span>{selectedSession.platform}</span> <>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)} )}
{selectedSession?.pipeline_name && ( {selectedSession?.pipeline_name && (
<> <>
{selectedSession?.platform && <span>·</span>} <span>·</span>
<span>{selectedSession.pipeline_name}</span> <span>{selectedSession.pipeline_name}</span>
</> </>
)} )}

View File

@@ -11,7 +11,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useCallback, useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -34,6 +34,35 @@ export default function DynamicFormComponent({
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
const { t } = useTranslation(); const { t } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: IDynamicFormItemSchema,
value: unknown,
): unknown => {
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return {
primary: typeof obj.primary === 'string' ? obj.primary : '',
fallbacks: Array.isArray(obj.fallbacks)
? (obj.fallbacks as unknown[]).filter(
(v): v is string => typeof v === 'string',
)
: [],
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
};
}
return value;
};
// 根据 itemConfigList 动态生成 zod schema // 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object( const formSchema = z.object(
itemConfigList.reduce( itemConfigList.reduce(
@@ -73,6 +102,12 @@ export default function DynamicFormComponent({
case 'bot-selector': case 'bot-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
break;
case 'prompt-editor': case 'prompt-editor':
fieldSchema = z.array( fieldSchema = z.array(
z.object({ z.object({
@@ -110,10 +145,10 @@ export default function DynamicFormComponent({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: itemConfigList.reduce((acc, item) => { defaultValues: itemConfigList.reduce((acc, item) => {
// 优先使用 initialValues如果没有则使用默认值 // 优先使用 initialValues如果没有则使用默认值
const value = initialValues?.[item.name] ?? item.default; const rawValue = initialValues?.[item.name] ?? item.default;
return { return {
...acc, ...acc,
[item.name]: value, [item.name]: normalizeFieldValue(item, rawValue),
}; };
}, {} as FormValues), }, {} as FormValues),
}); });
@@ -138,7 +173,8 @@ export default function DynamicFormComponent({
// 合并默认值和初始值 // 合并默认值和初始值
const mergedValues = itemConfigList.reduce( const mergedValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
acc[item.name] = initialValues[item.name] ?? item.default; const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
return acc; return acc;
}, },
{} as Record<string, object>, {} as Record<string, object>,
@@ -160,39 +196,44 @@ export default function DynamicFormComponent({
const onSubmitRef = useRef(onSubmit); const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit; onSubmitRef.current = onSubmit;
// Track the last emitted values to avoid emitting identical snapshots, // 监听表单值变化
// which would cause the parent to call setValue with an equivalent object, useEffect(() => {
// triggering a re-render loop. // Emit initial form values immediately so the parent always has a valid snapshot,
const lastEmittedRef = useRef<string>(''); // even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const emitValues = useCallback(() => {
const formValues = form.getValues(); const formValues = form.getValues();
const finalValues = itemConfigList.reduce( const initialFinalValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default; acc[item.name] = formValues[item.name] ?? item.default;
return acc; return acc;
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
const serialized = JSON.stringify(finalValues); onSubmitRef.current?.(initialFinalValues);
if (serialized !== lastEmittedRef.current) {
lastEmittedRef.current = serialized;
onSubmitRef.current?.(finalValues);
}
}, [form, itemConfigList]);
// 监听表单值变化 // Update previousInitialValues to the emitted snapshot so that if the
useEffect(() => { // parent writes these values back as new initialValues, the deep
// Emit initial form values immediately so the parent always has a valid snapshot, // comparison in the initialValues-sync useEffect won't detect a change
// even if the user saves without modifying any field. // and won't trigger an infinite update loop.
// form.watch(callback) only fires on subsequent changes, not on mount. previousInitialValues.current = initialFinalValues as Record<
emitValues(); string,
object
>;
const subscription = form.watch(() => { const subscription = form.watch(() => {
emitValues(); const formValues = form.getValues();
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, object>;
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, itemConfigList, emitValues]); }, [form, itemConfigList]);
return ( return (
<Form {...form}> <Form {...form}>
@@ -231,6 +272,7 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable) // All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing; const isFieldDisabled = !!isEditing;
return ( return (
<FormField <FormField
key={config.id} key={config.id}

View File

@@ -124,6 +124,28 @@ export default function DynamicFormItemComponent({
} }
}, [config.type]); }, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error('Failed to get LLM model list: ' + err.msg);
});
}
}, [config.type]);
useEffect(() => { useEffect(() => {
if ( if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR || config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
@@ -171,12 +193,7 @@ export default function DynamicFormItemComponent({
return <Textarea {...field} className="min-h-[120px]" />; return <Textarea {...field} className="min-h-[120px]" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return ( return <Switch checked={field.value} onCheckedChange={field.onChange} />;
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
);
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
@@ -227,7 +244,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT: case DynamicFormItemType.SELECT:
return ( return (
<Select value={field.value ?? ''} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
</SelectTrigger> </SelectTrigger>
@@ -318,6 +335,193 @@ export default function DynamicFormItemComponent({
</Select> </Select>
); );
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider
const groupedModelsForFallback = llmModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null &&
typeof rawModelValue === 'object' &&
!Array.isArray(rawModelValue)
? {
primary:
typeof (rawModelValue as Record<string, unknown>).primary ===
'string'
? ((rawModelValue as Record<string, unknown>)
.primary as string)
: '',
fallbacks: Array.isArray(
(rawModelValue as Record<string, unknown>).fallbacks,
)
? (
(rawModelValue as Record<string, unknown>)
.fallbacks as unknown[]
).filter((v): v is string => typeof v === 'string')
: [],
}
: {
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
fallbacks: [],
};
const renderModelSelect = (
value: string,
onChange: (val: string) => void,
placeholder: string,
) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModelsForFallback).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
);
const updateValue = (patch: Partial<typeof modelValue>) => {
field.onChange({ ...modelValue, ...patch });
};
const addFallbackModel = () => {
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
};
const updateFallbackModel = (index: number, value: string) => {
const updated = [...modelValue.fallbacks];
updated[index] = value;
updateValue({ fallbacks: updated });
};
const removeFallbackModel = (index: number) => {
const updated = [...modelValue.fallbacks];
updated.splice(index, 1);
updateValue({ fallbacks: updated });
};
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
const updated = [...modelValue.fallbacks];
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= updated.length) return;
[updated[index], updated[newIndex]] = [
updated[newIndex],
updated[index],
];
updateValue({ fallbacks: updated });
};
return (
<div className="space-y-3">
{/* Primary model selector */}
<div>
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
</div>
{/* Fallback models */}
{modelValue.fallbacks.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t('models.fallback.fallbackList')}
</p>
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}.
</span>
<div className="flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
t('models.selectModel'),
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => moveFallbackModel(index, 'up')}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => moveFallbackModel(index, 'down')}
disabled={index === modelValue.fallbacks.length - 1}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive"
onClick={() => removeFallbackModel(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Add fallback button */}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={addFallbackModel}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.fallback.addFallback')}
</Button>
</div>
);
}
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name // Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce( const kbsByEngine = knowledgeBases.reduce(

View File

@@ -422,12 +422,12 @@ export default function HomeSidebar({
const language = localStorage.getItem('langbot_language'); const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans' || language === 'zh-Hant') { if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open( window.open(
'https://docs.langbot.app/zh/insight/guide.html', 'https://docs.langbot.app/zh/insight/guide',
'_blank', '_blank',
); );
} else { } else {
window.open( window.open(
'https://docs.langbot.app/en/insight/guide.html', 'https://docs.langbot.app/en/insight/guide',
'_blank', '_blank',
); );
} }

View File

@@ -23,9 +23,9 @@ export const sidebarConfigList = [
route: '/home/bots', route: '/home/bots',
description: t('bots.description'), description: t('bots.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/usage/platforms/readme.html', en_US: 'https://docs.langbot.app/en/usage/platforms/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme.html', zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme.html', ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
}, },
}), }),
new SidebarChildVO({ new SidebarChildVO({
@@ -44,9 +44,9 @@ export const sidebarConfigList = [
route: '/home/pipelines', route: '/home/pipelines',
description: t('pipelines.description'), description: t('pipelines.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme.html', en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme.html', zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme.html', ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
}, },
}), }),
new SidebarChildVO({ new SidebarChildVO({
@@ -65,8 +65,8 @@ export const sidebarConfigList = [
route: '/home/monitoring', route: '/home/monitoring',
description: t('monitoring.description'), description: t('monitoring.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/features/monitoring.html', en_US: '',
zh_Hans: 'https://docs.langbot.app/zh/features/monitoring.html', zh_Hans: '',
}, },
}), }),
new SidebarChildVO({ new SidebarChildVO({
@@ -84,9 +84,9 @@ export const sidebarConfigList = [
route: '/home/knowledge', route: '/home/knowledge',
description: t('knowledge.description'), description: t('knowledge.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme.html', en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme.html', zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme.html', ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
}, },
}), }),
new SidebarChildVO({ new SidebarChildVO({
@@ -105,9 +105,9 @@ export const sidebarConfigList = [
route: '/home/plugins', route: '/home/plugins',
description: t('plugins.description'), description: t('plugins.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro.html', en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro.html', zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro.html', ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
}, },
}), }),
]; ];

View File

@@ -463,14 +463,16 @@ export default function ModelsDialog({
) )
: t('models.providerCount', { count: otherProviders.length })} : t('models.providerCount', { count: otherProviders.length })}
</span> </span>
<Button <div className="flex gap-2">
size="sm" <Button
variant="outline" size="sm"
onClick={handleCreateProvider} variant="outline"
> onClick={handleCreateProvider}
<Plus className="h-4 w-4 mr-1" /> >
{t('models.addProvider')} <Plus className="h-4 w-4 mr-1" />
</Button> {t('models.addProvider')}
</Button>
</div>
</div> </div>
{/* Provider List */} {/* Provider List */}

View File

@@ -36,11 +36,11 @@ export default function NewVersionDialog({
const getUpdateDocsUrl = () => { const getUpdateDocsUrl = () => {
const language = i18n.language; const language = i18n.language;
if (language === 'zh-Hans' || language === 'zh-Hant') { if (language === 'zh-Hans' || language === 'zh-Hant') {
return 'https://docs.langbot.app/zh/deploy/update.html'; return 'https://docs.langbot.app/zh/deploy/update';
} else if (language === 'ja-JP') { } else if (language === 'ja-JP') {
return 'https://docs.langbot.app/ja/deploy/update.html'; return 'https://docs.langbot.app/ja/deploy/update';
} else { } else {
return 'https://docs.langbot.app/en/deploy/update.html'; return 'https://docs.langbot.app/en/deploy/update';
} }
}; };

View File

@@ -22,6 +22,7 @@ import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api'; import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm'; import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc'; import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
@@ -68,7 +69,9 @@ export default function KBDetailDialog({
setKbInfo(resp.base); setKbInfo(resp.base);
} catch (e) { } catch (e) {
console.error('Failed to load KB info:', e); console.error('Failed to load KB info:', e);
toast.error(t('knowledge.loadKnowledgeBaseFailed')); toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
} }
} }
@@ -136,7 +139,9 @@ export default function KBDetailDialog({
onKbDeleted(); onKbDeleted();
} catch (e) { } catch (e) {
console.error('Failed to delete KB:', e); console.error('Failed to delete KB:', e);
toast.error(t('knowledge.deleteKnowledgeBaseFailed')); toast.error(
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
} finally { } finally {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
} }

View File

@@ -12,7 +12,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ParserInfo } from '@/app/infra/entities/api'; import { ParserInfo } from '@/app/infra/entities/api';
import { I18nObject } from '@/app/infra/entities/common'; import { CustomApiError, I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
interface FileUploadZoneProps { interface FileUploadZoneProps {
@@ -97,7 +97,9 @@ export default function FileUploadZone({
onUploadSuccess(); onUploadSuccess();
} catch (error) { } catch (error) {
console.error('File upload failed:', error); console.error('File upload failed:', error);
const errorMessage = t('knowledge.documentsTab.uploadError'); const errorMessage =
t('knowledge.documentsTab.uploadError') +
(error as CustomApiError).msg;
toast.error(errorMessage, { id: toastId }); toast.error(errorMessage, { id: toastId });
onUploadError(errorMessage); onUploadError(errorMessage);
} finally { } finally {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBaseFile } from '@/app/infra/entities/api'; import { KnowledgeBaseFile } from '@/app/infra/entities/api';
import { I18nObject } from '@/app/infra/entities/common'; import { I18nObject, CustomApiError } from '@/app/infra/entities/common';
import { columns, DocumentFile } from './documents/columns'; import { columns, DocumentFile } from './documents/columns';
import { DataTable } from './documents/data-table'; import { DataTable } from './documents/data-table';
import FileUploadZone from './FileUploadZone'; import FileUploadZone from './FileUploadZone';
@@ -87,7 +87,10 @@ export default function KBDoc({
}) })
.catch((error) => { .catch((error) => {
console.error('Delete failed:', error); console.error('Delete failed:', error);
toast.error(t('knowledge.documentsTab.fileDeleteFailed')); toast.error(
t('knowledge.documentsTab.fileDeleteFailed') +
(error as CustomApiError).msg,
);
}); });
}; };

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -23,6 +24,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api'; import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
@@ -217,7 +219,10 @@ export default function KBForm({
}) })
.catch((err) => { .catch((err) => {
console.error('update knowledge base failed', err); console.error('update knowledge base failed', err);
toast.error(t('knowledge.updateKnowledgeBaseFailed')); toast.error(
t('knowledge.updateKnowledgeBaseFailed') +
(err as CustomApiError).msg,
);
}); });
} else { } else {
// Create knowledge base // Create knowledge base
@@ -228,17 +233,26 @@ export default function KBForm({
}) })
.catch((err) => { .catch((err) => {
console.error('create knowledge base failed', err); console.error('create knowledge base failed', err);
toast.error(t('knowledge.createKnowledgeBaseFailed')); toast.error(
t('knowledge.createKnowledgeBaseFailed') +
(err as CustomApiError).msg,
);
}); });
} }
}; };
// Convert creation schema to dynamic form items (same as ExternalKBForm) // Convert creation schema to dynamic form items (same as ExternalKBForm)
const configFormItems = parseCreationSchema(selectedEngine?.creation_schema); // Memoize to avoid regenerating UUIDs on every render, which would cause
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
const configFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.creation_schema),
[selectedEngine?.creation_schema],
);
// Convert retrieval schema to dynamic form items // Convert retrieval schema to dynamic form items
const retrievalFormItems = parseCreationSchema( const retrievalFormItems = useMemo(
selectedEngine?.retrieval_schema, () => parseCreationSchema(selectedEngine?.retrieval_schema),
[selectedEngine?.retrieval_schema],
); );
// Show loading state // Show loading state
@@ -257,9 +271,12 @@ export default function KBForm({
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('knowledge.noEnginesAvailable')} {t('knowledge.noEnginesAvailable')}
</p> </p>
<p className="text-sm text-muted-foreground"> <Link
href="/home/plugins"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')} {t('knowledge.installEngineHint')}
</p> </Link>
</div> </div>
); );
} }

View File

@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
interface KBMigrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
internalKbCount: number;
externalKbCount: number;
onMigrationComplete: () => void;
}
export default function KBMigrationDialog({
open,
onOpenChange,
internalKbCount,
externalKbCount,
onMigrationComplete,
}: KBMigrationDialogProps) {
const { t } = useTranslation();
const [dismissing, setDismissing] = useState(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
toast.success(t('knowledge.migration.success'));
onOpenChange(false);
onMigrationComplete();
},
onError: (error) => {
toast.error(`${t('knowledge.migration.error')}${error}`);
},
});
const handleMigration = async (installPlugin: boolean) => {
try {
const resp = await httpClient.executeRagMigration(installPlugin);
asyncTask.startTask(resp.task_id);
} catch {
toast.error(t('knowledge.migration.error'));
}
};
const handleDismiss = async () => {
setDismissing(true);
try {
await httpClient.dismissRagMigration();
onOpenChange(false);
} catch {
toast.error(t('knowledge.migration.dismissError'));
} finally {
setDismissing(false);
}
};
const isRunning = asyncTask.status === AsyncTaskStatus.RUNNING;
const isError = asyncTask.status === AsyncTaskStatus.ERROR;
const totalCount = internalKbCount + externalKbCount;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!isRunning) onOpenChange(v);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('knowledge.migration.title')}</DialogTitle>
<DialogDescription>
{t('knowledge.migration.description')}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-3">
{!isRunning && !isError && (
<p className="text-sm text-muted-foreground">
{t('knowledge.migration.detected', {
total: totalCount,
internal: internalKbCount,
external: externalKbCount,
})}
</p>
)}
{isRunning && (
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<p className="text-sm">{t('knowledge.migration.running')}</p>
</div>
)}
{isError && (
<div className="space-y-2">
<p className="text-sm text-destructive">
{t('knowledge.migration.error')}
</p>
{asyncTask.error && (
<p className="text-xs text-muted-foreground bg-muted p-2 rounded">
{asyncTask.error}
</p>
)}
</div>
)}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
{!isRunning && !isError && (
<>
<Button onClick={() => handleMigration(true)} className="w-full">
{t('knowledge.migration.startWithInstall')}
</Button>
<Button
variant="outline"
onClick={() => handleMigration(false)}
className="w-full"
>
{t('knowledge.migration.startDataOnly')}
</Button>
<p className="text-xs text-muted-foreground text-center">
{t('knowledge.migration.dataOnlyHint')}
</p>
</>
)}
{isError && (
<Button onClick={() => handleMigration(true)} className="w-full">
{t('knowledge.migration.retry')}
</Button>
)}
{!isRunning && (
<Button
variant="ghost"
onClick={handleDismiss}
disabled={dismissing}
className="w-full text-destructive hover:text-destructive"
>
{t('knowledge.migration.dismiss')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RetrieveResult } from '@/app/infra/entities/api'; import { RetrieveResult } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface KBRetrieveGenericProps { interface KBRetrieveGenericProps {
@@ -41,7 +42,7 @@ export default function KBRetrieveGeneric({
setResults(response.results); setResults(response.results);
} catch (error) { } catch (error) {
console.error('Retrieve failed:', error); console.error('Retrieve failed:', error);
toast.error(t('knowledge.retrieveError')); toast.error(t('knowledge.retrieveError') + (error as CustomApiError).msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -5,7 +5,6 @@
.knowledgeListContainer { .knowledgeListContainer {
width: 100%; width: 100%;
margin-top: 2rem;
padding-left: 0.8rem; padding-left: 0.8rem;
padding-right: 0.8rem; padding-right: 0.8rem;
display: grid; display: grid;

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO'; import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard'; import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog'; import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api'; import { KnowledgeBase } from '@/app/infra/entities/api';
@@ -18,10 +19,29 @@ export default function KnowledgePage() {
const [selectedKbId, setSelectedKbId] = useState<string>(''); const [selectedKbId, setSelectedKbId] = useState<string>('');
const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// Migration dialog state
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
useEffect(() => { useEffect(() => {
getKnowledgeBaseList(); getKnowledgeBaseList();
checkMigrationStatus();
}, []); }, []);
async function checkMigrationStatus() {
try {
const resp = await httpClient.getRagMigrationStatus();
if (resp.needed) {
setMigrationInternalCount(resp.internal_kb_count);
setMigrationExternalCount(resp.external_kb_count);
setMigrationDialogOpen(true);
}
} catch {
// Silently ignore - migration check is non-critical
}
}
async function getKnowledgeBaseList() { async function getKnowledgeBaseList() {
const resp = await httpClient.getKnowledgeBases(); const resp = await httpClient.getKnowledgeBases();
@@ -85,8 +105,20 @@ export default function KnowledgePage() {
getKnowledgeBaseList(); getKnowledgeBaseList();
}; };
const handleMigrationComplete = () => {
getKnowledgeBaseList();
};
return ( return (
<div> <div>
<KBMigrationDialog
open={migrationDialogOpen}
onOpenChange={setMigrationDialogOpen}
internalKbCount={migrationInternalCount}
externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete}
/>
<KBDetailDialog <KBDetailDialog
open={detailDialogOpen} open={detailDialogOpen}
onOpenChange={setDetailDialogOpen} onOpenChange={setDetailDialogOpen}

View File

@@ -7,6 +7,7 @@ import {
EmbeddingCall, EmbeddingCall,
} from '../types/monitoring'; } from '../types/monitoring';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { parseUTCTimestamp } from '../utils/dateUtils';
/** /**
* Custom hook for fetching and managing monitoring data * Custom hook for fetching and managing monitoring data
@@ -120,7 +121,7 @@ export function useMonitoringData(filterState: FilterState) {
variables?: string; variables?: string;
}) => ({ }) => ({
id: msg.id, id: msg.id,
timestamp: new Date(msg.timestamp), timestamp: parseUTCTimestamp(msg.timestamp),
botId: msg.bot_id, botId: msg.bot_id,
botName: msg.bot_name, botName: msg.bot_name,
pipelineId: msg.pipeline_id, pipelineId: msg.pipeline_id,
@@ -154,7 +155,7 @@ export function useMonitoringData(filterState: FilterState) {
message_id?: string; message_id?: string;
}) => ({ }) => ({
id: call.id, id: call.id,
timestamp: new Date(call.timestamp), timestamp: parseUTCTimestamp(call.timestamp),
modelName: call.model_name, modelName: call.model_name,
tokens: { tokens: {
input: call.input_tokens, input: call.input_tokens,
@@ -190,7 +191,7 @@ export function useMonitoringData(filterState: FilterState) {
call_type?: string; call_type?: string;
}) => ({ }) => ({
id: call.id, id: call.id,
timestamp: new Date(call.timestamp), timestamp: parseUTCTimestamp(call.timestamp),
modelName: call.model_name, modelName: call.model_name,
promptTokens: call.prompt_tokens, promptTokens: call.prompt_tokens,
totalTokens: call.total_tokens, totalTokens: call.total_tokens,
@@ -227,10 +228,10 @@ export function useMonitoringData(filterState: FilterState) {
pipelineName: session.pipeline_name, pipelineName: session.pipeline_name,
messageCount: session.message_count, messageCount: session.message_count,
duration: duration:
new Date(session.last_activity).getTime() - parseUTCTimestamp(session.last_activity).getTime() -
new Date(session.start_time).getTime(), parseUTCTimestamp(session.start_time).getTime(),
lastActivity: new Date(session.last_activity), lastActivity: parseUTCTimestamp(session.last_activity),
startTime: new Date(session.start_time), startTime: parseUTCTimestamp(session.start_time),
platform: session.platform, platform: session.platform,
userId: session.user_id, userId: session.user_id,
}), }),
@@ -250,7 +251,7 @@ export function useMonitoringData(filterState: FilterState) {
message_id?: string; message_id?: string;
}) => ({ }) => ({
id: error.id, id: error.id,
timestamp: new Date(error.timestamp), timestamp: parseUTCTimestamp(error.timestamp),
errorType: error.error_type, errorType: error.error_type,
errorMessage: error.error_message, errorMessage: error.error_message,
botId: error.bot_id, botId: error.bot_id,

View File

@@ -97,3 +97,22 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
export function parseDate(dateStr: string): Date { export function parseDate(dateStr: string): Date {
return new Date(dateStr); return new Date(dateStr);
} }
/**
* Parse a UTC timestamp string from the backend into a Date object.
*
* The backend stores all monitoring timestamps in UTC but serializes them
* as naive ISO strings (e.g. "2026-03-25T14:30:00") without a timezone
* designator. JavaScript's `new Date()` would treat such strings as local
* time, causing the displayed time to be off by the user's UTC offset.
*
* This function appends 'Z' when the string has no timezone info, so that
* `new Date()` correctly interprets it as UTC.
*/
export function parseUTCTimestamp(timestamp: string): Date {
// If the string already contains timezone info ('Z', '+', or '-' offset), parse as-is
if (/Z|[+-]\d{2}:\d{2}$/.test(timestamp)) {
return new Date(timestamp);
}
return new Date(timestamp + 'Z');
}

View File

@@ -10,6 +10,7 @@ import { MessageContentRenderer } from '@/app/home/monitoring/components/Message
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring'; import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
interface PipelineMonitoringTabProps { interface PipelineMonitoringTabProps {
pipelineId: string; pipelineId: string;
@@ -120,7 +121,7 @@ export default function PipelineMonitoringTab({
message: result.message message: result.message
? { ? {
id: result.message.id, id: result.message.id,
timestamp: new Date(result.message.timestamp), timestamp: parseUTCTimestamp(result.message.timestamp),
botId: result.message.bot_id, botId: result.message.bot_id,
botName: result.message.bot_name, botName: result.message.bot_name,
pipelineId: result.message.pipeline_id, pipelineId: result.message.pipeline_id,
@@ -137,7 +138,7 @@ export default function PipelineMonitoringTab({
: undefined, : undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({ llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id, id: call.id,
timestamp: new Date(call.timestamp), timestamp: parseUTCTimestamp(call.timestamp),
modelName: call.model_name, modelName: call.model_name,
status: call.status, status: call.status,
duration: call.duration, duration: call.duration,
@@ -150,7 +151,7 @@ export default function PipelineMonitoringTab({
})), })),
errors: result.errors.map((error: RawErrorData) => ({ errors: result.errors.map((error: RawErrorData) => ({
id: error.id, id: error.id,
timestamp: new Date(error.timestamp), timestamp: parseUTCTimestamp(error.timestamp),
errorType: error.error_type, errorType: error.error_type,
errorMessage: error.error_message, errorMessage: error.error_message,
stackTrace: error.stack_trace, stackTrace: error.stack_trace,

View File

@@ -120,6 +120,8 @@ export default function PipelineFormComponent({
// Track unsaved changes by comparing current form values against a saved snapshot // Track unsaved changes by comparing current form values against a saved snapshot
const savedSnapshotRef = useRef<string>(''); const savedSnapshotRef = useRef<string>('');
// Track which dynamic form stages have completed their initial mount emission.
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch(); const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => { const hasUnsavedChanges = useMemo(() => {
if (!isEditMode || !savedSnapshotRef.current) return false; if (!isEditMode || !savedSnapshotRef.current) return false;
@@ -160,6 +162,7 @@ export default function PipelineFormComponent({
}; };
form.reset(loadedValues); form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear();
}); });
} }
}, []); }, []);
@@ -235,6 +238,33 @@ export default function PipelineFormComponent({
}); });
} }
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
function handleDynamicFormEmit(
formName: keyof FormValues,
stageName: string,
values: object,
) {
const stageKey = `${String(formName)}.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stageName]: values,
});
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
// Synchronously re-capture snapshot so that the useMemo comparison
// in the same render cycle still returns false.
savedSnapshotRef.current = JSON.stringify(form.getValues());
}
}
function renderDynamicForms( function renderDynamicForms(
stage: PipelineConfigStage, stage: PipelineConfigStage,
formName: keyof FormValues, formName: keyof FormValues,
@@ -264,13 +294,7 @@ export default function PipelineFormComponent({
{} {}
} }
onSubmit={(values) => { onSubmit={(values) => {
const currentValues = handleDynamicFormEmit(formName, stage.name, values);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}} }}
/> />
</div> </div>
@@ -302,13 +326,7 @@ export default function PipelineFormComponent({
{} {}
} }
onSubmit={(values) => { onSubmit={(values) => {
const currentValues = handleDynamicFormEmit(formName, stage.name, values);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}} }}
/> />
</div> </div>
@@ -333,13 +351,7 @@ export default function PipelineFormComponent({
(form.watch(formName) as Record<string, any>)?.[stage.name] || {} (form.watch(formName) as Record<string, any>)?.[stage.name] || {}
} }
onSubmit={(values) => { onSubmit={(values) => {
const currentValues = handleDynamicFormEmit(formName, stage.name, values);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
[stage.name]: values,
});
}} }}
/> />
</div> </div>

View File

@@ -38,12 +38,10 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => { (props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]); const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [modalOpen, setModalOpen] = useState<boolean>(false); const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>( const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null, null,
); );
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
const [showOperationModal, setShowOperationModal] = useState(false); const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>( const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE, PluginOperationType.DELETE,
@@ -166,12 +164,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
function handlePluginClick(plugin: PluginCardVO) { function handlePluginClick(plugin: PluginCardVO) {
setSelectedPlugin(plugin); setSelectedPlugin(plugin);
setModalOpen(true); setDetailModalOpen(true);
}
function handleViewReadme(plugin: PluginCardVO) {
setReadmePlugin(plugin);
setReadmeModalOpen(true);
} }
function handlePluginDelete(plugin: PluginCardVO) { function handlePluginDelete(plugin: PluginCardVO) {
@@ -355,52 +348,46 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</div> </div>
) : ( ) : (
<div className={`${styles.pluginListContainer}`}> <div className={`${styles.pluginListContainer}`}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col"> <DialogContent className="sm:max-w-[1100px] max-w-[95vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setModalOpen(false);
}}
/>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2 border-b"> <DialogHeader className="px-6 pt-6 pb-2 border-b">
<DialogTitle> <DialogTitle>
{readmePlugin && {selectedPlugin &&
`${readmePlugin.author}/${readmePlugin.name} - ${t( `${selectedPlugin.author}/${selectedPlugin.name}`}
'plugins.readme',
)}`}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 flex flex-row overflow-hidden min-h-0">
{readmePlugin && ( {/* Left side - Readme */}
<PluginReadme <div className="flex-1 overflow-y-auto border-r min-w-0">
pluginAuthor={readmePlugin.author} {selectedPlugin && (
pluginName={readmePlugin.name} <PluginReadme
/> pluginAuthor={selectedPlugin.author}
)} pluginName={selectedPlugin.name}
/>
)}
</div>
{/* Right side - Config */}
<div className="w-[380px] flex-shrink-0 overflow-y-auto px-4">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setDetailModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setDetailModalOpen(false);
}}
/>
)}
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -413,7 +400,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
onCardClick={() => handlePluginClick(vo)} onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)} onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)} onUpgradeClick={() => handlePluginUpdate(vo)}
onViewReadme={() => handleViewReadme(vo)}
/> />
</div> </div>
); );

View File

@@ -2,15 +2,7 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Settings,
FileText,
} from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -27,28 +19,20 @@ export default function PluginCardComponent({
onCardClick, onCardClick,
onDeleteClick, onDeleteClick,
onUpgradeClick, onUpgradeClick,
onViewReadme,
}: { }: {
cardVO: PluginCardVO; cardVO: PluginCardVO;
onCardClick: () => void; onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void; onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void; onUpgradeClick: (cardVO: PluginCardVO) => void;
onViewReadme: (cardVO: PluginCardVO) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
return ( return (
<> <>
<div <div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]" className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
onMouseEnter={() => setIsHovered(true)} onClick={() => onCardClick()}
onMouseLeave={() => {
if (!dropdownOpen) {
setIsHovered(false);
}
}}
> >
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]"> <div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{/* Icon - fixed width */} {/* Icon - fixed width */}
@@ -145,7 +129,10 @@ export default function PluginCardComponent({
</div> </div>
{/* Menu button - fixed width and position */} {/* Menu button - fixed width and position */}
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"> <div
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center"></div> <div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
@@ -153,9 +140,6 @@ export default function PluginCardComponent({
open={dropdownOpen} open={dropdownOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setDropdownOpen(open); setDropdownOpen(open);
if (!open) {
setIsHovered(false);
}
}} }}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -233,45 +217,6 @@ export default function PluginCardComponent({
</div> </div>
</div> </div>
</div> </div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={(e) => {
e.stopPropagation();
onViewReadme(cardVO);
}}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<FileText className="w-4 h-4" />
{t('plugins.readme')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onCardClick();
}}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<Settings className="w-4 h-4" />
{t('plugins.config')}
</Button>
</div>
</div> </div>
</> </>
); );

View File

@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
useState,
useEffect,
useCallback,
useRef,
Suspense,
useMemo,
} from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
@@ -70,9 +63,10 @@ function MarketPageContent({
RecommendationList[] RecommendationList[]
>([]); >([]);
const pageSize = 16; // 每页16个4行x4列 const pageSize = 12; // 每页12个
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const isComposingRef = useRef(false);
// 排序选项 // 排序选项
const sortOptions: SortOption[] = [ const sortOptions: SortOption[] = [
@@ -257,10 +251,14 @@ function MarketPageContent({
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
if (isComposingRef.current) {
return;
}
// 设置新的定时器 // 设置新的定时器
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
handleSearch(value); handleSearch(value);
}, 300); }, 500);
}, },
[handleSearch], [handleSearch],
); );
@@ -330,38 +328,7 @@ function MarketPageContent({
}; };
}, []); }, []);
// 计算所有推荐插件的 ID 集合 const visiblePlugins = plugins;
const recommendedPluginIds = useMemo(() => {
const ids = new Set<string>();
recommendationLists.forEach((list) => {
list.plugins.forEach((plugin) => {
ids.add(`${plugin.author} / ${plugin.name}`);
});
});
return ids;
}, [recommendationLists]);
// 过滤掉已在推荐列表中展示的插件
// 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤
// 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现
// 但是推荐列表只在第一页且无筛选时显示。
// 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。
const visiblePlugins = useMemo(() => {
const showRecommendations =
!searchQuery && componentFilter === 'all' && selectedTags.length === 0;
if (!showRecommendations) {
return plugins;
}
return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId));
}, [
plugins,
recommendedPluginIds,
searchQuery,
componentFilter,
selectedTags,
]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -436,6 +403,13 @@ function MarketPageContent({
placeholder={t('market.searchPlaceholder')} placeholder={t('market.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)} onChange={(e) => handleSearchInputChange(e.target.value)}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={(e) => {
isComposingRef.current = false;
handleSearchInputChange((e.target as HTMLInputElement).value);
}}
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
// Immediately search, clear debounce timer // Immediately search, clear debounce timer
@@ -562,8 +536,7 @@ function MarketPageContent({
{/* Recommendation Lists */} {/* Recommendation Lists */}
{!searchQuery && {!searchQuery &&
componentFilter === 'all' && componentFilter === 'all' &&
selectedTags.length === 0 && selectedTags.length === 0 && (
currentPage === 1 && (
<div className="pt-4"> <div className="pt-4">
<RecommendationLists <RecommendationLists
lists={recommendationLists} lists={recommendationLists}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react'; import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
@@ -18,7 +18,7 @@ export interface RecommendationList {
plugins: PluginV4[]; plugins: PluginV4[];
} }
const PAGE_SIZE = 4; // plugins per page in a recommendation row // Match the main plugin grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4
function pluginToVO( function pluginToVO(
plugin: PluginV4, plugin: PluginV4,
@@ -47,18 +47,53 @@ function RecommendationListRow({
list, list,
tagNames, tagNames,
onInstall, onInstall,
isLast,
}: { }: {
list: RecommendationList; list: RecommendationList;
tagNames: Record<string, string>; tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void; onInstall: (author: string, pluginName: string) => void;
isLast: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
const gridRef = useRef<HTMLDivElement>(null);
const plugins = list.plugins || []; const plugins = list.plugins || [];
const totalPages = Math.ceil(plugins.length / PAGE_SIZE);
const start = page * PAGE_SIZE; // Measure how many columns the CSS grid actually renders
const visiblePlugins = plugins.slice(start, start + PAGE_SIZE); const measureCols = useCallback(() => {
if (!gridRef.current) return;
const style = window.getComputedStyle(gridRef.current);
const cols = style.gridTemplateColumns.split(' ').length;
setPerPage(cols);
}, []);
useEffect(() => {
measureCols();
const observer = new ResizeObserver(measureCols);
if (gridRef.current) observer.observe(gridRef.current);
return () => observer.disconnect();
}, [measureCols]);
// Auto-advance every 5 seconds
useEffect(() => {
if (plugins.length <= perPage) return;
const timer = setInterval(() => {
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
}, 5000);
return () => clearInterval(timer);
}, [plugins.length, perPage]);
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
const safePage = Math.min(page, totalPages - 1);
if (safePage !== page) setPage(safePage);
const start = safePage * perPage;
const visiblePlugins = plugins.slice(start, start + perPage);
if (plugins.length === 0) return null; if (plugins.length === 0) return null;
@@ -77,19 +112,19 @@ function RecommendationListRow({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0} disabled={safePage === 0}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
<span className="text-xs text-muted-foreground px-1"> <span className="text-xs text-muted-foreground px-1">
{page + 1} / {totalPages} {safePage + 1} / {totalPages}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1} disabled={safePage >= totalPages - 1}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
@@ -97,7 +132,10 @@ function RecommendationListRow({
</div> </div>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"> <div
ref={gridRef}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
>
{visiblePlugins.map((plugin) => ( {visiblePlugins.map((plugin) => (
<PluginMarketCardComponent <PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name} key={plugin.author + ' / ' + plugin.name}
@@ -107,7 +145,9 @@ function RecommendationListRow({
/> />
))} ))}
</div> </div>
{totalPages > 1 && <div className="border-b border-border mt-6" />} {totalPages > 1 && !isLast && (
<div className="border-b border-border mt-6" />
)}
</div> </div>
); );
} }
@@ -125,12 +165,13 @@ export function RecommendationLists({
return ( return (
<div className="mt-6"> <div className="mt-6">
{lists.map((list) => ( {lists.map((list, index) => (
<RecommendationListRow <RecommendationListRow
key={list.uuid} key={list.uuid}
list={list} list={list}
tagNames={tagNames} tagNames={tagNames}
onInstall={onInstall} onInstall={onInstall}
isLast={index === lists.length - 1}
/> />
))} ))}
<div className="border-b border-border mb-6" /> <div className="border-b border-border mb-6" />

View File

@@ -1,6 +1,12 @@
import { PluginMarketCardVO } from './PluginMarketCardVO'; import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
Wrench, Wrench,
AudioWaveform, AudioWaveform,
@@ -9,8 +15,9 @@ import {
ExternalLink, ExternalLink,
Book, Book,
FileText, FileText,
Info,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({ export default function PluginMarketCardComponent({
@@ -24,6 +31,43 @@ export default function PluginMarketCardComponent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
const measure = () => {
const container = bottomRef.current;
if (!container) return;
const width = container.offsetWidth;
const availableForTags = width - 140 - 80;
if (availableForTags <= 0) {
setVisibleTags(0);
return;
}
const tagWidth = 80;
const plusBadgeWidth = 40;
const maxTags = Math.max(
0,
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
);
if (maxTags >= tags.length) {
setVisibleTags(tags.length);
} else {
setVisibleTags(Math.max(1, maxTags));
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(bottomRef.current);
return () => observer.disconnect();
}, [cardVO.tags]);
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) { function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();
@@ -46,6 +90,13 @@ export default function PluginMarketCardComponent({
Parser: <FileText className="w-4 h-4" />, Parser: <FileText className="w-4 h-4" />,
}; };
// Plugins that only contain KnowledgeRetriever components are deprecated
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
return ( return (
<div <div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative" className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
@@ -66,8 +117,34 @@ export default function PluginMarketCardComponent({
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full"> <div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId} {cardVO.pluginId}
</div> </div>
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full"> <div className="flex items-center gap-1.5 w-full min-w-0">
{cardVO.label} <div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e) => e.preventDefault()}
>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[240px] text-xs"
>
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
</div> </div>
@@ -95,10 +172,13 @@ export default function PluginMarketCardComponent({
</div> </div>
{/* 下部分:下载量、标签和组件列表 */} {/* 下部分:下载量、标签和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0"> <div
<div className="flex flex-row items-center justify-start gap-2 flex-wrap"> ref={bottomRef}
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */} {/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]"> <div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg <svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0" className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -116,14 +196,14 @@ export default function PluginMarketCardComponent({
</div> </div>
</div> </div>
{/* Tags */} {/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && ( {cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, 2).map((tag) => ( {cardVO.tags.slice(0, visibleTags).map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="secondary" variant="secondary"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0" className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0 whitespace-nowrap"
> >
<svg <svg
className="w-2.5 h-2.5 flex-shrink-0" className="w-2.5 h-2.5 flex-shrink-0"
@@ -138,15 +218,17 @@ export default function PluginMarketCardComponent({
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" /> <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" /> <line x1="7" y1="7" x2="7.01" y2="7" />
</svg> </svg>
<span className="truncate">{tagNames[tag] || tag}</span> <span className="truncate max-w-[5rem]">
{tagNames[tag] || tag}
</span>
</Badge> </Badge>
))} ))}
{cardVO.tags.length > 2 && ( {remainingTags > 0 && (
<Badge <Badge
variant="outline" variant="outline"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0" className="text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap"
> >
+{cardVO.tags.length - 2} +{remainingTags}
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -262,6 +262,12 @@ export interface ApiRespSystemInfo {
limitation: SystemLimitation; limitation: SystemLimitation;
} }
export interface RagMigrationStatusResp {
needed: boolean;
internal_kb_count: number;
external_kb_count: number;
}
export interface ApiRespPluginSystemStatus { export interface ApiRespPluginSystemStatus {
is_enable: boolean; is_enable: boolean;
is_connected: boolean; is_connected: boolean;

View File

@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
SELECT = 'select', SELECT = 'select',
LLM_MODEL_SELECTOR = 'llm-model-selector', LLM_MODEL_SELECTOR = 'llm-model-selector',
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector', EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
PROMPT_EDITOR = 'prompt-editor', PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown', UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector', KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',

View File

@@ -40,6 +40,7 @@ import {
ModelProvider, ModelProvider,
ApiRespKnowledgeEngines, ApiRespKnowledgeEngines,
ApiRespParsers, ApiRespParsers,
RagMigrationStatusResp,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; import { Plugin } from '@/app/infra/entities/plugin';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
@@ -355,6 +356,7 @@ export class BackendClient extends BaseHttpClient {
is_active: boolean; is_active: boolean;
platform: string | null; platform: string | null;
user_id: string | null; user_id: string | null;
user_name: string | null;
}>; }>;
total: number; total: number;
}> { }> {
@@ -383,6 +385,7 @@ export class BackendClient extends BaseHttpClient {
level: string; level: string;
platform: string | null; platform: string | null;
user_id: string | null; user_id: string | null;
user_name: string | null;
runner_name: string | null; runner_name: string | null;
variables: string | null; variables: string | null;
role: string | null; role: string | null;
@@ -710,6 +713,23 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/status/plugin-system'); return this.get('/api/v1/system/status/plugin-system');
} }
// ============ RAG Migration API ============
public getRagMigrationStatus(): Promise<RagMigrationStatusResp> {
return this.get('/api/v1/knowledge/migration/status');
}
public executeRagMigration(
installPlugin: boolean = true,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/knowledge/migration/execute', {
install_plugin: installPlugin,
});
}
public dismissRagMigration(): Promise<object> {
return this.post('/api/v1/knowledge/migration/dismiss');
}
public getPluginDebugInfo(): Promise<{ public getPluginDebugInfo(): Promise<{
debug_url: string; debug_url: string;
plugin_debug_key: string; plugin_debug_key: string;

View File

@@ -23,7 +23,7 @@ import {
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { httpClient, initializeUserInfo } from '@/app/infra/http'; import { httpClient, initializeUserInfo } from '@/app/infra/http';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Mail, Lock, Loader2 } from 'lucide-react'; import { Mail, Lock, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp'; import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -46,6 +46,8 @@ export default function Login() {
const [accountType, setAccountType] = useState<AccountType | null>(null); const [accountType, setAccountType] = useState<AccountType | null>(null);
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({ const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)), resolver: zodResolver(formSchema(t)),
@@ -61,6 +63,7 @@ export default function Login() {
async function checkAccountInfo() { async function checkAccountInfo() {
try { try {
setLoadError(null);
const res = await httpClient.getAccountInfo(); const res = await httpClient.getAccountInfo();
if (!res.initialized) { if (!res.initialized) {
router.push('/register'); router.push('/register');
@@ -72,11 +75,22 @@ export default function Login() {
// Also check if already logged in // Also check if already logged in
checkIfAlreadyLoggedIn(); checkIfAlreadyLoggedIn();
} catch { } catch (err) {
const errorMessage =
err instanceof Error ? err.message : t('common.loginLoadError');
setLoadError(errorMessage);
setLoading(false); setLoading(false);
} }
} }
async function handleRetry() {
setRetrying(true);
setLoading(true);
setLoadError(null);
await checkAccountInfo();
setRetrying(false);
}
function checkIfAlreadyLoggedIn() { function checkIfAlreadyLoggedIn() {
httpClient httpClient
.checkUserToken() .checkUserToken()
@@ -129,6 +143,54 @@ export default function Login() {
); );
} }
// Show error state when account info failed to load
if (loadError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="flex justify-between items-center mb-6">
<ThemeToggle />
<LanguageSelector />
</div>
<img
src={langbotIcon.src}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
<CardTitle className="text-2xl text-center">
{t('common.welcome')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col items-center gap-3 py-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-sm text-center text-muted-foreground">
{t('common.loginLoadErrorDesc')}
</p>
<code className="text-xs bg-muted px-3 py-2 rounded max-w-full overflow-x-auto block text-center text-muted-foreground">
{loadError}
</code>
<Button
onClick={handleRetry}
disabled={retrying}
variant="outline"
className="mt-2 cursor-pointer"
>
{retrying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t('common.retry')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Determine what to show based on account type // Determine what to show based on account type
const showLocalLogin = const showLocalLogin =
accountType === 'local' || (accountType === 'space' && hasPassword); accountType === 'local' || (accountType === 'space' && hasPassword);
@@ -284,6 +346,27 @@ export default function Login() {
</form> </form>
</Form> </Form>
)} )}
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
<a
href="https://langbot.app/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.privacyPolicy')}
</a>{' '}
{t('common.and')}{' '}
<a
href={t('common.dataCollectionPolicyUrl')}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.dataCollectionPolicy')}
</a>
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -253,6 +253,27 @@ export default function Register() {
</Button> </Button>
</form> </form>
</Form> </Form>
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
<a
href="https://langbot.app/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.privacyPolicy')}
</a>{' '}
{t('common.and')}{' '}
<a
href={t('common.dataCollectionPolicyUrl')}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
{t('common.dataCollectionPolicy')}
</a>
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -11,6 +11,10 @@ const enUS = {
continueToLogin: 'Login to continue', continueToLogin: 'Login to continue',
loginSuccess: 'Login successful', loginSuccess: 'Login successful',
loginFailed: 'Login failed, please check your email and password', loginFailed: 'Login failed, please check your email and password',
loginLoadError: 'Unable to connect to server',
loginLoadErrorDesc:
'Unable to connect to the LangBot backend. Please make sure the service is running and try again.',
retry: 'Retry',
enterEmail: 'Enter email address', enterEmail: 'Enter email address',
enterPassword: 'Enter password', enterPassword: 'Enter password',
invalidEmail: 'Please enter a valid email address', invalidEmail: 'Please enter a valid email address',
@@ -47,6 +51,12 @@ const enUS = {
copyFailed: 'Copy Failed', copyFailed: 'Copy Failed',
test: 'Test', test: 'Test',
forgotPassword: 'Forgot Password?', forgotPassword: 'Forgot Password?',
agreementNotice: 'By continuing, you agree to our',
privacyPolicy: 'Privacy Policy',
and: 'and',
dataCollectionPolicy: 'Data Collection Policy',
dataCollectionPolicyUrl:
'https://docs.langbot.app/en/insight/data-collection-policy',
loading: 'Loading...', loading: 'Loading...',
fieldRequired: 'This field is required', fieldRequired: 'This field is required',
or: 'or', or: 'or',
@@ -230,6 +240,11 @@ const enUS = {
modelsCount: '{{count}} model(s)', modelsCount: '{{count}} model(s)',
expandModels: 'Expand', expandModels: 'Expand',
collapseModels: 'Collapse', collapseModels: 'Collapse',
fallback: {
primary: 'Primary Model',
fallbackList: 'Fallback Models',
addFallback: 'Add Fallback Model',
},
}, },
bots: { bots: {
title: 'Bots', title: 'Bots',
@@ -273,6 +288,8 @@ const enUS = {
webhookUrlCopied: 'Webhook URL copied', webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint: webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button', 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
webhookUrlHintEither:
'Use either of the two URLs above in your platform configuration',
logLevel: 'Log Level', logLevel: 'Log Level',
allLevels: 'All Levels', allLevels: 'All Levels',
selectLevel: 'Select Level', selectLevel: 'Select Level',
@@ -483,6 +500,9 @@ const enUS = {
allComponents: 'All Components', allComponents: 'All Components',
requestPlugin: 'Request Plugin', requestPlugin: 'Request Plugin',
viewDetails: 'View Details', viewDetails: 'View Details',
deprecated: 'Deprecated',
deprecatedTooltip:
'Please install the corresponding Knowledge Engine plugin.',
tags: { tags: {
filterByTags: 'Filter by Tags', filterByTags: 'Filter by Tags',
selected: 'selected', selected: 'selected',
@@ -707,7 +727,7 @@ const enUS = {
cannotChangeEmbeddingModel: cannotChangeEmbeddingModel:
'Knowledge base created cannot be modified embedding model', 'Knowledge base created cannot be modified embedding model',
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully', updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
updateKnowledgeBaseFailed: 'Knowledge base update failed', updateKnowledgeBaseFailed: 'Knowledge base update failed: ',
documentsTab: { documentsTab: {
name: 'Name', name: 'Name',
status: 'Status', status: 'Status',
@@ -717,14 +737,14 @@ const enUS = {
supportedFormats: supportedFormats:
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats', 'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
uploadSuccess: 'File uploaded successfully!', uploadSuccess: 'File uploaded successfully!',
uploadError: 'File upload failed, please try again', uploadError: 'File upload failed: ',
uploadingFile: 'Uploading file...', uploadingFile: 'Uploading file...',
fileSizeExceeded: fileSizeExceeded:
'File size exceeds 10MB limit. Please split into smaller files.', 'File size exceeds 10MB limit. Please split into smaller files.',
actions: 'Actions', actions: 'Actions',
delete: 'Delete File', delete: 'Delete File',
fileDeleteSuccess: 'File deleted successfully', fileDeleteSuccess: 'File deleted successfully',
fileDeleteFailed: 'File deletion failed', fileDeleteFailed: 'File deletion failed: ',
processing: 'Processing', processing: 'Processing',
completed: 'Completed', completed: 'Completed',
failed: 'Failed', failed: 'Failed',
@@ -745,7 +765,7 @@ const enUS = {
content: 'Content', content: 'Content',
fileName: 'File Name', fileName: 'File Name',
noResults: 'No results', noResults: 'No results',
retrieveError: 'Retrieve failed', retrieveError: 'Retrieve failed: ',
unknownEngine: 'Unknown Engine', unknownEngine: 'Unknown Engine',
knowledgeEngine: 'Knowledge Engine', knowledgeEngine: 'Knowledge Engine',
knowledgeEngineRequired: 'Knowledge engine is required', knowledgeEngineRequired: 'Knowledge engine is required',
@@ -757,10 +777,10 @@ const enUS = {
engineSettingsReadonly: 'read-only in edit mode', engineSettingsReadonly: 'read-only in edit mode',
retrievalSettings: 'Retrieval Settings', retrievalSettings: 'Retrieval Settings',
noEnginesAvailable: 'No knowledge base engines available', noEnginesAvailable: 'No knowledge base engines available',
installEngineHint: 'Please install a knowledge base plugin first', installEngineHint: 'Please install a "Knowledge Engine" plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base', createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
loadKnowledgeBaseFailed: 'Failed to load knowledge base', loadKnowledgeBaseFailed: 'Failed to load knowledge base: ',
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base', deleteKnowledgeBaseFailed: 'Failed to delete knowledge base: ',
getKnowledgeBaseListError: 'Failed to get knowledge base list: ', getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
embeddingModel: 'Embedding Model', embeddingModel: 'Embedding Model',
embeddingModelRequired: 'Embedding model is required for this engine', embeddingModelRequired: 'Embedding model is required for this engine',
@@ -773,6 +793,23 @@ const enUS = {
retrieverConfiguration: 'Retriever Configuration', retrieverConfiguration: 'Retriever Configuration',
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from', retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
retrieverMarketLink: 'here', retrieverMarketLink: 'here',
migration: {
title: 'Knowledge Base Migration',
description:
'The new version has refactored the knowledge base into a plugin-based architecture, unifying built-in and external knowledge bases as "Knowledge Engine" plugins. Migration of legacy knowledge base data is required. Your old data has been automatically backed up in the database.',
detected:
'Found {{total}} knowledge base(s) to migrate ({{internal}} internal, {{external}} external).',
startWithInstall: 'Auto-install Plugin & Migrate',
startDataOnly: 'Migrate Data Only',
dataOnlyHint:
'"Migrate Data Only" is for offline/intranet environments. Please install the corresponding plugin manually after migration.',
dismiss: 'Discard Original Data',
running: 'Migrating knowledge bases, please wait...',
success: 'Knowledge base migration completed',
error: 'Knowledge base migration failed: ',
dismissError: 'Operation failed',
retry: 'Retry',
},
}, },
register: { register: {
title: 'Initialize LangBot 👋', title: 'Initialize LangBot 👋',

View File

@@ -1,4 +1,4 @@
const jaJP = { const jaJP = {
common: { common: {
login: 'ログイン', login: 'ログイン',
logout: 'ログアウト', logout: 'ログアウト',
@@ -12,6 +12,10 @@ const jaJP = {
loginSuccess: 'ログインに成功しました', loginSuccess: 'ログインに成功しました',
loginFailed: loginFailed:
'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください', 'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください',
loginLoadError: 'サーバーに接続できません',
loginLoadErrorDesc:
'LangBot バックエンドに接続できません。サービスが起動していることを確認してから再試行してください。',
retry: '再試行',
enterEmail: 'メールアドレスを入力', enterEmail: 'メールアドレスを入力',
enterPassword: 'パスワードを入力', enterPassword: 'パスワードを入力',
invalidEmail: '有効なメールアドレスを入力してください', invalidEmail: '有効なメールアドレスを入力してください',
@@ -48,6 +52,12 @@ const jaJP = {
copyFailed: 'コピーに失敗しました', copyFailed: 'コピーに失敗しました',
test: 'テスト', test: 'テスト',
forgotPassword: 'パスワードを忘れた?', forgotPassword: 'パスワードを忘れた?',
agreementNotice: '続行することで、以下に同意したものとみなされます:',
privacyPolicy: 'プライバシーポリシー',
and: 'および',
dataCollectionPolicy: 'データ収集ポリシー',
dataCollectionPolicyUrl:
'https://docs.langbot.app/ja/insight/data-collection-policy',
loading: '読み込み中...', loading: '読み込み中...',
fieldRequired: 'この項目は必須です', fieldRequired: 'この項目は必須です',
or: 'または', or: 'または',
@@ -235,6 +245,11 @@ const jaJP = {
modelsCount: '{{count}} 個のモデル', modelsCount: '{{count}} 個のモデル',
expandModels: '展開', expandModels: '展開',
collapseModels: '折りたたむ', collapseModels: '折りたたむ',
fallback: {
primary: 'プライマリモデル',
fallbackList: 'フォールバックモデル',
addFallback: 'フォールバックモデルを追加',
},
}, },
bots: { bots: {
title: 'ボット', title: 'ボット',
@@ -278,6 +293,8 @@ const jaJP = {
webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint: webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください', '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
webhookUrlHintEither:
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
logLevel: 'ログレベル', logLevel: 'ログレベル',
allLevels: 'すべてのレベル', allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択', selectLevel: 'レベルを選択',
@@ -491,6 +508,9 @@ const jaJP = {
noTags: 'タグがありません', noTags: 'タグがありません',
}, },
viewDetails: '詳細を表示', viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -709,7 +729,7 @@ const jaJP = {
cannotChangeEmbeddingModel: cannotChangeEmbeddingModel:
'知識ベース作成後は埋め込みモデルを変更できません', '知識ベース作成後は埋め込みモデルを変更できません',
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました', updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました', updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
documentsTab: { documentsTab: {
name: '名前', name: '名前',
status: 'ステータス', status: 'ステータス',
@@ -720,14 +740,14 @@ const jaJP = {
supportedFormats: supportedFormats:
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています', 'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
uploadSuccess: 'ファイルのアップロードに成功しました!', uploadSuccess: 'ファイルのアップロードに成功しました!',
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください', uploadError: 'ファイルのアップロードに失敗しました',
uploadingFile: 'ファイルをアップロード中...', uploadingFile: 'ファイルをアップロード中...',
fileSizeExceeded: fileSizeExceeded:
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。', 'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
actions: 'アクション', actions: 'アクション',
delete: 'ドキュメントを削除', delete: 'ドキュメントを削除',
fileDeleteSuccess: 'ドキュメントの削除に成功しました', fileDeleteSuccess: 'ドキュメントの削除に成功しました',
fileDeleteFailed: 'ドキュメントの削除に失敗しました', fileDeleteFailed: 'ドキュメントの削除に失敗しました',
processing: '処理中', processing: '処理中',
completed: '完了', completed: '完了',
failed: '失敗', failed: '失敗',
@@ -748,10 +768,13 @@ const jaJP = {
content: '内容', content: '内容',
fileName: 'ファイル名', fileName: 'ファイル名',
noResults: '検索結果がありません', noResults: '検索結果がありません',
retrieveError: '検索に失敗しました', retrieveError: '検索に失敗しました',
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください',
unknownEngine: '不明なエンジン', unknownEngine: '不明なエンジン',
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました', loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました', deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:', getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
addExternal: '外部ナレッジベースを追加', addExternal: '外部ナレッジベースを追加',
createExternalSuccess: '外部ナレッジベースが正常に作成されました', createExternalSuccess: '外部ナレッジベースが正常に作成されました',
@@ -762,6 +785,23 @@ const jaJP = {
retrieverConfiguration: '検索器設定', retrieverConfiguration: '検索器設定',
retrieverInstallInfo: 'ナレッジ検索器プラグインは', retrieverInstallInfo: 'ナレッジ検索器プラグインは',
retrieverMarketLink: 'こちらからインストールできます', retrieverMarketLink: 'こちらからインストールできます',
migration: {
title: 'ナレッジベースの移行',
description:
'新バージョンではナレッジベースをプラグインベースのアーキテクチャに再構築し、内蔵ナレッジベースと外部ナレッジベースを「ナレッジエンジン」プラグインとして統合しました。旧ナレッジベースデータの移行が必要です。旧データはデータベースに自動的にバックアップされています。',
detected:
'移行が必要なナレッジベースが{{total}}件見つかりました(内部{{internal}}件、外部{{external}}件)。',
startWithInstall: 'プラグインを自動インストールして移行',
startDataOnly: 'データのみ移行',
dataOnlyHint:
'「データのみ移行」はオフライン環境向けです。移行完了後に対応するプラグインを手動でインストールしてください。',
dismiss: '元データを破棄',
running: 'ナレッジベースを移行中です。しばらくお待ちください...',
success: 'ナレッジベースの移行が完了しました',
error: 'ナレッジベースの移行に失敗しました:',
dismissError: '操作に失敗しました',
retry: 'リトライ',
},
}, },
register: { register: {
title: 'LangBot を初期化 👋', title: 'LangBot を初期化 👋',

View File

@@ -11,6 +11,9 @@ const zhHans = {
continueToLogin: '登录以继续', continueToLogin: '登录以继续',
loginSuccess: '登录成功', loginSuccess: '登录成功',
loginFailed: '登录失败,请检查邮箱和密码是否正确', loginFailed: '登录失败,请检查邮箱和密码是否正确',
loginLoadError: '无法连接到服务器',
loginLoadErrorDesc: '无法连接到 LangBot 后端服务,请确认服务已启动后重试。',
retry: '重试',
enterEmail: '输入邮箱地址', enterEmail: '输入邮箱地址',
enterPassword: '输入密码', enterPassword: '输入密码',
invalidEmail: '请输入有效的邮箱地址', invalidEmail: '请输入有效的邮箱地址',
@@ -47,6 +50,12 @@ const zhHans = {
copyFailed: '复制失败', copyFailed: '复制失败',
test: '测试', test: '测试',
forgotPassword: '忘记密码?', forgotPassword: '忘记密码?',
agreementNotice: '继续即表示您同意我们的',
privacyPolicy: '隐私政策',
and: '和',
dataCollectionPolicy: '数据收集政策',
dataCollectionPolicyUrl:
'https://docs.langbot.app/zh/insight/data-collection-policy',
loading: '加载中...', loading: '加载中...',
fieldRequired: '此字段为必填项', fieldRequired: '此字段为必填项',
or: '或', or: '或',
@@ -221,6 +230,11 @@ const zhHans = {
modelsCount: '{{count}} 个模型', modelsCount: '{{count}} 个模型',
expandModels: '展开', expandModels: '展开',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '备用模型',
addFallback: '添加备用模型',
},
}, },
bots: { bots: {
title: '机器人', title: '机器人',
@@ -262,6 +276,7 @@ const zhHans = {
webhookUrlCopied: 'Webhook 地址已复制', webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint: webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮', '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
logLevel: '日志级别', logLevel: '日志级别',
allLevels: '全部级别', allLevels: '全部级别',
selectLevel: '选择级别', selectLevel: '选择级别',
@@ -468,6 +483,8 @@ const zhHans = {
noTags: '暂无标签', noTags: '暂无标签',
}, },
viewDetails: '查看详情', viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -679,7 +696,7 @@ const zhHans = {
updateTime: '更新于', updateTime: '更新于',
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型', cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知识库更新成功', updateKnowledgeBaseSuccess: '知识库更新成功',
updateKnowledgeBaseFailed: '知识库更新失败', updateKnowledgeBaseFailed: '知识库更新失败',
documentsTab: { documentsTab: {
name: '名称', name: '名称',
status: '状态', status: '状态',
@@ -688,13 +705,13 @@ const zhHans = {
uploading: '上传中...', uploading: '上传中...',
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式', supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
uploadSuccess: '文件上传成功!', uploadSuccess: '文件上传成功!',
uploadError: '文件上传失败,请重试', uploadError: '文件上传失败',
uploadingFile: '上传文件中...', uploadingFile: '上传文件中...',
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传', fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
actions: '操作', actions: '操作',
delete: '删除文件', delete: '删除文件',
fileDeleteSuccess: '文件删除成功', fileDeleteSuccess: '文件删除成功',
fileDeleteFailed: '文件删除失败', fileDeleteFailed: '文件删除失败',
processing: '处理中', processing: '处理中',
completed: '完成', completed: '完成',
failed: '失败', failed: '失败',
@@ -715,7 +732,7 @@ const zhHans = {
content: '内容', content: '内容',
fileName: '文件名', fileName: '文件名',
noResults: '暂无结果', noResults: '暂无结果',
retrieveError: '检索失败', retrieveError: '检索失败',
unknownEngine: '未知引擎', unknownEngine: '未知引擎',
knowledgeEngine: '知识引擎', knowledgeEngine: '知识引擎',
knowledgeEngineRequired: '知识引擎不能为空', knowledgeEngineRequired: '知识引擎不能为空',
@@ -726,10 +743,10 @@ const zhHans = {
engineSettingsReadonly: '编辑模式下不可修改', engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
noEnginesAvailable: '没有可用的知识库引擎', noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装知识插件', installEngineHint: '请先安装知识引擎」插件',
createKnowledgeBaseFailed: '知识库创建失败', createKnowledgeBaseFailed: '知识库创建失败',
loadKnowledgeBaseFailed: '知识库加载失败', loadKnowledgeBaseFailed: '知识库加载失败',
deleteKnowledgeBaseFailed: '知识库删除失败', deleteKnowledgeBaseFailed: '知识库删除失败',
getKnowledgeBaseListError: '获取知识库列表失败:', getKnowledgeBaseListError: '获取知识库列表失败:',
embeddingModel: '嵌入模型', embeddingModel: '嵌入模型',
embeddingModelRequired: '此引擎需要选择嵌入模型', embeddingModelRequired: '此引擎需要选择嵌入模型',
@@ -742,6 +759,23 @@ const zhHans = {
retrieverConfiguration: '检索器配置', retrieverConfiguration: '检索器配置',
retrieverInstallInfo: '您可以从', retrieverInstallInfo: '您可以从',
retrieverMarketLink: '此处安装知识检索器插件', retrieverMarketLink: '此处安装知识检索器插件',
migration: {
title: '知识库迁移',
description:
'新版本已将知识库重构为插件化架构,并统一内置知识库和外部知识库为「知识引擎」插件,需要对旧知识库数据进行迁移。您的旧数据已自动备份在数据库中。',
detected:
'共检测到 {{total}} 个知识库需要迁移({{internal}} 个内置知识库,{{external}} 个外部知识库)。',
startWithInstall: '自动安装插件并迁移',
startDataOnly: '仅迁移数据',
dataOnlyHint:
'「仅迁移数据」适合内网环境使用,请在迁移完成后自行安装对应插件',
dismiss: '丢弃原数据',
running: '正在迁移知识库,请稍候...',
success: '知识库迁移完成',
error: '知识库迁移失败:',
dismissError: '操作失败',
retry: '重试',
},
}, },
register: { register: {
title: '初始化 LangBot 👋', title: '初始化 LangBot 👋',

View File

@@ -11,6 +11,9 @@ const zhHant = {
continueToLogin: '登入以繼續', continueToLogin: '登入以繼續',
loginSuccess: '登入成功', loginSuccess: '登入成功',
loginFailed: '登入失敗,請檢查電子郵件和密碼是否正確', loginFailed: '登入失敗,請檢查電子郵件和密碼是否正確',
loginLoadError: '無法連線到伺服器',
loginLoadErrorDesc: '無法連線到 LangBot 後端服務,請確認服務已啟動後重試。',
retry: '重試',
enterEmail: '輸入電子郵件地址', enterEmail: '輸入電子郵件地址',
enterPassword: '輸入密碼', enterPassword: '輸入密碼',
invalidEmail: '請輸入有效的電子郵件地址', invalidEmail: '請輸入有效的電子郵件地址',
@@ -47,6 +50,12 @@ const zhHant = {
copyFailed: '複製失敗', copyFailed: '複製失敗',
test: '測試', test: '測試',
forgotPassword: '忘記密碼?', forgotPassword: '忘記密碼?',
agreementNotice: '繼續即表示您同意我們的',
privacyPolicy: '隱私政策',
and: '和',
dataCollectionPolicy: '數據收集政策',
dataCollectionPolicyUrl:
'https://docs.langbot.app/zh/insight/data-collection-policy',
loading: '載入中...', loading: '載入中...',
fieldRequired: '此欄位為必填', fieldRequired: '此欄位為必填',
or: '或', or: '或',
@@ -220,6 +229,11 @@ const zhHant = {
modelsCount: '{{count}} 個模型', modelsCount: '{{count}} 個模型',
expandModels: '展開', expandModels: '展開',
collapseModels: '收起', collapseModels: '收起',
fallback: {
primary: '主模型',
fallbackList: '備用模型',
addFallback: '新增備用模型',
},
}, },
bots: { bots: {
title: '機器人', title: '機器人',
@@ -261,6 +275,7 @@ const zhHant = {
webhookUrlCopied: 'Webhook 位址已複製', webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint: webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕', '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
logLevel: '日誌級別', logLevel: '日誌級別',
allLevels: '全部級別', allLevels: '全部級別',
selectLevel: '選擇級別', selectLevel: '選擇級別',
@@ -461,6 +476,8 @@ const zhHant = {
noTags: '暫無標籤', noTags: '暫無標籤',
}, },
viewDetails: '查看詳情', viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',
}, },
mcp: { mcp: {
title: 'MCP', title: 'MCP',
@@ -672,7 +689,7 @@ const zhHant = {
updateTime: '更新於', updateTime: '更新於',
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型', cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知識庫更新成功', updateKnowledgeBaseSuccess: '知識庫更新成功',
updateKnowledgeBaseFailed: '知識庫更新失敗', updateKnowledgeBaseFailed: '知識庫更新失敗',
documentsTab: { documentsTab: {
name: '名稱', name: '名稱',
status: '狀態', status: '狀態',
@@ -681,13 +698,13 @@ const zhHant = {
uploading: '上傳中...', uploading: '上傳中...',
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式', supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
uploadSuccess: '文檔上傳成功!', uploadSuccess: '文檔上傳成功!',
uploadError: '文檔上傳失敗,請重試', uploadError: '文檔上傳失敗',
uploadingFile: '上傳文檔中...', uploadingFile: '上傳文檔中...',
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳', fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
actions: '操作', actions: '操作',
delete: '刪除文檔', delete: '刪除文檔',
fileDeleteSuccess: '文檔刪除成功', fileDeleteSuccess: '文檔刪除成功',
fileDeleteFailed: '文檔刪除失敗', fileDeleteFailed: '文檔刪除失敗',
processing: '處理中', processing: '處理中',
completed: '完成', completed: '完成',
failed: '失敗', failed: '失敗',
@@ -708,10 +725,12 @@ const zhHant = {
content: '內容', content: '內容',
fileName: '文檔名稱', fileName: '文檔名稱',
noResults: '暫無結果', noResults: '暫無結果',
retrieveError: '檢索失敗', retrieveError: '檢索失敗',
noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件',
unknownEngine: '未知引擎', unknownEngine: '未知引擎',
loadKnowledgeBaseFailed: '知識庫載入失敗', loadKnowledgeBaseFailed: '知識庫載入失敗',
deleteKnowledgeBaseFailed: '知識庫刪除失敗', deleteKnowledgeBaseFailed: '知識庫刪除失敗',
getKnowledgeBaseListError: '取得知識庫列表失敗:', getKnowledgeBaseListError: '取得知識庫列表失敗:',
addExternal: '添加外部知識庫', addExternal: '添加外部知識庫',
createExternalSuccess: '外部知識庫創建成功', createExternalSuccess: '外部知識庫創建成功',
@@ -722,6 +741,23 @@ const zhHant = {
retrieverConfiguration: '檢索器配置', retrieverConfiguration: '檢索器配置',
retrieverInstallInfo: '您可以從', retrieverInstallInfo: '您可以從',
retrieverMarketLink: '此處安裝知識檢索器插件', retrieverMarketLink: '此處安裝知識檢索器插件',
migration: {
title: '知識庫遷移',
description:
'新版本已將知識庫重構為插件化架構,並統一內建知識庫和外部知識庫為「知識引擎」插件,需要對舊知識庫資料進行遷移。您的舊資料已自動備份在資料庫中。',
detected:
'共檢測到 {{total}} 個知識庫需要遷移({{internal}} 個內建知識庫,{{external}} 個外部知識庫)。',
startWithInstall: '自動安裝插件並遷移',
startDataOnly: '僅遷移資料',
dataOnlyHint:
'「僅遷移資料」適合內網環境使用,請在遷移完成後自行安裝對應插件',
dismiss: '丟棄原數據',
running: '正在遷移知識庫,請稍候...',
success: '知識庫遷移完成',
error: '知識庫遷移失敗:',
dismissError: '操作失敗',
retry: '重試',
},
}, },
register: { register: {
title: '初始化 LangBot 👋', title: '初始化 LangBot 👋',