Compare commits

..

46 Commits

Author SHA1 Message Date
6mvp6
6bb73297e0 feat(wecom): add user feedback support for WeChat Work AI Bot
This commit implements user feedback functionality (like/dislike) for
WeChat Work AI Bot conversations, including:

Backend changes:
- Add feedback_id and stream_id fields to WecomBotEvent
- Implement feedback event handling in WecomBotClient (api.py)
- Add StreamSessionManager._feedback_index for feedback_id lookup
- Add on_feedback decorator for custom feedback handlers
- Create MonitoringFeedback entity for database persistence
- Add dbm025 migration for monitoring_feedback table
- Implement FeedbackMonitor helper class
- Update all platform adapters with ap parameter support
- Update botmgr to pass bot_info for monitoring context

Frontend changes:
- Add FeedbackCard and FeedbackList components
- Add useFeedbackData hook for feedback data fetching
- Add feedback tab to monitoring page
- Add feedback types and interfaces
- Add i18n translations (zh-Hans, en-US)

Other changes:
- Update Dockerfile with Chinese mirror for faster builds
- Update docker-compose.yaml with network configuration
- Update .gitignore for docker data and backup files

Note: Known issues that need future improvement:
- feedback_type=3 (cancel) is recorded but not properly handled
- Duplicate feedback records are not deduplicated
2026-03-30 00:05:27 +08:00
Typer_Body
1c419e3591 Optimize the plugin system (#2090)
* Optimize the plugin system

* feat: enhance plugin installation process and improve task management

* fix: linter err

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-03-29 23:58:34 +08:00
Junyan Qin
b0a9be77b0 feat(web): move Quick Start to account menu and update i18n references 2026-03-29 00:49:02 +08:00
Junyan Qin
e02ade5a30 feat: add preset selection options and update translations for select preset 2026-03-29 00:32:26 +08:00
Junyan Qin
1a51ba8e7e fix(market): add request plugin CTA to empty search results 2026-03-28 22:16:23 +08:00
Junyan Qin
e7b22d6ebf fix: i18n issues 2026-03-28 20:55:43 +08:00
Junyan Qin
dddfa8ac79 chore: add more language supports 2026-03-28 20:48:36 +08:00
Junyan Qin
99e2976826 feat(i18n): add zh_Hant and ja_JP translations to all adapter YAML files
- Add zh_Hant (Traditional Chinese) to all 17 adapter YAML metadata and config fields
- Add ja_JP translations to global adapters (Telegram, Discord, Slack, Lark, LINE)
- Fix buggy zh_Hant in line.yaml and slack.yaml (contained simplified Chinese)
- Add zh_Hant field to backend I18nString model
- Add adapter category grouping with locale-aware ordering
- Add webhook Cloud CTA for community edition users
- Fix wizard progress not clearing on skip/complete
2026-03-28 19:41:27 +08:00
Junyan Chin
71e44f0e54 Feat/space cta optimization (#2089)
* feat(wizard): persist wizard progress to backend for session resumption

Store wizard step, selected adapter, created bot UUID, and runner
selection in the metadata table. On revisit, the wizard restores
progress and verifies the bot still exists. Progress is cleared
automatically when the wizard is completed or skipped.

* feat(dynamic-form): optimize LLM model selection with space login CTA and improve localization strings

* feat(web): add LangBot Cloud CTA for webhook URL fields in community edition

Show a subtle hint below webhook URL fields prompting users about
LangBot Cloud's public endpoint, only visible in community edition.
Covers all 8 webhook-based adapters with i18n support (4 locales).
2026-03-28 17:24:39 +08:00
Junyan Chin
4c904c2375 Fix/frontend optimizations (#2088)
* fix(web): auto-redirect to wizard on first visit and change sidebar icons to blue

* refactor(wizard): use backend metadata table instead of localStorage for wizard completion state

- Add wizard_completed field to system info API (read from metadata table)
- Add POST /api/v1/system/wizard/completed endpoint to mark wizard done
- Frontend home layout checks systemInfo.wizard_completed for auto-redirect
- Wizard calls markWizardCompleted API on skip/finish
- Ensures consistent behavior across all browsers on the same instance

* fix(wizard): update systemInfo in memory before navigation to prevent redirect loop

* fix(monitoring): prevent horizontal overflow and unify empty state styles

* fix(wizard): use Object.assign for systemInfo and await wizard completion API

- Replace systemInfo reassignment with Object.assign in all 3 locations
  to preserve object identity across module imports
- Await markWizardCompleted() POST in wizard skip/finish handlers
  instead of fire-and-forget to ensure backend persistence
- Always re-fetch systemInfo in home layout to get latest
  wizard_completed state from backend

* fix(wizard): prevent redirect loop by blocking navigation on failed status save

- Refactor wizard_completed (boolean) to wizard_status (string: none/skipped/completed)
- Remove ALL localStorage usage from wizard page (form state persistence)
- Replace AlertDialogAction with Button so skip dialog stays open during POST
- Add loading spinners for skip and complete actions
- If POST fails, show error toast and keep dialog/button active for retry
- If POST succeeds, update in-memory state and navigate

* fix(wizard): fix row[0].value bug causing GET /info to always return wizard_status=none

conn.execute(select(Entity)) returns Row with raw column values, not ORM
entities. row[0] is the key column (a string), so row[0].value raises
AttributeError which was silently swallowed by except-pass, making the
GET endpoint always return wizard_status=none regardless of DB state.

* fix(wizard): replace AlertDialog with Dialog for skip confirmation to remove slide animation

* chore: optimize toast in wizard

* fix(wizard): set default token value for Telegram adapter and initialize adapter config in wizard

* feat(web): move webhook URL to dynamic form system, add market category filter, fix layout overflow

- Add 'webhook-url' dynamic form field type rendered as read-only input
  with copy button, defined in adapter YAML specs instead of hardcoded
  in BotForm. Supports show_if conditions for optional-webhook adapters.
- Remove hardcoded webhook display logic from BotForm.tsx, pass webhook
  URLs via systemContext to DynamicFormComponent.
- Fetch webhook URLs after bot creation in wizard and pass to Step 1.
- Support ?category= query param on /home/market page for filtering by
  component type (mirrors langbot-space behavior).
- Link 'install knowledge engine' hint to /home/market?category=KnowledgeEngine.
- Fix SidebarInset missing min-w-0 causing content overflow when sidebar
  is expanded.
- Add vertical divider between plugin detail config and readme panels.
- Fix infinite re-render loop in DynamicFormComponent by memoizing
  editableItems array.

* fix: lint

* fix(web): change systemInfo to const to satisfy prefer-const lint rule

* fix: update adapter descriptions for clarity and usage requirements
2026-03-28 15:50:32 +08:00
fdc310
498d030da9 Fix/weconbot image and file (#2085)
* fix:wecombot file and image

* fix: add enable-stream-reply config
2026-03-28 01:24:54 +08:00
Junyan Chin
c111bf1714 Feat/onboarding wizard (#2086)
* feat(web): add onboarding wizard for guided bot creation

Implement a full-screen 4-step wizard at /wizard that guides users
through selecting a platform, configuring a bot, choosing an AI engine,
and completing setup. The wizard uses DynamicFormComponent for adapter
and pipeline configuration, embeds BotLogListComponent for real-time
debugging, persists state to localStorage, and integrates with Space
OAuth flow. Also fixes a prompt-editor crash in DynamicFormComponent
when value is undefined.

* feat(wizard): redesign step 0/1 flow, add skip dialog, auto-expand log images

- Step 0: Remove bot name/description fields; auto-derive name from adapter
  label; create disabled bot on confirm; advance to Step 1 automatically
- Step 1: Replace 'Create Bot' with 'Save & Enable Bot'; update adapter
  config and enable bot; disable form fields after saving
- Add skip confirmation AlertDialog with i18n message
- Add LanguageSelector to wizard header
- Move wizard sidebar entry to last position to prevent fallback redirect loop
- Add defaultExpanded prop to BotLogCard; auto-expand entries with images
  in wizard via autoExpandImages prop on BotLogListComponent
- Remove automatic default pipeline creation (write_default_pipeline) from
  backend persistence manager since the wizard now handles pipeline creation
- Update all 4 locale files (en-US, zh-Hans, zh-Hant, ja-JP)

* fix(wizard): hide detailed logs link in wizard, allow re-editing bot config after save

- Add hideDetailedLogsLink prop to BotLogListComponent; pass it in wizard
- Remove isEditing on DynamicFormComponent so form stays editable after save
- Always show save button; label changes to 'Re-save' after first save
- Add resaveBot i18n key to all 4 locale files

* style(wizard): move save button into config card header

* fix(wizard): initialize userInfo/systemInfo so model selector works

The wizard runs outside /home layout, so userInfo was null. This caused
the model-fallback-selector to filter out all Space models, showing an
empty dropdown. Fix by calling initializeUserInfo() and
initializeSystemInfo() before fetching wizard data.

Also:
- Hide log toolbar in wizard via hideToolbar prop on BotLogListComponent
- Add empty state message for bot logs (noLogs i18n key, all 4 locales)

* feat(wizard): redesign AI Engine step with left-right split layout

Before selecting a runner: centered grid of runner cards.
After selecting: left panel shows compact runner list for switching,
right panel shows runner config form with slide-in animations.

Also fix prompt field default: add default value to prompt-editor field
in ai.yaml metadata so the prompt is pre-populated with
'You are a helpful assistant.' instead of being empty.

* feat(pipeline): add default values to ai.yaml runner configs and show_if for n8n auth fields

- Sync default values from default-pipeline-config.json to all runner
  config fields in ai.yaml so wizard forms are pre-populated
- Add show_if conditions to n8n-service-api auth fields so only the
  relevant credentials appear based on selected auth-type
- Fix prompt-editor crash in DynamicFormItemComponent when field.value
  is undefined (Array.isArray guard + fallback)
- Improve wizard Step 2 split layout with fixed column widths,
  independent scroll, ring clipping fix, and mobile responsiveness
- Use key={selected} on DynamicFormComponent to force remount on
  runner switch
- Improve pipeline creation flow: create → fetch defaults → merge AI
  section → update (preserves trigger/safety/output defaults)

* feat(dynamic-form): add systemContext prop with __system.* namespace for show_if conditions

- Add systemContext prop to DynamicFormComponent for injecting external
  variables accessible via __system.* prefix in show_if conditions
- Extract resolveShowIfValue() helper for cleaner field resolution
- Pass { is_wizard: true } from wizard to hide knowledge-bases field
- Remove bot config save toast in wizard (keep inline indicator)

* feat(sidebar): render wizard as standalone item before Home group with fallback redirect fix

* fix(wizard): remove unused setBotDescription to fix lint error
2026-03-28 00:46:22 +08:00
Junyan Qin
6570f276d2 feat(web): add plugin install dropdown to sidebar with context-based action dispatch
Add '+' dropdown menu to plugins sidebar category with three install
options: marketplace, upload local, and install from GitHub. Use shared
React context (pendingPluginInstallAction) instead of URL params to
reliably trigger install actions across components. Add e.stopPropagation
on all DropdownMenuItem handlers to prevent React portal event bubbling
from triggering parent SidebarMenuButton navigation.
2026-03-27 20:39:26 +08:00
Junyan Qin
42e1e038bd feat(web): add test functionality to MCPForm and integrate with MCPDetailContent 2026-03-27 20:09:15 +08:00
Junyan Qin
d0e54a45c7 fix(web): show correct MCP server runtime status in sidebar dots
Use runtime_info.status from the API instead of only checking the enable
flag. Dots now show: green (connected), yellow (connecting), red (error),
gray (disabled or no status).
2026-03-27 20:02:16 +08:00
Junyan Qin
23fa47b07e feat(web): refactor MCP servers as sidebar entities and improve sidebar footer
- Refactor MCP servers to be managed as collapsible sidebar sub-items with
  ?id= detail routing and inline form (matching bots/pipelines pattern)
- Add MCPDetailContent with create/edit modes, enable toggle, and danger zone
- Extract MCPForm as standalone inline form from MCPFormDialog
- Move API Integration to standalone sidebar footer button
- Add GitHub star CTA with live star count badge in user dropdown menu
- Add MCP server status dot indicators in sidebar (green/gray for enabled/disabled)
- Add i18n keys for MCP detail page and GitHub star CTA in all 4 locales
2026-03-27 19:59:34 +08:00
Junyan Qin
4902c1d3b2 fix(web): only show ws connection status on active debug tab 2026-03-27 19:16:27 +08:00
Junyan Qin
a6f96e5209 fix(web): improve mobile responsiveness for marketplace, plugin detail, session monitor, and pipeline form 2026-03-27 19:02:24 +08:00
Junyan Qin
37c41bcfe4 feat(web): add popover flyout for collapsed sidebar entity categories 2026-03-27 18:53:17 +08:00
Junyan Qin
9e223949a7 fix(web): refresh sidebar and navigate away after pipeline deletion
The onDeletePipeline callback was a no-op, causing the sidebar to
remain stale and the content area to stay on the deleted pipeline.
Now calls refreshPipelines() and navigates to /home/pipelines,
consistent with bot and knowledge base deletion behavior.
2026-03-27 18:28:34 +08:00
Junyan Qin
267bd72c63 fix(web): resolve zodResolver type mismatch for optional description fields
Remove .default('') from zod schemas to align input/output types,
preventing type conflict between zodResolver and useForm in
@hookform/resolvers v5. Use nullish coalescing at entity assignment
sites to ensure string type safety.
2026-03-27 18:10:30 +08:00
Junyan Qin
af0d00e5e9 refactor(web): make description optional and remove default values for bot, pipeline, and knowledge base
- Remove .min(1) validation on description field, replace with .optional().default('')
- Remove pre-filled default description text from all three create forms
- Remove required asterisk (*) marker from description labels
- No backend changes needed: Bot/Pipeline DB accepts empty string, KB DB allows null
2026-03-27 18:00:48 +08:00
Junyan Qin
244e16c491 perf: ui 2026-03-27 17:22:24 +08:00
Junyan Qin
cad259fe39 refactor(web): simplify sidebar visual design
- Remove vertical guide lines from collapsible sub-items (border-l)
- Move create button from list bottom to category header row as a hover-revealed + icon
- Remove active background highlight from category headers; only child entities show active state
- Remove unused CREATE_I18N_KEYS constant
2026-03-27 15:00:17 +08:00
Junyan Qin
bc3199bf29 feat(web): add icons/emoji to selectors, sync bot enable status and plugin list in sidebar
- Bot adapter selector: show adapter icon in trigger and dropdown items
- Knowledge engine selector: show plugin icon derived from plugin_id
- Pipeline binding selector: show pipeline emoji in trigger and dropdown items
- Knowledge base selectors (single/multi): show KB emoji in all views
- Sidebar bot entries: show green/gray status dot on adapter icon for enable/disable state
- Sidebar plugin list: sync after install/uninstall from all entry points (PluginInstalledComponent, plugins page, marketplace page)
- Pipeline form: add cursor-pointer to left-side tab list buttons
- Clean up unused onBotDeleted prop from BotForm
2026-03-27 14:51:15 +08:00
Junyan Qin
127dc455c3 refactor(web): redesign bot config page with card-based layout and dirty-aware save button
- Restructure bot edit page from flat form to card-based layout (Basic Info, Pipeline Binding, Adapter Config, Danger Zone)
- Move enable switch and save button to sticky header for quick access
- Move webhook URL display into adapter config card (contextually related)
- Remove redundant adapter icon card; show description as FormDescription
- Add dedicated Danger Zone card with red border for delete action
- Remove duplicate delete dialog from BotForm (single source in BotDetailContent)
- Implement form dirty tracking: save button is disabled until user modifies content
- Add i18n keys for new card titles/descriptions across all 4 locales
2026-03-27 12:29:18 +08:00
Junyan Qin
e8dc6fde53 feat: autoclean monitoring events 2026-03-27 11:57:24 +08:00
Junyan Chin
4a97895dea Feat/shadcn sidebar and page views (#2084)
* feat(web): migrate sidebar to shadcn and convert entity editors to page views

* feat(web): enhance sidebar with sections, collapsible persistence, sub-item sorting/limiting, and UI polish

- Reorganize sidebar into Home and Extensions sections with collapsible groups
- Split plugins page into plugins, market, and mcp as separate routes
- Add sidebar sub-items sorted by updatedAt with max 5 visible and expand/collapse toggle
- Persist collapsible section state and sidebar open state in localStorage
- Fix page refresh stripping query params by splitting handleChildClick/selectChild
- Swap plugin detail layout (config left, readme right)
- Add fixed headers with internal scroll for all detail and list pages
- Remove entity form borders and sidebar rail
- Improve dark mode sidebar/content contrast
- Rename monitoring to Dashboard, move to first position
- Update breadcrumb to show Home or Extensions based on current route
- Add i18n translations for more/less toggle in all 4 locales

* fix(web): fix scroll behavior - constrain layout to viewport, fix fixed headers and independent scroll areas

- Change SidebarProvider wrapper from min-h-svh to h-svh overflow-hidden to constrain layout to viewport height (root cause of all scroll issues)
- Fix create mode pages (bot, pipeline, knowledge): extract title bar out of scroll container so only form content scrolls
- Fix plugin detail: add overflow-x-hidden on both config and readme panels to prevent horizontal overflow
- Add min-h-0 to all TabsContent in edit mode for cross-browser flex shrink safety
- Change nested <main> to <div> in layout to avoid invalid nested <main> tags (SidebarInset already renders as <main>)

* style(web): polish UI - dashboard i18n, sidebar create text, cursor-pointer tabs, remove cancel buttons

* feat(web): add plugin context menu to sidebar sub-items

- Add hover-reveal dropdown menu (Ellipsis icon) on plugin sidebar items
- Menu items: Update (marketplace only), View Source (marketplace/github), Delete
- Add confirmation dialog with async task polling for delete/update operations
- Extend SidebarEntityItem with installSource and installInfo fields
- Fix PipelineFormComponent optional onCancel invocation

* fix(web): prevent plugin sidebar text from overlapping menu button

Add right padding on plugin sub-items and explicit truncate on text
span so long plugin names never overlap the hover menu button.

* feat(web): show update indicator on sidebar plugin menu

- Fetch marketplace plugin versions in SidebarDataContext.refreshPlugins
- Compare with installed version using isNewerVersion to set hasUpdate
- Show red dot on menu trigger when update available (always visible)
- Show 'New' badge on Update menu item when update available
- Marketplace fetch failure is silently caught to avoid blocking sidebar

* refactor(web): remove entity list pages, back buttons, and make sidebar toggle collapse

- Remove card grid list views from bots, pipelines, knowledge pages
- Show empty state placeholder when no entity is selected
- Preserve KB migration dialog at page level
- Remove back (ArrowLeft) buttons from all detail pages (bots, pipelines, knowledge, plugins)
- Sidebar parent click for bots/pipelines/knowledge now toggles collapse instead of navigating
- Breadcrumb second level is now non-clickable (always BreadcrumbPage)
- Add selectFromSidebar i18n keys in all 4 locales

* feat(web): enhance bot session monitor with refresh functionality and improve log card UI

* refactor(web): optimize pipeline detail page with vertical config nav and debug chat polish

- Convert pipeline config tab's horizontal sub-tabs to vertical left-side navigation with icons
- Replace hardcoded colors in PipelineFormComponent and DebugDialog with theme-aware Tailwind classes
- Replace custom SVG icons with lucide-react (User, Users, ImageIcon, Send, Reply, etc.)
- Replace hardcoded Chinese strings with i18n keys (allMembers, file, voice, uploadImage, uploading)
- Modernize chat bubbles to use bg-primary/10 and bg-muted instead of hardcoded blue/gray
- Translate all Chinese comments to English in both components
- Delete unused pipelineFormStyle.module.css
- Remove max-w-2xl constraint from config tab container

* fix(web): improve dark mode contrast and relocate WebSocket status indicator

Bump dark mode --muted, --accent, --secondary from oklch(0.18) to oklch(0.24)
to fix invisible TabsList, message bubbles, and selected items against the
oklch(0.17) background. Move WebSocket connection dot from pipeline title
into the Debug Chat tab trigger so it is always visible. Replace hardcoded
Quote border colors with theme-aware border-muted-foreground/50.

* fix(web): increase dark mode contrast for muted/accent/secondary to oklch(0.27)

Previous value of oklch(0.24) was still not distinguishable enough against
the oklch(0.17) background. Bump to oklch(0.27) for a 0.10 lightness gap,
matching the contrast ratio of the default shadcn zinc dark theme.

* style(web): replace hardcoded colors with theme tokens in monitoring dashboard

Convert all monitoring page components from hardcoded gray/white colors
to theme-aware CSS variable tokens (bg-card, text-foreground,
text-muted-foreground, bg-muted, bg-background, bg-accent, border).
Semantic colors (red/green/blue/purple for status badges and error
styling) are intentionally preserved.

* feat(web): show debug indicator for debugging plugins in sidebar

Add orange Bug icon next to plugin name in sidebar sub-items when the
plugin is connected via WebSocket debug mode. Hide context menu for
debug plugins since delete/update operations are not supported.

* feat(web): show install source and debug badge on plugin detail page

Display a badge next to the plugin title indicating the install source
(GitHub blue, Local green, Marketplace purple) or debugging status
(orange with Bug icon), matching the existing plugin card convention.

* fix(web): resolve eslint errors for CI - remove unused imports and variables

* fix(web): remove stale setSubtitle call and fix prettier formatting

* Refactor code formatting and improve readability

- Updated HomeSidebar.tsx to enhance clarity in conditional assignment.
- Adjusted CSS formatting in github-markdown.css for better alignment.
- Cleaned up tsconfig.json by consolidating array formatting for consistency.

* fix(ci): use local prettier instead of mirrors-prettier to avoid version mismatch (3.1.0 vs 3.8.1)
2026-03-27 01:51:13 +08:00
xiaolou
3c0495fc51 fix: 修复钉钉文件消息解析失效问题(优化 downloadCode 提取逻辑) (#2080)
* fix: resolve dingtalk file parsing issue by extracting downloadCode from content

* style: fix ruff format trailing whitespace

---------

Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-03-27 00:17:26 +08:00
Junyan Qin
dfd25deb68 feat(web): hide deprecated KnowledgeRetriever plugins from marketplace
KnowledgeRetriever has been superseded by KnowledgeEngine. Filter out
plugins that only contain KnowledgeRetriever components from both the
main plugin list and recommendation lists, and remove the now-unused
deprecated badge UI.
2026-03-26 00:56:24 +08:00
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
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
170 changed files with 18248 additions and 5176 deletions

8
.gitignore vendored
View File

@@ -52,3 +52,11 @@ src/langbot/web/
/dist
/build
*.egg-info
# Docker 部署产生的本地文件
docker/data/
docker/docker-compose.override.yaml
# 备份目录
LangBot_backup_*/
*.bak

View File

@@ -9,16 +9,14 @@ repos:
# Run the formatter of backend.
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
types_or: [javascript, jsx, ts, tsx, css, scss]
additional_dependencies:
- prettier@3.1.0
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx --prefix web prettier --write --ignore-unknown
language: system
types_or: [javascript, jsx, ts, tsx, css, scss]
- id: lint-staged
name: lint-staged
entry: cd web && pnpm lint-staged

View File

@@ -10,14 +10,18 @@ FROM python:3.12.7-slim
WORKDIR /app
# Use Chinese mirror for faster and more reliable package downloads
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
COPY . .
COPY --from=node /app/web/out ./web/out
RUN apt update \
&& apt install gcc -y \
&& python -m pip install --no-cache-dir uv \
&& uv sync \
&& python -m pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple uv \
&& uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& touch /.dockerenv
CMD [ "uv", "run", "--no-sync", "main.py" ]

View File

@@ -34,8 +34,6 @@
---
## 什么是 LangBot
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 平台。
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。

View File

@@ -34,4 +34,4 @@ services:
networks:
langbot_network:
driver: bridge
driver: bridge

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.3"
version = "4.9.4"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -64,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.3",
"langbot-plugin==0.3.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

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

View File

@@ -272,15 +272,30 @@ class DingTalkClient:
message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file':
down_list = incoming_message.get_down_list()
if len(down_list) >= 2:
message_data['File'] = await self.get_file_url(down_list[0])
message_data['Name'] = down_list[1]
# 获取原始数据字典并提取嵌套的文件信息
raw_data = incoming_message.to_dict()
file_info = raw_data.get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(file_info, str):
try:
file_info = json.loads(file_info)
except (json.JSONDecodeError, TypeError):
file_info = {}
download_code = file_info.get('downloadCode')
file_name = file_info.get('fileName')
if download_code and file_name:
# 转换 downloadCode 为可下载的真实 URL
message_data['File'] = await self.get_file_url(download_code)
message_data['Name'] = file_name
else:
if self.logger:
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
message_data['File'] = None
message_data['Name'] = None
message_data['Type'] = 'file'
copy_message_data = message_data.copy()

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

@@ -6,7 +6,8 @@ import traceback
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
import re
from typing import Any, Callable, Optional, Tuple
from urllib.parse import unquote
import httpx
@@ -63,6 +64,9 @@ class StreamSession:
# 缓存最近一次片段,处理重试或超时兜底
last_chunk: Optional[StreamChunk] = None
# 反馈 ID用于接收用户点赞/点踩反馈
feedback_id: Optional[str] = None
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
@@ -73,6 +77,7 @@ class StreamSessionManager:
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
if not msg_id:
@@ -82,6 +87,32 @@ class StreamSessionManager:
def get_session(self, stream_id: str) -> Optional[StreamSession]:
return self._sessions.get(stream_id)
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
"""根据 feedback_id 查找会话。
Args:
feedback_id: 企业微信反馈事件中的反馈 ID。
Returns:
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
"""
if not feedback_id:
return None
stream_id = self._feedback_index.get(feedback_id)
if stream_id:
return self._sessions.get(stream_id)
return None
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
"""注册 feedback_id 与 stream_id 的映射。
Args:
stream_id: 企业微信流式会话 ID。
feedback_id: 反馈 ID。
"""
if feedback_id and stream_id:
self._feedback_index[feedback_id] = stream_id
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
"""根据企业微信回调创建或获取会话。
@@ -199,52 +230,139 @@ class StreamSessionManager:
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.
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
"""Decrypt AES-256-CBC encrypted file data.
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
Args:
download_url: The encrypted file download URL.
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
logger: Logger instance.
encrypted_data: The raw encrypted bytes.
aes_key_str: Base64-encoded AES key (may lack padding).
Returns:
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
Decrypted bytes with PKCS#7 padding removed.
"""
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
if not encrypted_data:
raise ValueError('encrypted_data is empty')
if not aes_key_str:
raise ValueError('aes_key is empty')
aes_key = base64.b64decode(encoding_aes_key + '=')
iv = aes_key[:16]
# Python's base64.b64decode requires proper padding (length % 4 == 0).
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
remainder = len(aes_key_str) % 4
if remainder != 0:
aes_key_str = aes_key_str + '=' * (4 - remainder)
key = base64.b64decode(aes_key_str)
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted_bytes)
iv = key[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
# Ensure encrypted data is aligned to AES block size (16 bytes).
# Node.js setAutoPadding(false) silently handles unaligned data,
# but PyCryptodome will raise an error.
block_size = 16
data_remainder = len(encrypted_data) % block_size
if data_remainder != 0:
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
decrypted = cipher.decrypt(encrypted_data)
# Remove PKCS#7 padding with validation
if len(decrypted) == 0:
raise ValueError('Decrypted data is empty')
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
if decrypted.startswith(b'\xff\xd8'):
# Verify all padding bytes are consistent
for i in range(len(decrypted) - pad_len, len(decrypted)):
if decrypted[i] != pad_len:
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
return decrypted[: len(decrypted) - pad_len]
def _extract_filename(content_disposition: str) -> Optional[str]:
"""Extract filename from a Content-Disposition header value."""
if not content_disposition:
return None
# RFC 5987: filename*=UTF-8''xxx
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
if utf8_match:
return unquote(utf8_match.group(1))
# Standard: filename="xxx" or filename=xxx
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
if match:
return unquote(match.group(1))
return None
def _bytes_to_data_uri(data: bytes) -> str:
"""Convert raw bytes to a data URI with auto-detected MIME type."""
if data.startswith(b'\xff\xd8'):
mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'):
elif data.startswith(b'\x89PNG'):
mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
elif data.startswith((b'GIF87a', b'GIF89a')):
mime_type = 'image/gif'
elif decrypted.startswith(b'BM'):
elif data.startswith(b'BM'):
mime_type = 'image/bmp'
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
mime_type = 'image/tiff'
elif data[:4] == b'%PDF':
mime_type = 'application/pdf'
elif data[:4] == b'PK\x03\x04':
mime_type = 'application/zip'
else:
mime_type = 'application/octet-stream'
base64_str = base64.b64encode(decrypted).decode('utf-8')
base64_str = base64.b64encode(data).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
async def download_encrypted_file(
download_url: str, aes_key: str, logger: EventLogger
) -> Tuple[Optional[bytes], Optional[str]]:
"""Download an AES-encrypted file from WeChat Work and decrypt it.
Args:
download_url: The encrypted file download URL.
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
or platform EncodingAESKey).
logger: Logger instance.
Returns:
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
"""
if not download_url:
return None, None
if not aes_key:
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
return None, None
filename: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(download_url)
if response.status_code != 200:
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
return None, None
encrypted_bytes = response.content
filename = _extract_filename(response.headers.get('content-disposition', ''))
except Exception:
await logger.error(f'Failed to download file: {traceback.format_exc()}')
return None, None
try:
decrypted = _decrypt_file(encrypted_bytes, aes_key)
return decrypted, filename
except Exception:
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
return None, None
async def parse_wecom_bot_message(
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
) -> dict[str, Any]:
@@ -273,10 +391,22 @@ async def parse_wecom_bot_message(
max_inline_file_size = 5 * 1024 * 1024
async def _safe_download(url: str):
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
if not url:
return None
return await download_encrypted_file(url, encoding_aes_key, logger)
return None, None
key = per_msg_aeskey or encoding_aes_key
if not key:
await logger.warning('No AES key available for file decryption, skipping download')
return None, None
return await download_encrypted_file(url, key, logger)
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
"""Download, decrypt, and convert to data URI for backward compatibility."""
data, _filename = await _safe_download(url, per_msg_aeskey)
if data:
return _bytes_to_data_uri(data)
return None
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
@@ -285,14 +415,17 @@ async def parse_wecom_bot_message(
'content', ''
)
elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '')
base64_data = await _safe_download(picurl)
image_info = msg_json.get('image', {})
picurl = image_info.get('url', '')
per_msg_aeskey = image_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
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')
per_msg_aeskey = voice_info.get('aeskey', '')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -302,12 +435,13 @@ async def parse_wecom_bot_message(
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
per_msg_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -316,13 +450,14 @@ async def parse_wecom_bot_message(
'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)
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
per_msg_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -332,9 +467,11 @@ async def parse_wecom_bot_message(
'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
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
@@ -355,13 +492,16 @@ async def parse_wecom_bot_message(
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)
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -371,13 +511,16 @@ async def parse_wecom_bot_message(
'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
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
if file_bytes:
file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -387,13 +530,14 @@ async def parse_wecom_bot_message(
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)
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
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')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -402,7 +546,7 @@ async def parse_wecom_bot_message(
'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)
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
@@ -483,14 +627,27 @@ class WecomBotClient:
self.stream_sessions = StreamSessionManager(logger=logger)
self.stream_poll_timeout = 0.5
self._feedback_callback: Optional[Callable] = None
def set_feedback_callback(self, callback: Callable) -> None:
"""设置反馈回调函数。
Args:
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
"""
self._feedback_callback = callback
@staticmethod
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
def _build_stream_payload(
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
) -> dict[str, Any]:
"""按照企业微信协议拼装返回报文。
Args:
stream_id: 企业微信会话 ID。
content: 推送的文本内容。
finish: 是否为最终片段。
feedback_id: 反馈 ID用于接收用户点赞/点踩反馈。
Returns:
dict[str, Any]: 可直接加密返回的 payload。
@@ -498,13 +655,16 @@ class WecomBotClient:
Example:
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
"""
stream_payload = {
'id': stream_id,
'finish': finish,
'content': content,
}
if feedback_id:
stream_payload['feedback'] = {'id': feedback_id}
return {
'msgtype': 'stream',
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
'stream': stream_payload,
}
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -560,9 +720,14 @@ class WecomBotClient:
"""
session, is_new = self.stream_sessions.create_or_get(msg_json)
feedback_id = str(uuid.uuid4())
session.feedback_id = feedback_id
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
message_data = await self.get_message(msg_json)
if message_data:
message_data['stream_id'] = session.stream_id
message_data['feedback_id'] = feedback_id
try:
event = wecombotevent.WecomBotEvent(message_data)
except Exception:
@@ -571,7 +736,7 @@ class WecomBotClient:
if is_new:
asyncio.create_task(self._dispatch_event(event))
payload = self._build_stream_payload(session.stream_id, '', False)
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
return await self._encrypt_and_reply(payload, nonce)
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
@@ -696,11 +861,79 @@ class WecomBotClient:
msg_json = json.loads(decrypted_xml)
event = msg_json.get('event', {})
event_type = event.get('eventtype', '')
if event_type == 'feedback_event':
return await self._handle_feedback_event(msg_json, nonce)
if msg_json.get('msgtype') == 'stream':
return await self._handle_post_followup_response(msg_json, nonce)
return await self._handle_post_initial_response(msg_json, nonce)
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信用户反馈事件(点赞/点踩)。
Args:
msg_json: 解密后的企业微信反馈事件 JSON。
nonce: 企业微信回调参数 nonce。
Returns:
Tuple[Response, int]: Quart Response 及状态码。
Note:
企业微信协议要求:反馈事件目前仅支持回复空包。
"""
try:
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
feedback_id = feedback_event.get('id', '')
feedback_type = feedback_event.get('type', 0)
feedback_content = feedback_event.get('content', '')
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
await self.logger.info(
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
f'content={feedback_content}, reasons={inaccurate_reasons}'
)
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
if session:
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, '
f'user_id={session.user_id}'
)
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
except Exception:
await self.logger.error(traceback.format_exc())
return await self._encrypt_and_reply({}, nonce)
async def get_message(self, msg_json):
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
@@ -769,8 +1002,20 @@ class WecomBotClient:
return decorator
def on_feedback(self):
def decorator(func: Callable):
if 'feedback' not in self._message_handlers:
self._message_handlers['feedback'] = []
self._message_handlers['feedback'].append(func)
return func
return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key):
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data:
return _bytes_to_data_uri(data)
return None
async def run_task(self, host: str, port: int, *args, **kwargs):
"""

View File

@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
AI Bot ID
"""
return self.get('aibotid', '')
@property
def feedback_id(self) -> str:
"""
反馈 ID用于关联用户点赞/点踩反馈
"""
return self.get('feedback_id', '')
@property
def stream_id(self) -> str:
"""
流式消息 ID
"""
return self.get('stream_id', '')

View File

@@ -4,6 +4,7 @@ import base64
import binascii
import httpx
import traceback
from urllib.parse import quote
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
@@ -67,6 +68,31 @@ class WecomClient:
await self.logger.error(f'获取accesstoken失败:{response.json()}')
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):
if not self.check_access_token_for_contacts():
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)

View File

@@ -13,9 +13,9 @@ from .. import group
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
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:
if '/' in image_key or '\\' in image_key:
if '..' in image_key or '\\' in image_key:
return quart.Response(status=404)
if not await self.ap.storage_mgr.storage_provider.exists(image_key):

View File

@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
'platform',
'user_id',
]
elif export_type == 'feedback':
data = await self.ap.monitoring_service.export_feedback(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
)
headers = [
'id',
'timestamp',
'feedback_id',
'feedback_type',
'feedback_content',
'inaccurate_reasons',
'bot_id',
'bot_name',
'pipeline_id',
'pipeline_name',
'session_id',
'message_id',
'stream_id',
'user_id',
'platform',
]
else:
return self.error(message=f'Invalid export type: {export_type}', code=400)
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
)
return response, 200
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback_stats() -> str:
"""Get feedback statistics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_feedback_stats(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
)
return self.success(data=stats)
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_feedback() -> str:
"""Get feedback list"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
feedback_type_str = quart.request.args.get('feedbackType')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
# Parse feedback type
feedback_type = int(feedback_type_str) if feedback_type_str else None
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'feedback': feedback_list,
'total': total,
'limit': limit,
'offset': offset,
}
)

View File

@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing asset_url parameter')
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
install_info = {
'asset_url': asset_url,
'owner': owner,
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
data = await quart.request.json
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
ctx.metadata['install_source'] = 'marketplace'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-marketplace',
label=f'Installing plugin from marketplace ...{data}',
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
context=ctx,
)
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
}
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
ctx.metadata['install_source'] = 'local'
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
kind='plugin-operation',
name='plugin-install-local',
label=f'Installing plugin from local ...{file.filename}',
label=f'Installing plugin from local {file.filename}',
context=ctx,
)

View File

@@ -1,7 +1,11 @@
import json
import quart
import sqlalchemy
from .. import group
from .....utils import constants
from .....entity.persistence.metadata import Metadata
@group.group_class('system', '/api/v1/system')
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
wizard_status = 'none'
wizard_progress = None
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
)
for row in result:
if row.key == 'wizard_status':
wizard_status = row.value
elif row.key == 'wizard_progress':
try:
wizard_progress = json.loads(row.value)
except (json.JSONDecodeError, TypeError):
wizard_progress = None
except Exception:
pass
return self.success(
data={
'version': constants.semantic_version,
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service', False
),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
}
)
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" }
"""
data = await quart.request.get_json(silent=True) or {}
status = data.get('status', 'completed')
if status not in ('skipped', 'completed'):
return self.http_status(400, 400, f'Invalid wizard status: {status}')
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
)
# Clear wizard progress when wizard is completed/skipped
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
)
except Exception as e:
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
return self.success(data={})
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Save wizard progress to metadata table.
Accepts JSON body with wizard state fields:
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
"bot_saved": bool, "selected_runner": str|null }
"""
data = await quart.request.get_json(silent=True) or {}
progress_json = json.dumps(data, ensure_ascii=False)
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
)
except Exception as e:
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
return self.success(data={})
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
if task_type == '':
task_type = None
if task_kind == '':
task_kind = None
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(task_id: str) -> str:

View File

@@ -105,6 +105,28 @@ class HTTPController:
):
if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html'
elif path.startswith('home/'):
# SPA fallback for /home/* sub-routes.
# Entity detail views use query params (e.g. /home/bots?id=uuid),
# so the pre-rendered list page is served directly via path + '.html'.
# This fallback handles any remaining unmatched sub-paths.
segments = path.rstrip('/').split('/')
# Walk up parent segments looking for matching .html files
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Final fallback to index.html for /home/* routes
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
else:
return await quart.send_from_directory(frontend_path, '404.html')

View File

@@ -68,7 +68,6 @@ class BotService:
'wecomcs',
'LINE',
'lark',
'gewechat',
]:
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', '')

View File

@@ -16,6 +16,57 @@ class MonitoringService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
# ========== Cleanup Methods ==========
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
"""Delete monitoring records older than the specified retention period.
Args:
retention_days: Number of days to retain records.
Returns:
A dict mapping table name to the number of deleted rows.
"""
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
days=retention_days
)
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
(
'monitoring_messages',
persistence_monitoring.MonitoringMessage,
persistence_monitoring.MonitoringMessage.timestamp,
),
(
'monitoring_llm_calls',
persistence_monitoring.MonitoringLLMCall,
persistence_monitoring.MonitoringLLMCall.timestamp,
),
(
'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall,
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
),
(
'monitoring_errors',
persistence_monitoring.MonitoringError,
persistence_monitoring.MonitoringError.timestamp,
),
(
'monitoring_sessions',
persistence_monitoring.MonitoringSession,
persistence_monitoring.MonitoringSession.last_activity,
),
]
deleted_counts: dict[str, int] = {}
for table_name, model_cls, ts_column in tables_and_columns:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
deleted_counts[table_name] = result.rowcount
return deleted_counts
# ========== Recording Methods ==========
async def record_message(
@@ -1132,3 +1183,268 @@ class MonitoringService:
}
for row in rows
]
# ========== Feedback Methods ==========
async def record_feedback(
self,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
inaccurate_reasons: list[str] | None = None,
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
platform: str | None = None,
) -> str:
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
feedback_content: Optional user feedback text
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
bot_id: Bot ID
bot_name: Bot name
pipeline_id: Pipeline ID
pipeline_name: Pipeline name
session_id: Session ID
message_id: Message ID
stream_id: Stream ID (for WeChat Work streaming messages)
user_id: User ID
platform: Platform name (e.g., 'wecom')
Returns:
The record ID
"""
import json
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
)
return record_id
async def get_feedback_stats(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get feedback statistics.
Returns:
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total likes (feedback_type = 1)
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 1
)
if conditions:
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
total_likes = likes_result.scalar() or 0
# Get total dislikes (feedback_type = 2)
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
persistence_monitoring.MonitoringFeedback.feedback_type == 2
)
if conditions:
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
total_dislikes = dislikes_result.scalar() or 0
# Get total feedback count
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
total_query = total_query.where(sqlalchemy.and_(*conditions))
total_result = await self.ap.persistence_mgr.execute_async(total_query)
total_feedback = total_result.scalar() or 0
# Calculate satisfaction rate
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
# Get feedback by bot
bot_stats_query = (
sqlalchemy.select(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
sqlalchemy.func.sum(
sqlalchemy.case(
(persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1),
else_=0
)
).label('likes'),
sqlalchemy.func.sum(
sqlalchemy.case(
(persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1),
else_=0
)
).label('dislikes'),
)
.group_by(
persistence_monitoring.MonitoringFeedback.bot_id,
persistence_monitoring.MonitoringFeedback.bot_name,
)
)
if conditions:
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
bot_stats = [
{
'bot_id': row.bot_id,
'bot_name': row.bot_name,
'total': row.total,
'likes': row.likes or 0,
'dislikes': row.dislikes or 0,
}
for row in bot_stats_result.all()
]
return {
'total_feedback': total_feedback,
'total_likes': total_likes,
'total_dislikes': total_dislikes,
'satisfaction_rate': round(satisfaction_rate, 2),
'by_bot': bot_stats,
}
async def get_feedback_list(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
feedback_type: int | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if feedback_type is not None:
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
# Get feedback list
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
)
for row in rows
],
total,
)
async def export_feedback(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100000,
) -> list[dict]:
"""Export feedback as list of dictionaries for CSV conversion."""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
persistence_monitoring.MonitoringFeedback.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit)
result = await self.ap.persistence_mgr.execute_async(query)
rows = result.all()
return [
{
'id': row[0].id if isinstance(row, tuple) else row.id,
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
'feedback_type': 'like' if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1 else 'dislike',
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
}
for row in rows
]

View File

@@ -188,6 +188,34 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start monitoring data cleanup task if enabled
monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
if auto_cleanup_cfg.get('enabled', True):
retention_days = auto_cleanup_cfg.get('retention_days', 30)
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
async def monitoring_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
f'(retention={retention_days}d): {deleted}'
)
except Exception as e:
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
await asyncio.sleep(check_interval_seconds)
self.task_mgr.create_task(
monitoring_cleanup_loop(),
name='monitoring-cleanup',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
self.task_mgr.create_task(
never_ending(),
name='never-ending-task',

View File

@@ -17,9 +17,13 @@ class TaskContext:
log: str
"""Log"""
metadata: dict
"""Structured metadata for progress reporting"""
def __init__(self):
self.current_action = 'default'
self.log = ''
self.metadata = {}
def _log(self, msg: str):
self.log += msg + '\n'
@@ -38,7 +42,7 @@ class TaskContext:
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
def to_dict(self) -> dict:
return {'current_action': self.current_action, 'log': self.log}
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
@staticmethod
def new() -> TaskContext:
@@ -211,9 +215,14 @@ class AsyncTaskManager:
def get_tasks_dict(
self,
type: str = None,
kind: str = None,
) -> dict:
return {
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
],
'id_index': TaskWrapper._id_index,
}

View File

@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
"""英文"""
zh_Hans: typing.Optional[str] = None
"""中文"""
"""简体中文"""
zh_Hant: typing.Optional[str] = None
"""繁体中文"""
ja_JP: typing.Optional[str] = None
"""日文"""
th_TH: typing.Optional[str] = None
"""泰文"""
vi_VN: typing.Optional[str] = None
"""越南文"""
es_ES: typing.Optional[str] = None
"""西班牙文"""
def to_dict(self) -> dict:
"""转换为字典"""
dic = {}
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
dic['en_US'] = self.en_US
if self.zh_Hans is not None:
dic['zh_Hans'] = self.zh_Hans
if self.zh_Hant is not None:
dic['zh_Hant'] = self.zh_Hant
if self.ja_JP is not None:
dic['ja_JP'] = self.ja_JP
if self.th_TH is not None:
dic['th_TH'] = self.th_TH
if self.vi_VN is not None:
dic['vi_VN'] = self.vi_VN
if self.es_ES is not None:
dic['es_ES'] = self.es_ES
return dic

View File

@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
class MonitoringFeedback(Base):
"""User feedback records (like/dislike) from AI Bot conversations"""
__tablename__ = 'monitoring_feedback'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
# Context fields
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom

View File

@@ -2,18 +2,16 @@ from __future__ import annotations
import datetime
import typing
import json
import uuid
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database, migration
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
from ..entity.persistence import base, metadata, model as persistence_model
from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
from ..api.http.service import pipeline as pipeline_service
from . import databases, migrations
importutil.import_modules_in_pkg(databases)
@@ -78,7 +76,6 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
await self.write_default_pipeline()
await self.write_space_model_providers()
async def create_tables(self):
@@ -101,29 +98,6 @@ class PersistenceManager:
if row is None:
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
async def write_default_pipeline(self):
# write default pipeline
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
default_pipeline_uuid = None
if result.first() is None:
self.ap.logger.info('Creating default pipeline...')
pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))
default_pipeline_uuid = str(uuid.uuid4())
pipeline_data = {
'uuid': default_pipeline_uuid,
'for_version': self.ap.ver_mgr.get_current_version(),
'stages': pipeline_service.default_stage_order,
'is_default': True,
'name': 'ChatPipeline',
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
'config': pipeline_config,
'extensions_preferences': {},
}
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
async def write_space_model_providers(self):
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'

View File

@@ -0,0 +1,75 @@
import sqlalchemy
from .. import migration
@migration.migration_class(25)
class DBMigrateFeedbackStats(migration.DBMigration):
"""Add monitoring_feedback table for storing user feedback from AI Bot conversations"""
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 upgrade(self):
"""Create monitoring_feedback table."""
if await self._table_exists('monitoring_feedback'):
self.ap.logger.debug('monitoring_feedback table already exists, skipping migration.')
return
# Create monitoring_feedback table with all columns
create_table_sql = '''
CREATE TABLE monitoring_feedback (
id VARCHAR(255) PRIMARY KEY,
timestamp DATETIME NOT NULL,
feedback_id VARCHAR(255) NOT NULL UNIQUE,
feedback_type INTEGER NOT NULL,
feedback_content TEXT,
inaccurate_reasons TEXT,
bot_id VARCHAR(255),
bot_name VARCHAR(255),
pipeline_id VARCHAR(255),
pipeline_name VARCHAR(255),
session_id VARCHAR(255),
message_id VARCHAR(255),
stream_id VARCHAR(255),
user_id VARCHAR(255),
platform VARCHAR(255)
)
'''
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(create_table_sql))
# Create indexes
indexes = [
'CREATE INDEX ix_monitoring_feedback_timestamp ON monitoring_feedback (timestamp)',
'CREATE UNIQUE INDEX ix_monitoring_feedback_feedback_id ON monitoring_feedback (feedback_id)',
'CREATE INDEX ix_monitoring_feedback_bot_id ON monitoring_feedback (bot_id)',
'CREATE INDEX ix_monitoring_feedback_pipeline_id ON monitoring_feedback (pipeline_id)',
'CREATE INDEX ix_monitoring_feedback_session_id ON monitoring_feedback (session_id)',
'CREATE INDEX ix_monitoring_feedback_message_id ON monitoring_feedback (message_id)',
'CREATE INDEX ix_monitoring_feedback_stream_id ON monitoring_feedback (stream_id)',
]
for index_sql in indexes:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(index_sql))
self.ap.logger.info('Created monitoring_feedback table with indexes.')
async def downgrade(self):
"""Drop monitoring_feedback table."""
if await self._table_exists('monitoring_feedback'):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('DROP TABLE monitoring_feedback')
)
self.ap.logger.info('Dropped monitoring_feedback table.')

View File

@@ -353,3 +353,62 @@ class LLMCallMonitor:
)
return False # Don't suppress exceptions
class FeedbackMonitor:
"""Helper for recording user feedback from AI Bot conversations"""
@staticmethod
async def record_feedback(
ap: app.Application,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
inaccurate_reasons: list[str] | None = None,
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
platform: str = 'wecom',
):
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
ap: Application instance
feedback_id: Unique feedback identifier from platform
feedback_type: 1 = like, 2 = dislike
feedback_content: Optional user feedback text
inaccurate_reasons: List of reasons for inaccurate response
bot_id: Bot UUID
bot_name: Bot name
pipeline_id: Pipeline UUID
pipeline_name: Pipeline name
session_id: Session ID
message_id: Message ID
stream_id: Stream ID
user_id: User ID
platform: Platform name (default: wecom)
"""
try:
await ap.monitoring_service.record_feedback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
session_id=session_id,
message_id=message_id,
stream_id=stream_id,
user_id=user_id,
platform=platform,
)
ap.logger.info(f'Recorded feedback: feedback_id={feedback_id}, type={feedback_type}')
except Exception as e:
ap.logger.error(f'Failed to record feedback: {e}')

View File

@@ -9,6 +9,7 @@ from ..core import app, entities as core_entities, taskmgr
from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.errors import platform as platform_errors
@@ -267,13 +268,33 @@ class PlatformManager:
adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config,
logger,
ap=self.ap,
)
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
adapter_inst.config['_webhook_prefix'] = self.ap.instance_config.data['api'].get(
'webhook_prefix', 'http://127.0.0.1:5300'
# 如果 adapter 支持 set_bot_info 方法,设置 bot 信息(用于监控记录)
if hasattr(adapter_inst, 'set_bot_info'):
pipeline_name = ''
if bot_entity.use_pipeline_uuid:
try:
pipeline_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
persistence_pipeline.LegacyPipeline.uuid == bot_entity.use_pipeline_uuid
)
)
pipeline_row = pipeline_result.first()
if pipeline_row:
pipeline_name = pipeline_row[0]
except Exception:
pass
adapter_inst.set_bot_info(
bot_id=bot_entity.uuid,
bot_name=bot_entity.name,
pipeline_id=bot_entity.use_pipeline_uuid or '',
pipeline_name=pipeline_name,
)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)

View File

@@ -5,19 +5,25 @@ metadata:
label:
en_US: OneBot v11
zh_Hans: OneBot v11
zh_Hant: OneBot v11
description:
en_US: OneBot v11 Adapter
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
en_US: OneBot v11 Adapter, used for QQ bots
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
icon: onebot.png
spec:
categories:
- protocol
config:
- name: host
label:
en_US: Host
zh_Hans: 主机
zh_Hant: 主機
description:
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
type: string
required: true
default: 0.0.0.0
@@ -25,9 +31,11 @@ spec:
label:
en_US: Port
zh_Hans: 端口
zh_Hant: 連接埠
description:
en_US: Port
zh_Hans: 监听的端口
zh_Hant: 監聽的連接埠
type: integer
required: true
default: 2280
@@ -35,9 +43,11 @@ spec:
label:
en_US: Access Token
zh_Hans: 访问令牌
zh_Hant: 存取令牌
description:
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
type: string
required: false
default: ""

View File

@@ -139,7 +139,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
dict # 回复卡片消息字典key为消息idvalue为回复卡片实例id用于在流式消息时判断是否发送到指定卡片
)
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
required_keys = [
'client_id',
'client_secret',

View File

@@ -5,16 +5,21 @@ metadata:
label:
en_US: DingTalk
zh_Hans: 钉钉
zh_Hant: 釘釘
description:
en_US: DingTalk Adapter
zh_Hans: 钉钉适配器,请查看文档了解使用方式
zh_Hant: 釘釘適配器,請查看文件了解使用方式
icon: dingtalk.svg
spec:
categories:
- china
config:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端ID
zh_Hant: 用戶端ID
type: string
required: true
default: ""
@@ -22,6 +27,7 @@ spec:
label:
en_US: Client Secret
zh_Hans: 客户端密钥
zh_Hant: 用戶端密鑰
type: string
required: true
default: ""
@@ -29,6 +35,7 @@ spec:
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
type: string
required: true
default: ""
@@ -36,6 +43,7 @@ spec:
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
@@ -43,6 +51,7 @@ spec:
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
type: boolean
required: false
default: true
@@ -50,9 +59,11 @@ spec:
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
@@ -60,6 +71,7 @@ spec:
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
type: boolean
required: false
default: false
@@ -67,6 +79,7 @@ spec:
label:
en_US: card template id
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
type: string
required: true
default: "填写你的卡片template_id"

View File

@@ -5,16 +5,34 @@ metadata:
label:
en_US: Discord
zh_Hans: Discord
zh_Hant: Discord
ja_JP: Discord
th_TH: Discord
vi_VN: Discord
es_ES: Discord
description:
en_US: Discord Adapter
zh_Hans: Discord 适配器,请查看文档了解使用方式
zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境
zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境
ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です
th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้
vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
icon: discord.svg
spec:
categories:
- popular
- global
config:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端ID
zh_Hant: 用戶端ID
ja_JP: クライアント ID
th_TH: รหัสไคลเอนต์
vi_VN: ID khách hàng
es_ES: ID de cliente
type: string
required: true
default: ""
@@ -22,6 +40,11 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
ja_JP: トークン
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,609 +0,0 @@
import gewechat_client
import typing
import asyncio
import traceback
import re
import copy
import threading
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ...utils import image
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
from ..logger import EventLogger
class GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
def __init__(self, config: dict):
self.config = config
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
content_list = []
for component in message_chain:
if isinstance(component, platform_message.At):
content_list.append({'type': 'at', 'target': component.target})
elif isinstance(component, platform_message.Plain):
content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if not component.url:
pass
content_list.append({'type': 'image', 'image': component.url})
elif isinstance(component, platform_message.Voice):
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
content_list.append({'type': 'image', 'image': component.url})
elif isinstance(component, platform_message.WeChatMiniPrograms):
content_list.append(
{
'type': 'WeChatMiniPrograms',
'mini_app_id': component.mini_app_id,
'display_name': component.display_name,
'page_path': component.page_path,
'cover_img_url': component.image_url,
'title': component.title,
'user_name': component.user_name,
}
)
elif isinstance(component, platform_message.WeChatForwardMiniPrograms):
content_list.append(
{
'type': 'WeChatForwardMiniPrograms',
'xml_data': component.xml_data,
'image_url': component.image_url,
}
)
elif isinstance(component, platform_message.WeChatEmoji):
content_list.append(
{
'type': 'WeChatEmoji',
'emoji_md5': component.emoji_md5,
'emoji_size': component.emoji_size,
}
)
elif isinstance(component, platform_message.WeChatLink):
content_list.append(
{
'type': 'WeChatLink',
'link_title': component.link_title,
'link_desc': component.link_desc,
'link_thumb_url': component.link_thumb_url,
'link_url': component.link_url,
}
)
elif isinstance(component, platform_message.WeChatForwardLink):
content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatForwardImage):
content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatForwardFile):
content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatAppMsg):
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
elif isinstance(component, platform_message.WeChatForwardQuote):
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
return content_list
async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain:
message_list = []
ats_bot = False
content = message['Data']['Content']['string']
content_no_preifx = content
is_group_message = self._is_group_message(message)
if is_group_message:
ats_bot = self._ats_bot(message, bot_account_id)
if '@所有人' in content:
message_list.append(platform_message.AtAll())
elif ats_bot:
message_list.append(platform_message.At(target=bot_account_id))
content_no_preifx, _ = self._extract_content_and_sender(content)
msg_type = message['Data']['MsgType']
handler_map = {
1: self._handler_text,
3: self._handler_image,
34: self._handler_voice,
49: self._handler_compound,
}
handler = handler_map.get(msg_type, self._handler_default)
handler_result = await handler(
message=message,
content_no_preifx=content_no_preifx,
)
if handler_result and len(handler_result) > 0:
message_list.extend(handler_result)
return platform_message.MessageChain(message_list)
async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
if message and self._is_group_message(message):
pattern = r'@\S{1,20}'
content_no_preifx = re.sub(pattern, '', content_no_preifx)
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
try:
image_xml = content_no_preifx
if not image_xml:
return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])
base64_str, image_format = await image.get_gewechat_image_base64(
gewechat_url=self.config['gewechat_url'],
gewechat_file_url=self.config['gewechat_file_url'],
app_id=self.config['app_id'],
xml_content=image_xml,
token=self.config['token'],
image_type=2,
)
elements = [
platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'),
platform_message.WeChatForwardImage(xml_data=image_xml),
]
return platform_message.MessageChain(elements)
except Exception as e:
print(f'处理图片失败: {str(e)}')
return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])
async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
message_List = []
try:
audio_base64 = message['Data']['ImgBuf']['buffer']
if not audio_base64:
message_List.append(platform_message.Unknown(text='[语音内容为空]'))
return platform_message.MessageChain(message_List)
voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')
message_List.append(voice_element)
except KeyError as e:
print(f'语音数据字段缺失: {str(e)}')
message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))
except Exception as e:
print(f'处理语音消息异常: {str(e)}')
message_List.append(platform_message.Unknown(text='[语音处理失败]'))
return platform_message.MessageChain(message_List)
async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
try:
xml_data = ET.fromstring(content_no_preifx)
appmsg_data = xml_data.find('.//appmsg')
if appmsg_data:
data_type = appmsg_data.findtext('.//type', '')
sub_handler_map = {
'57': self._handler_compound_quote,
'5': self._handler_compound_link,
'6': self._handler_compound_file,
'33': self._handler_compound_mini_program,
'36': self._handler_compound_mini_program,
'2000': partial(self._handler_compound_unsupported, text='[转账消息]'),
'2001': partial(self._handler_compound_unsupported, text='[红包消息]'),
'51': partial(self._handler_compound_unsupported, text='[视频号消息]'),
}
handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)
return await handler(
message=message,
xml_data=xml_data,
)
else:
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
except Exception as e:
print(f'解析复合消息失败: {str(e)}')
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
async def _handler_compound_quote(
self, message: Optional[dict], xml_data: ET.Element
) -> platform_message.MessageChain:
message_list = []
appmsg_data = xml_data.find('.//appmsg')
quote_data = ''
user_data = ''
sender_id = xml_data.findtext('.//fromusername')
if appmsg_data:
user_data = appmsg_data.findtext('.//title') or ''
quote_data = appmsg_data.find('.//refermsg').findtext('.//content')
message_list.append(
platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode'))
)
if quote_data:
quote_data_message_list = platform_message.MessageChain()
try:
if '<msg>' not in quote_data:
quote_data_message_list.append(platform_message.Plain(quote_data))
else:
quote_data_xml = ET.fromstring(quote_data)
if quote_data_xml.find('img'):
quote_data_message_list.extend(await self._handler_image(None, quote_data))
elif quote_data_xml.find('voicemsg'):
quote_data_message_list.extend(await self._handler_voice(None, quote_data))
elif quote_data_xml.find('videomsg'):
quote_data_message_list.extend(await self._handler_default(None, quote_data))
else:
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
except Exception as e:
print(f'处理引用消息异常 expcetion:{e}')
quote_data_message_list.append(platform_message.Plain(quote_data))
message_list.append(
platform_message.Quote(
sender_id=sender_id,
origin=quote_data_message_list,
)
)
if len(user_data) > 0:
pattern = r'@\S{1,20}'
user_data = re.sub(pattern, '', user_data)
message_list.append(platform_message.Plain(user_data))
return platform_message.MessageChain(message_list)
async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)])
async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
message_list = []
try:
appmsg = xml_data.find('.//appmsg')
if appmsg is None:
return platform_message.MessageChain()
message_list.append(
platform_message.WeChatLink(
link_title=appmsg.findtext('title', ''),
link_desc=appmsg.findtext('des', ''),
link_url=appmsg.findtext('url', ''),
link_thumb_url=appmsg.findtext('thumburl', ''),
)
)
xml_data_str = ET.tostring(xml_data, encoding='unicode')
message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str))
except Exception as e:
print(f'解析链接消息失败: {str(e)}')
return platform_message.MessageChain(message_list)
async def _handler_compound_mini_program(
self, message: dict, xml_data: ET.Element
) -> platform_message.MessageChain:
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])
async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
if message:
msg_type = message['Data']['MsgType']
else:
msg_type = ''
return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])
def _handler_compound_unsupported(
self, message: dict, xml_data: str, text: Optional[str] = None
) -> platform_message.MessageChain:
if not text:
text = f'[xml_data={xml_data}]'
content_list = []
content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))
return platform_message.MessageChain(content_list)
def _ats_bot(self, message: dict, bot_account_id: str) -> bool:
ats_bot = False
try:
to_user_name = message['Wxid']
raw_content = message['Data']['Content']['string']
content_no_prefix, _ = self._extract_content_and_sender(raw_content)
push_content = message.get('Data', {}).get('PushContent', '')
ats_bot = ats_bot or ('在群聊中@了你' in push_content)
msg_source = message.get('Data', {}).get('MsgSource', '') or ''
if len(msg_source) > 0:
msg_source_data = ET.fromstring(msg_source)
at_user_list = msg_source_data.findtext('atuserlist') or ''
ats_bot = ats_bot or (to_user_name in at_user_list)
if message.get('Data', {}).get('MsgType', 0) == 49:
xml_data = ET.fromstring(content_no_prefix)
appmsg_data = xml_data.find('.//appmsg')
tousername = message['Wxid']
if appmsg_data:
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')
ats_bot = ats_bot or (quote_id == tousername)
except Exception as e:
print(f'Error in gewechat _ats_bot: {e}')
finally:
return ats_bot
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
try:
regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:')
line_split = raw_content.split('\n')
if len(line_split) > 0 and regex.match(line_split[0]):
raw_content = '\n'.join(line_split[1:])
sender_id = line_split[0].strip(':')
return raw_content, sender_id
except Exception as e:
print(f'_extract_content_and_sender got except: {e}')
finally:
return raw_content, None
def _is_group_message(self, message: dict) -> bool:
from_user_name = message['Data']['FromUserName']['string']
return from_user_name.endswith('@chatroom')
class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, config: dict):
self.config = config
self.message_converter = GewechatMessageConverter(config)
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
pass
async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent:
if event['Wxid'] == event['Data']['FromUserName']['string']:
return None
if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][
'string'
].startswith('weixin'):
return None
message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)
if not message_chain:
return None
if '@chatroom' in event['Data']['FromUserName']['string']:
sender_wxid = event['Data']['Content']['string'].split(':')[0]
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_wxid,
member_name=event['Data']['FromUserName']['string'],
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=event['Data']['FromUserName']['string'],
name=event['Data']['FromUserName']['string'],
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=event['Data']['CreateTime'],
source_platform_object=event,
)
else:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event['Data']['FromUserName']['string'],
nickname=event['Data']['FromUserName']['string'],
remark='',
),
message_chain=message_chain,
time=event['Data']['CreateTime'],
source_platform_object=event,
)
class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: gewechat_client.GewechatClient = None
bot_uuid: str = None
message_converter: GewechatMessageConverter = None
event_converter: GewechatEventConverter = 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: EventLogger):
super().__init__(
config=config,
logger=logger,
)
self.message_converter = GewechatMessageConverter(config)
self.event_converter = GewechatEventConverter(config)
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
data = await request.json
await self.logger.debug(f'Gewechat callback event: {data}')
if 'data' in data:
data['Data'] = data['data']
if 'type_name' in data:
data['TypeName'] = data['type_name']
if 'testMsg' in data:
return 'ok'
elif 'TypeName' in data and data['TypeName'] == 'AddMsg':
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception:
await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')
return 'ok'
if event and event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return 'ok'
return 'ok'
async def _handle_message(self, message: platform_message.MessageChain, target_id: str):
content_list = await self.message_converter.yiri2target(message)
at_targets = [item['target'] for item in content_list if item['type'] == 'at']
at_targets = at_targets or []
member_info = []
if at_targets:
member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[
'data'
]
for msg in content_list:
if msg['type'] == 'text' and at_targets:
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
handler_map = {
'text': lambda msg: self.bot.post_text(
app_id=self.config['app_id'],
to_wxid=target_id,
content=msg['content'],
ats=','.join(at_targets),
),
'image': lambda msg: self.bot.post_image(
app_id=self.config['app_id'],
to_wxid=target_id,
img_url=msg['image'],
),
'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data'],
cover_img_url=msg.get('image_url'),
),
'WeChatEmoji': lambda msg: self.bot.post_emoji(
app_id=self.config['app_id'],
to_wxid=target_id,
emoji_md5=msg['emoji_md5'],
emoji_size=msg['emoji_size'],
),
'WeChatLink': lambda msg: self.bot.post_link(
app_id=self.config['app_id'],
to_wxid=target_id,
title=msg['link_title'],
desc=msg['link_desc'],
link_url=msg['link_url'],
thumb_url=msg['link_thumb_url'],
),
'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
mini_app_id=msg['mini_app_id'],
display_name=msg['display_name'],
page_path=msg['page_path'],
cover_img_url=msg['cover_img_url'],
title=msg['title'],
user_name=msg['user_name'],
),
'WeChatForwardLink': lambda msg: self.bot.forward_url(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'WeChatForwardImage': lambda msg: self.bot.forward_image(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'WeChatForwardFile': lambda msg: self.bot.forward_file(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'voice': lambda msg: self.bot.post_voice(
app_id=self.config['app_id'],
to_wxid=target_id,
voice_url=msg['url'],
voice_duration=msg['length'],
),
'WeChatAppMsg': lambda msg: self.bot.post_app_msg(
app_id=self.config['app_id'],
to_wxid=target_id,
appmsg=msg['app_msg'],
),
'at': lambda msg: None,
}
if handler := handler_map.get(msg['type']):
handler(msg)
else:
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
return await self._handle_message(message, target_id)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
if message_source.source_platform_object:
target_id = message_source.source_platform_object['Data']['FromUserName']['string']
return await self._handle_message(message, target_id)
async def is_muted(self, group_id: int) -> bool:
pass
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
],
):
pass
def _build_callback_url(self) -> str:
webhook_prefix = self.config.get('_webhook_prefix', 'http://127.0.0.1:5300').rstrip('/')
return f'{webhook_prefix}/bots/{self.bot_uuid}'
async def run_async(self):
if not self.config['token']:
session = httpclient.get_session()
async with session.post(
f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId',
json={'app_id': self.config['app_id']},
) as response:
if response.status != 200:
raise Exception(f'获取gewechat token失败: {await response.text()}')
self.config['token'] = (await response.json())['data']
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])
def gewechat_init_process():
profile = self.bot.get_profile(self.config['app_id'])
self.bot_account_id = profile['data']['nickName']
try:
callback_url = self._build_callback_url()
self.bot.set_callback(self.config['token'], callback_url)
print(f'Gewechat 回调地址已设置: {callback_url}')
except Exception as e:
raise Exception(f'设置 Gewechat 回调失败token失效{e}')
threading.Thread(target=gewechat_init_process).start()
# 统一 webhook 模式下,不启动独立的 HTTP 服务
# 保持适配器运行
while True:
await asyncio.sleep(1)
async def kill(self) -> bool:
return False

View File

@@ -1,51 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: gewechat
label:
en_US: GeWeChat
zh_Hans: GeWeChat个人微信
description:
en_US: GeWeChat Adapter (Unified Webhook)
zh_Hans: GeWeChat 适配器(统一 Webhook请查看文档了解使用方式
icon: gewechat.png
spec:
config:
- name: gewechat_url
label:
en_US: GeWeChat URL
zh_Hans: GeWeChat URL
description:
en_US: GeWeChat API server address, e.g. http://127.0.0.1:2531
zh_Hans: GeWeChat API 服务器地址,如 http://127.0.0.1:2531
type: string
required: true
default: ""
- name: gewechat_file_url
label:
en_US: GeWeChat file download URL
zh_Hans: GeWeChat 文件下载URL
description:
en_US: GeWeChat file download service address
zh_Hans: GeWeChat 文件下载服务地址
type: string
required: true
default: ""
- name: app_id
label:
en_US: App ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
type: string
required: true
default: ""
execution:
python:
path: ./gewechat.py
attr: GeWeChatAdapter

View File

@@ -5,16 +5,21 @@ metadata:
label:
en_US: KOOK
zh_Hans: KOOK
zh_Hant: KOOK
description:
en_US: KOOK Adapter (formerly KaiHeiLa)
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
icon: kook.png
spec:
categories:
- china
config:
- name: token
label:
en_US: Bot Token
zh_Hans: 机器人令牌
zh_Hant: 機器人令牌
type: string
required: true
default: ""

View File

@@ -5,16 +5,26 @@ metadata:
label:
en_US: Lark
zh_Hans: 飞书
zh_Hant: 飛書
ja_JP: Lark
description:
en_US: Lark Adapter
zh_Hans: 飞书适配器,请查看文档了解使用方式
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
icon: lark.svg
spec:
categories:
- popular
- china
- global
config:
- name: app_id
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
ja_JP: アプリ ID
type: string
required: true
default: ""
@@ -22,6 +32,8 @@ spec:
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
ja_JP: アプリシークレット
type: string
required: true
default: ""
@@ -29,9 +41,13 @@ spec:
label:
en_US: Bot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
ja_JP: ボット名
description:
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
type: string
required: true
default: ""
@@ -39,29 +55,63 @@ spec:
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
zh_Hant: 啟用 Webhook 模式
ja_JP: 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 长连接模式
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
ja_JP: Webhook コールバック URL
description:
en_US: Copy this URL and paste it into your Lark app's webhook configuration
zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中
zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中
ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: encrypt-key
label:
en_US: Encrypt Key
zh_Hans: 加密密钥
zh_Hant: 加密密鑰
ja_JP: 暗号化キー
description:
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
type: string
required: true
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用飞书流式回复模式
zh_Hant: 啟用飛書串流回覆模式
ja_JP: ストリーミング返信モードを有効化
description:
en_US: If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
type: boolean
required: true
default: false
@@ -69,28 +119,40 @@ spec:
label:
en_US: App Type
zh_Hans: 应用类型
zh_Hant: 應用類型
ja_JP: アプリタイプ
description:
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
type: select
options:
- name: self
label:
en_US: Self-built Application
zh_Hans: 自建应用
zh_Hant: 自建應用
ja_JP: カスタムアプリ
- name: isv
label:
en_US: Store Application
zh_Hans: 商店应用
zh_Hant: 商店應用
ja_JP: ストアアプリ
required: false
default: self
- name: bot_added_welcome
label:
en_US: Bot Welcome Message
zh_Hans: 机器人进群欢迎语
zh_Hant: 機器人進群歡迎語
ja_JP: ボット参加時のウェルカムメッセージ
description:
en_US: Welcome message when the bot is added to a group, supports Markdown format
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
type: text
required: false
default: ""

View File

@@ -136,7 +136,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
configuration = Configuration(access_token=config['channel_access_token'])
line_webhook = WebhookHandler(config['channel_secret'])
parser = WebhookParser(config['channel_secret'])

View File

@@ -5,20 +5,52 @@ metadata:
label:
en_US: LINE
zh_Hans: LINE
zh_Hant: LINE
th_TH: LINE
vi_VN: LINE
es_ES: LINE
description:
en_US: LINE Adapter
zh_Hans: LINE适配器请查看文档了解使用方式
ja_JP: LINEアダプター、ドキュメントを参照してください
zh_Hant: LINE適配器,請查看文檔了解使用方式
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
zh_Hans: LINE适配器需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน
vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng
es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso
icon: line.png
spec:
categories:
- global
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
ja_JP: Webhook コールバック URL
zh_Hant: Webhook 回調地址
th_TH: URL การเรียกกลับ Webhook
vi_VN: URL gọi lại Webhook
es_ES: URL de devolución de llamada Webhook
description:
en_US: Copy this URL and paste it into your LINE channel's webhook configuration
zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中
ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください
zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ
vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn
es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE
type: webhook-url
required: false
default: ""
- name: channel_access_token
label:
en_US: Channel access token
zh_Hans: 频道访问令牌
ja_JP: チャンネルアクセストークン
zh_Hant: 頻道訪問令牌
zh_Hant: 頻道存取令牌
th_TH: โทเค็นการเข้าถึงช่อง
vi_VN: Mã truy cập kênh
es_ES: Token de acceso del canal
type: string
required: true
default: ""
@@ -27,12 +59,18 @@ spec:
en_US: Channel secret
zh_Hans: 消息密钥
ja_JP: チャンネルシークレット
zh_Hant: 息密
zh_Hant: 息密
th_TH: รหัสลับช่อง
vi_VN: Khóa bí mật kênh
es_ES: Secreto del canal
description:
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
zh_Hans: 请填写加密密钥
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
zh_Hant: 請填寫加密密
zh_Hant: 請填寫加密密
th_TH: กรุณากรอกคีย์เข้ารหัส
vi_VN: Vui lòng điền khóa mã hóa
es_ES: Por favor, introduzca la clave de cifrado
type: string
required: true
default: ""

View File

@@ -60,7 +60,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
# 校验必填项
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
missing_keys = [k for k in required_keys if k not in config]

View File

@@ -5,23 +5,40 @@ metadata:
label:
en_US: Official Account
zh_Hans: 微信公众号
zh_Hant: 微信公眾號
description:
en_US: Official Account Adapter
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: officialaccount.png
spec:
categories:
- china
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your Official Account webhook configuration
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
type: string
zh_Hant: 令牌
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hant: 訊息加解密密鑰
type: string
required: true
default: ""
@@ -29,6 +46,7 @@ spec:
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
type: string
required: true
default: ""
@@ -36,6 +54,7 @@ spec:
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
type: string
required: true
default: ""
@@ -43,6 +62,7 @@ spec:
label:
en_US: Mode
zh_Hans: 接入模式
zh_Hant: 接入模式
type: string
required: true
default: "drop"
@@ -50,6 +70,7 @@ spec:
label:
en_US: Loading Message
zh_Hans: 加载消息
zh_Hant: 載入訊息
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
@@ -57,9 +78,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API可根据文档修改此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API可根據文件修改此項
type: string
required: false
default: "https://api.weixin.qq.com"

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,70 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: openclaw-weixin
label:
en_US: OpenClaw WeChat
zh_Hans: 个人微信机器人
zh_Hant: 個人微信機器人
description:
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
zh_Hans: 微信官方个人助手,扫码即可登录使用
zh_Hant: 微信官方個人助手,掃碼即可登入使用
icon: wechat.png
spec:
categories:
- popular
- china
config:
- name: base_url
label:
en_US: API Base URL
zh_Hans: API 基础地址
zh_Hant: API 基礎地址
description:
en_US: The base URL of the OpenClaw WeChat backend API
zh_Hans: OpenClaw 微信后端 API 的基础地址
zh_Hant: OpenClaw 微信後端 API 的基礎地址
type: string
required: true
default: "https://ilinkai.weixin.qq.com"
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
description:
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
type: string
required: false
default: ""
- name: account_id
label:
en_US: Account ID
zh_Hans: 账号标识
zh_Hant: 帳號標識
description:
en_US: A label for this WeChat account (used for display purposes)
zh_Hans: 此微信账号的标识(用于显示)
zh_Hant: 此微信帳號的標識(用於顯示)
type: string
required: false
default: "openclaw-weixin"
- name: poll_timeout
label:
en_US: Poll Timeout (seconds)
zh_Hans: 轮询超时(秒)
zh_Hant: 輪詢逾時(秒)
description:
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
type: integer
required: false
default: 35
execution:
python:
path: ./openclaw_weixin.py
attr: OpenClawWeixinAdapter

View File

@@ -132,7 +132,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
)

View File

@@ -5,16 +5,33 @@ metadata:
label:
en_US: QQ Official API
zh_Hans: QQ 官方 API
zh_Hant: QQ 官方 API
description:
en_US: QQ Official API (Webhook)
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
zh_Hans: QQ 官方 API (Webhook)需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: qqofficial.svg
spec:
categories:
- china
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: appid
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
type: string
required: true
default: ""
@@ -22,6 +39,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥
zh_Hant: 密鑰
type: string
required: true
default: ""
@@ -29,6 +47,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""

View File

@@ -5,36 +5,66 @@ metadata:
label:
en_US: Satori
zh_Hans: Satori
zh_Hant: Satori
th_TH: Satori
vi_VN: Satori
es_ES: Satori
description:
en_US: SatoriAdapter
zh_Hans: 古明地觉协议适配器
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
zh_Hant: Satori 協定適配器,支援多種平台的接入,請查看文件了解使用方式
th_TH: อะแดปเตอร์โปรโตคอล Satori รองรับการเชื่อมต่อหลายแพลตฟอร์ม โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
vi_VN: Bộ điều hợp giao thức Satori, hỗ trợ kết nối nhiều nền tảng, vui lòng xem tài liệu để biết cách sử dụng
es_ES: Adaptador del protocolo Satori, soporta acceso a múltiples plataformas, consulte la documentación para obtener instrucciones de uso
icon: satori.png
spec:
categories:
- protocol
config:
- name: platform
label:
en_US: Platform
zh_Hans: 平台名称
zh_Hant: 平台名稱
th_TH: ชื่อแพลตฟอร์ม
vi_VN: Tên nền tảng
es_ES: Nombre de la plataforma
type: string
required: true
default: "llonebot"
description:
en_US: The platform name (e.g., llonebot, discord, telegram)
zh_Hans: 平台名称(如 llonebot, discord, telegram
zh_Hant: 平台名稱(如 llonebot、discord、telegram
th_TH: ชื่อแพลตฟอร์ม (เช่น llonebot, discord, telegram)
vi_VN: "Tên nền tảng (ví dụ: llonebot, discord, telegram)"
es_ES: El nombre de la plataforma (p. ej., llonebot, discord, telegram)
- name: host
label:
en_US: Host
zh_Hans: 主机地址
zh_Hant: 主機地址
th_TH: ที่อยู่โฮสต์
vi_VN: Địa chỉ máy chủ
es_ES: Dirección del host
type: string
required: true
default: "127.0.0.1"
description:
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
zh_Hans: LLOneBot Satori服务器的主机地址如 127.0.0.1, localhost, 192.168.1.100
zh_Hant: LLOneBot Satori 伺服器的主機地址(如 127.0.0.1、localhost、192.168.1.100
th_TH: ที่อยู่โฮสต์ของเซิร์ฟเวอร์ LLOneBot Satori (เช่น 127.0.0.1, localhost, 192.168.1.100)
vi_VN: "Địa chỉ máy chủ LLOneBot Satori (ví dụ: 127.0.0.1, localhost, 192.168.1.100)"
es_ES: La dirección del host del servidor LLOneBot Satori (p. ej., 127.0.0.1, localhost, 192.168.1.100)
- name: port
label:
en_US: Port
zh_Hans: 监听端口
zh_Hant: 監聽連接埠
th_TH: พอร์ต
vi_VN: Cổng
es_ES: Puerto
type: integer
required: true
default: 5600
@@ -42,6 +72,10 @@ spec:
label:
en_US: Satori API Endpoint
zh_Hans: Satori API 终结点
zh_Hant: Satori API 端點
th_TH: จุดปลาย Satori API
vi_VN: Điểm cuối Satori API
es_ES: Punto de acceso de la API Satori
type: string
required: true
default: "http://localhost:5600/v1"
@@ -49,6 +83,10 @@ spec:
label:
en_US: Satori WebSocket Endpoint
zh_Hans: Satori WebSocket 终结点
zh_Hant: Satori WebSocket 端點
th_TH: จุดปลาย Satori WebSocket
vi_VN: Điểm cuối Satori WebSocket
es_ES: Punto de acceso WebSocket de Satori
type: string
required: true
default: "ws://localhost:5600/v1/events"
@@ -56,6 +94,10 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""

View File

@@ -99,7 +99,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
required_keys = [
'bot_token',
'signing_secret',

View File

@@ -5,16 +5,54 @@ metadata:
label:
en_US: Slack
zh_Hans: Slack
zh_Hant: Slack
ja_JP: Slack
th_TH: Slack
vi_VN: Slack
es_ES: Slack
description:
en_US: Slack Adapter
zh_Hans: Slack 适配器,请查看文档了解使用方式
zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式
ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
icon: slack.png
spec:
categories:
- popular
- global
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
ja_JP: Webhook コールバック URL
th_TH: URL การเรียกกลับ Webhook
vi_VN: URL gọi lại Webhook
es_ES: URL de devolución de llamada Webhook
description:
en_US: Copy this URL and paste it into your Slack app's event subscription configuration
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中
ja_JP: この URL をコピーして Slack アプリのイベントサブスクリプション設定に貼り付けてください
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่าการสมัครรับเหตุการณ์ของแอป Slack ของคุณ
vi_VN: Sao chép URL này và dán vào cấu hình đăng ký sự kiện của ứng dụng Slack của bạn
es_ES: Copie esta URL y péguela en la configuración de suscripción de eventos de su aplicación Slack
type: webhook-url
required: false
default: ""
- name: bot_token
label:
en_US: Bot Token
zh_Hans: 机器人令牌
zh_Hant: 機器人令牌
ja_JP: ボットトークン
th_TH: โทเค็นบอท
vi_VN: Mã thông báo Bot
es_ES: Token del bot
type: string
required: true
default: ""
@@ -22,6 +60,11 @@ spec:
label:
en_US: signing_secret
zh_Hans: 密钥
zh_Hant: 密鑰
ja_JP: 署名シークレット
th_TH: คีย์ลายเซ็น
vi_VN: Khóa ký
es_ES: Secreto de firma
type: string
required: true
default: ""

View File

@@ -5,23 +5,46 @@ metadata:
label:
en_US: Telegram
zh_Hans: 电报
zh_Hant: Telegram
ja_JP: Telegram
th_TH: Telegram
vi_VN: Telegram
es_ES: Telegram
description:
en_US: Telegram Adapter
zh_Hans: 电报适配器,请查看文档了解使用方式
zh_Hans: Telegram 适配器,请查看文档了解使用方式
zh_Hant: Telegram 適配器,請查看文件了解使用方式
ja_JP: Telegram アダプター。使用方法の詳細については、ドキュメントを参照してください。
th_TH: อะแดปเตอร์ Telegram โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
vi_VN: Bộ điều hợp Telegram, vui lòng xem tài liệu để biết cách sử dụng
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
icon: telegram.svg
spec:
categories:
- popular
- global
config:
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
ja_JP: トークン
th_TH: โทเค็น
vi_VN: Mã thông báo
es_ES: Token
type: string
required: true
default: ""
default: "token_from_botfather"
- name: markdown_card
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
ja_JP: Markdown カードを使用
th_TH: การ์ด Markdown
vi_VN: Thẻ Markdown
es_ES: Tarjeta Markdown
type: boolean
required: false
default: true
@@ -29,9 +52,19 @@ spec:
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用电报流式回复模式
zh_Hant: 啟用 Telegram 串流回覆模式
ja_JP: ストリーミング返信モードを有効化
th_TH: เปิดใช้งานโหมดตอบกลับแบบสตรีม
vi_VN: Bật chế độ trả lời trực tuyến
es_ES: Habilitar modo de respuesta en streaming
description:
en_US: If enabled, the bot will use the stream of telegram reply mode
zh_Hans: 如果启用,将使用电报流式方式来回复内容
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
type: boolean
required: true
default: false

View File

@@ -5,11 +5,21 @@ metadata:
label:
en_US: "WebSocket Chat"
zh_Hans: "WebSocket 聊天"
zh_Hant: "WebSocket 聊天"
th_TH: "แชท WebSocket"
vi_VN: "Trò chuyện WebSocket"
es_ES: "Chat WebSocket"
description:
en_US: "WebSocket adapter for bidirectional real-time communication"
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
zh_Hant: "用於雙向即時通訊的 WebSocket 適配器"
th_TH: "อะแดปเตอร์ WebSocket สำหรับการสื่อสารแบบเรียลไทม์สองทิศทาง"
vi_VN: "Bộ điều hợp WebSocket cho giao tiếp thời gian thực hai chiều"
es_ES: "Adaptador WebSocket para comunicación bidireccional en tiempo real"
icon: ""
spec:
categories:
- protocol
config: []
execution:
python:

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

@@ -539,7 +539,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
quart_app = quart.Quart(__name__)
message_converter = WeChatPadMessageConverter(config, logger)

View File

@@ -4,17 +4,22 @@ metadata:
name: wechatpad
label:
en_US: WeChatPad
zh_CN: WeChatPad个人微信ipad
zh_Hans: WeChatPad个人微信ipad
zh_Hant: WeChatPad個人微信iPad
description:
en_US: WeChatPad Adapter
zh_CN: WeChatPad 适配器
zh_Hans: WeChatPad 适配器基于WeChatPad的个人微信解决方案请查看文档了解使用方式
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
icon: wechatpad.png
spec:
categories:
- china
config:
- name: wechatpad_url
label:
en_US: WeChatPad ERL
zh_CN: WeChatPad URL
zh_Hant: WeChatPad URL
type: string
required: true
default: ""
@@ -22,6 +27,7 @@ spec:
label:
en_US: WeChatPad_Ws
zh_CN: WeChatPad_Ws
zh_Hant: WeChatPad_Ws
type: string
required: true
default: ""
@@ -29,6 +35,7 @@ spec:
label:
en_US: Admin_Key
zh_CN: 管理员密匙
zh_Hant: 管理員密鑰
type: string
required: true
default: ""
@@ -36,6 +43,7 @@ spec:
label:
en_US: Token
zh_CN: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
@@ -43,6 +51,7 @@ spec:
label:
en_US: wxid
zh_CN: wxid
zh_Hant: wxid
type: string
required: true
default: ""

View File

@@ -148,51 +148,54 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
pass
if type(event) is platform_events.FriendMessage:
payload = {
'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
return event.source_platform_object
@staticmethod
async def target2yiri(event: WecomEvent):
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
"""
将 WecomEvent 转换为平台的 FriendMessage 对象。
Args:
event (WecomEvent): 企业微信事件。
bot (WecomClient): 企业微信客户端,用于获取用户信息。
Returns:
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':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=str(event.agent_id),
nickname=nickname,
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':
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=str(event.agent_id),
nickname=nickname,
remark='',
)
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):
@@ -203,14 +206,13 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
config: dict
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
# 校验必填项
required_keys = [
'corpid',
'secret',
'token',
'EncodingAESKey',
'contacts_secret',
]
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'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
logger=logger,
unified_mode=True,
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)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
fixed_user_id = Wecom_event.user_id
# 删掉开头的u
fixed_user_id = fixed_user_id[1:]
# user_id is the original FromUserName from WecomEvent
user_id = Wecom_event.user_id
for content in content_list:
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':
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':
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':
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):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomEvent):
self.bot_account_id = event.receiver_id
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:
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')

View File

@@ -5,16 +5,34 @@ metadata:
label:
en_US: WeCom
zh_Hans: 企业微信
zh_Hant: 企業微信
description:
en_US: WeCom Adapter
zh_Hans: 企业微信适配器,请查看文档了解使用方式
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
icon: wecom.png
spec:
categories:
- popular
- china
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your WeCom app's webhook configuration
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: corpid
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
@@ -22,6 +40,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥 (Secret)
zh_Hant: 密鑰 (Secret)
type: string
required: true
default: ""
@@ -29,6 +48,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
type: string
required: true
default: ""
@@ -36,13 +56,7 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
type: string
required: true
default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
type: string
required: true
default: ""
@@ -50,9 +64,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档填写此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API可根據文件填寫此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"

View File

@@ -10,8 +10,10 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger
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, StreamSession
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
from ...core import app as langbot_app
from ...pipeline.monitoring_helper import FeedbackMonitor
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -192,8 +194,10 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_ws_mode: bool = False
bot_name: str = ''
listeners: dict = {}
ap: langbot_app.Application = None # Application reference for monitoring
_bot_info: dict = None # Bot info for monitoring (bot_id, bot_name, pipeline_id, pipeline_name)
def __init__(self, config: dict, logger: EventLogger):
def __init__(self, config: dict, logger: EventLogger, ap: langbot_app.Application = None, **kwargs):
enable_webhook = config.get('enable-webhook', False)
bot_name = config.get('robot_name', '')
@@ -228,8 +232,14 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id=bot_account_id,
bot_name=bot_name,
event_converter=event_converter,
**kwargs,
)
self.listeners = {}
object.__setattr__(self, '_ws_mode', ws_mode)
object.__setattr__(self, 'ap', ap)
# Register feedback handler for monitoring
self._register_feedback_handler()
async def reply_message(
self,
@@ -277,14 +287,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
"""智能机器人侧默认开启流式能力。
Returns:
bool: 恒定返回 True。
Example:
流水线执行阶段会调用此方法以确认是否启用流式。"""
return True
"""Whether streaming output is enabled for this bot instance."""
return self.config.get('enable-stream-reply', True)
async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False)
@@ -324,6 +328,66 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
def set_bot_info(
self,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
):
"""设置 bot 信息(用于监控记录)"""
self._bot_info = {
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
}
def _register_feedback_handler(self):
"""注册用户反馈处理器,用于持久化反馈数据到监控服务"""
async def handle_feedback(
feedback_id: str,
feedback_type: int,
feedback_content: str,
inaccurate_reasons: list[str],
session: StreamSession,
):
"""处理用户反馈事件,持久化到监控服务"""
if not self.ap or not self._bot_info:
return
try:
# Build session_id from session info
session_id = None
if session.chat_id:
session_id = f'group_{session.chat_id}'
elif session.user_id:
session_id = f'person_{session.user_id}'
await FeedbackMonitor.record_feedback(
ap=self.ap,
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content if feedback_content else None,
inaccurate_reasons=inaccurate_reasons if inaccurate_reasons else None,
bot_id=self._bot_info['bot_id'],
bot_name=self._bot_info['bot_name'],
pipeline_id=self._bot_info['pipeline_id'],
pipeline_name=self._bot_info['pipeline_name'],
session_id=session_id,
message_id=session.msg_id if session else None,
stream_id=session.stream_id if session else None,
user_id=session.user_id if session else None,
platform='wecom',
)
except Exception:
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
# Register the feedback handler with the bot client
if hasattr(self.bot, 'on_feedback'):
self.bot.on_feedback()(handle_feedback)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:

View File

@@ -5,16 +5,21 @@ metadata:
label:
en_US: WeComBot
zh_Hans: 企业微信智能机器人
zh_Hant: 企業微信智慧機器人
description:
en_US: WeComBot Adapter
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
icon: wecombot.png
spec:
categories:
- china
config:
- name: BotId
label:
en_US: BotId
zh_Hans: 机器人ID (BotId)
zh_Hant: 機器人ID (BotId)
type: string
required: true
default: ""
@@ -22,6 +27,7 @@ spec:
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
@@ -29,19 +35,39 @@ spec:
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
zh_Hant: 啟用 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 长连接模式
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your WeComBot webhook configuration
zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中
zh_Hant: 複製此地址並貼到企業微信智慧機器人的 Webhook 設定中
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: Secret
label:
en_US: Secret
zh_Hans: 机器人密钥 (Secret)
zh_Hant: 機器人密鑰 (Secret)
description:
en_US: Required for WebSocket long connection mode
zh_Hans: 使用 WS 长连接模式时必填
zh_Hant: 使用 WS 長連線模式時必填
type: string
required: false
default: ""
@@ -49,9 +75,11 @@ spec:
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
zh_Hant: 使用 Webhook 模式時必填
type: string
required: false
default: ""
@@ -59,9 +87,11 @@ spec:
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
description:
en_US: Required for Webhook mode
zh_Hans: 使用 Webhook 模式时必填
zh_Hant: 使用 Webhook 模式時必填
type: string
required: false
default: ""
@@ -69,12 +99,26 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
description:
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
type: string
required: false
default: ""
- name: enable-stream-reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
description:
en_US: If enabled, the bot will use streaming mode to reply messages
zh_Hans: 如果启用,机器人将使用流式模式回复消息
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
type: boolean
required: false
default: true
execution:
python:
path: ./wecombot.py

View File

@@ -5,16 +5,33 @@ metadata:
label:
en_US: WeComCustomerService
zh_Hans: 企业微信客服
zh_Hant: 企業微信客服
description:
en_US: WeComCSAdapter
zh_Hans: 企业微信客服适配器
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: wecom.png
spec:
categories:
- china
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your WeCom Customer Service webhook configuration
zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中
zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: corpid
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
@@ -22,6 +39,7 @@ spec:
label:
en_US: Secret
zh_Hans: 密钥
zh_Hant: 密鑰
type: string
required: true
default: ""
@@ -29,6 +47,7 @@ spec:
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
@@ -36,6 +55,7 @@ spec:
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hant: 訊息加解密密鑰
type: string
required: true
default: ""
@@ -43,9 +63,11 @@ spec:
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档修改此项
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API可根據文件修改此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
import asyncio
import io
import time
import zipfile
from typing import Any
import typing
import os
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
return await self.handler.ping()
def _extract_deps_metadata(
self,
file_bytes: bytes,
task_context: taskmgr.TaskContext | None,
):
"""Extract dependency count from requirements.txt inside plugin zip."""
if task_context is None:
return
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
for name in zf.namelist():
if name.endswith('requirements.txt'):
content = zf.read(name).decode('utf-8', errors='ignore')
deps = [
line.strip()
for line in content.splitlines()
if line.strip() and not line.strip().startswith('#')
]
task_context.metadata['deps_total'] = len(deps)
task_context.metadata['deps_list'] = deps
break
except Exception:
pass
async def install_plugin(
self,
install_source: PluginInstallSource,
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
if install_source == PluginInstallSource.LOCAL:
# transfer file before install
file_bytes = install_info['plugin_file']
self._extract_deps_metadata(file_bytes, task_context)
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
elif install_source == PluginInstallSource.GITHUB:
# download and transfer file
# download and transfer file with streaming progress
try:
async with httpx.AsyncClient(
trust_env=True,
follow_redirects=True,
timeout=20,
timeout=60,
) as client:
response = await client.get(
install_info['asset_url'],
)
response.raise_for_status()
file_bytes = response.content
async with client.stream('GET', install_info['asset_url']) as response:
response.raise_for_status()
total = int(response.headers.get('content-length', 0))
downloaded = 0
chunks: list[bytes] = []
start_time = time.time()
if task_context is not None:
task_context.set_current_action('downloading plugin package')
task_context.metadata['download_total'] = total
task_context.metadata['download_current'] = 0
task_context.metadata['download_speed'] = 0
async for chunk in response.aiter_bytes(chunk_size=8192):
chunks.append(chunk)
downloaded += len(chunk)
if task_context is not None:
elapsed = time.time() - start_time
task_context.metadata['download_current'] = downloaded
task_context.metadata['download_total'] = total
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
file_bytes = b''.join(chunks)
self._extract_deps_metadata(file_bytes, task_context)
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
if task_context is not None:
task_context.trace(trace)
# Forward structured metadata from runtime
metadata = ret.get('metadata', None)
if metadata is not None and task_context is not None:
task_context.metadata.update(metadata)
async def upgrade_plugin(
self,
plugin_author: str,

View File

@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
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)
return handler.ActionResponse.success(
data={
'llm_models': llm_models,
'llm_models': [m['uuid'] for m in llm_models],
},
)
@@ -531,6 +531,7 @@ class RuntimeConnectionHandler(handler.Handler):
filters = data.get('filters')
search_type = data.get('search_type', 'vector')
query_text = data.get('query_text', '')
vector_weight = data.get('vector_weight')
try:
results = await self.ap.rag_runtime_service.vector_search(
collection_id,
@@ -539,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
filters,
search_type,
query_text,
vector_weight=vector_weight,
)
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
@@ -613,6 +615,47 @@ class RuntimeConnectionHandler(handler.Handler):
# ================= 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."""

View File

@@ -41,6 +41,7 @@ class RAGRuntimeService:
filters: dict[str, Any] | None = None,
search_type: str = 'vector',
query_text: str = '',
vector_weight: float | None = None,
) -> list[dict[str, Any]]:
"""Handle VECTOR_SEARCH action."""
return await self.ap.vector_db_mgr.search(
@@ -50,6 +51,7 @@ class RAGRuntimeService:
filter=filters,
search_type=search_type,
query_text=query_text,
vector_weight=vector_weight,
)
async def vector_delete(

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 24
required_database_version = 25
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -97,6 +97,7 @@ class VectorDBManager:
filter: dict | None = None,
search_type: str = 'vector',
query_text: str = '',
vector_weight: float | None = None,
) -> list[dict]:
"""Proxy: Search vectors.
@@ -111,6 +112,7 @@ class VectorDBManager:
search_type=search_type,
query_text=query_text,
filter=filter,
vector_weight=vector_weight,
)
if not results or 'ids' not in results or not results['ids']:

View File

@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
{"file_id": "abc"}
{"created_at": {"$gte": 1700000000}}
{"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

View File

@@ -52,13 +52,16 @@ class ChromaVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]:
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)
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)
@@ -127,6 +130,7 @@ class ChromaVectorDatabase(VectorDatabase):
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:
@@ -144,7 +148,15 @@ class ChromaVectorDatabase(VectorDatabase):
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
# RRF fusion
fused = self._rrf_fuse([vector_ids, text_ids], k)
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': [[]]}
@@ -197,16 +209,24 @@ class ChromaVectorDatabase(VectorDatabase):
}
@staticmethod
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
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 ranked_ids in result_lists:
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) + 1.0 / (_RRF_K + rank + 1)
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]

View File

@@ -255,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection

View File

@@ -192,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance

View File

@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]:
exists = await self.client.collection_exists(collection)
if not exists:

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from decimal import Decimal
import re
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:
"""Internal method to get or create a collection with proper configuration."""
collection = self._normalize_collection_name(collection)
if collection in self._collections:
return self._collections[collection]
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
if not embeddings_list:
return
collection = self._normalize_collection_name(collection)
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size)
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: Dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
query_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(
coll.hybrid_search,
query=query_cfg,
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
else:
# Default: vector search via query()
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
query_kwargs['where'] = filter
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name
file_id: File ID to delete
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name
filter: Chroma-style ``where`` filter dict
"""
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
@@ -347,6 +389,7 @@ class SeekDBVectorDatabase(VectorDatabase):
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
@@ -367,6 +410,7 @@ class SeekDBVectorDatabase(VectorDatabase):
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)
@@ -390,6 +434,7 @@ class SeekDBVectorDatabase(VectorDatabase):
Args:
collection: Collection name
"""
collection = self._normalize_collection_name(collection)
# Remove from cache
if collection in self._collections:
del self._collections[collection]

View File

@@ -78,6 +78,14 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
monitoring:
auto_cleanup:
# Enable automatic cleanup of expired monitoring records
enabled: true
# Retention period in days, records older than this will be deleted
retention_days: 30
# Cleanup check interval in hours
check_interval_hours: 1
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'

View File

@@ -23,30 +23,30 @@ stages:
label:
en_US: Local Agent
zh_Hans: 内置 Agent
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
- name: dify-service-api
label:
en_US: Dify Service API
zh_Hans: Dify 服务 API
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
- name: n8n-service-api
label:
en_US: n8n Workflow API
zh_Hans: n8n 工作流 API
- name: langflow-api
label:
en_US: Langflow API
zh_Hans: Langflow API
- name: coze-api
label:
en_US: Coze API
zh_Hans: 扣子 API
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
- name: langflow-api
label:
en_US: Langflow API
zh_Hans: Langflow API
- name: local-agent
label:
en_US: Local Agent
@@ -74,6 +74,10 @@ stages:
type: integer
required: true
default: 10
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: prompt
label:
en_US: Prompt
@@ -83,6 +87,9 @@ stages:
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor
required: true
default:
- role: system
content: "You are a helpful assistant."
- name: knowledge-bases
label:
en_US: Knowledge Bases
@@ -93,26 +100,10 @@ stages:
type: knowledge-base-multi-selector
required: false
default: []
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
description:
en_US: Configure the Tbox App API of the pipeline
zh_Hans: 配置蚂蚁百宝箱平台 API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: dify-service-api
label:
en_US: Dify Service API
@@ -127,6 +118,12 @@ stages:
zh_Hans: 基础 URL
type: string
required: true
options:
- name: 'https://api.dify.ai/v1'
label:
en_US: Dify Cloud
zh_Hans: Dify 云服务
default: 'https://api.dify.ai/v1'
- name: base-prompt
label:
en_US: Base PROMPT
@@ -163,52 +160,7 @@ stages:
zh_Hans: API 密钥
type: string
required: true
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
description:
en_US: Configure the Aliyun Dashscope App API of the pipeline
zh_Hans: 配置阿里云百炼平台 API
config:
- name: app-type
label:
en_US: App Type
zh_Hans: 应用类型
type: select
required: true
default: agent
options:
- name: agent
label:
en_US: Agent
zh_Hans: Agent
- name: workflow
label:
en_US: Workflow
zh_Hans: 工作流
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
- name: references_quote
label:
en_US: References Quote
zh_Hans: 引用文本
description:
en_US: The text prompt when the references are included
zh_Hans: 包含引用资料时的文本提示
type: string
required: false
default: '参考资料来自:'
default: 'your-api-key'
- name: n8n-service-api
label:
en_US: n8n Workflow API
@@ -226,6 +178,7 @@ stages:
zh_Hans: n8n 工作流的 webhook URL
type: string
required: true
default: 'http://your-n8n-webhook-url'
- name: auth-type
label:
en_US: Authentication Type
@@ -263,6 +216,10 @@ stages:
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: basic-password
label:
en_US: Password
@@ -273,6 +230,10 @@ stages:
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: jwt-secret
label:
en_US: Secret
@@ -283,6 +244,10 @@ stages:
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: jwt-algorithm
label:
en_US: Algorithm
@@ -293,6 +258,10 @@ stages:
type: string
required: false
default: 'HS256'
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: header-name
label:
en_US: Header Name
@@ -303,6 +272,10 @@ stages:
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: header-value
label:
en_US: Header Value
@@ -313,6 +286,10 @@ stages:
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: timeout
label:
en_US: Timeout
@@ -333,6 +310,140 @@ stages:
type: string
required: false
default: 'response'
- name: coze-api
label:
en_US: coze API
zh_Hans: 扣子 API
description:
en_US: Configure the Coze API of the pipeline
zh_Hans: 配置Coze API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Coze server
zh_Hans: Coze服务器的 API 密钥
type: string
required: true
default: ''
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: The ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
default: ''
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
zh_Hans: Coze API 的基础 URL请使用 https://api.coze.com 用于全球 Coze 版coze.com
type: string
options:
- name: 'https://api.coze.cn'
label:
en_US: Coze China
zh_Hans: Coze 中国版
- name: 'https://api.coze.com'
label:
en_US: Coze Global
zh_Hans: Coze 全球版
default: "https://api.coze.cn"
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
default: true
- name: timeout
label:
en_US: Request Timeout
zh_Hans: 请求超时
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
default: 120
- name: tbox-app-api
label:
en_US: Tbox App API
zh_Hans: 蚂蚁百宝箱平台 API
description:
en_US: Configure the Tbox App API of the pipeline
zh_Hans: 配置蚂蚁百宝箱平台 API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
default: ''
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
default: ''
- name: dashscope-app-api
label:
en_US: Aliyun Dashscope App API
zh_Hans: 阿里云百炼平台 API
description:
en_US: Configure the Aliyun Dashscope App API of the pipeline
zh_Hans: 配置阿里云百炼平台 API
config:
- name: app-type
label:
en_US: App Type
zh_Hans: 应用类型
type: select
required: true
default: agent
options:
- name: agent
label:
en_US: Agent
zh_Hans: Agent
- name: workflow
label:
en_US: Workflow
zh_Hans: 工作流
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
type: string
required: true
default: 'your-api-key'
- name: app-id
label:
en_US: App ID
zh_Hans: 应用 ID
type: string
required: true
default: 'your-app-id'
- name: references_quote
label:
en_US: References Quote
zh_Hans: 引用文本
description:
en_US: The text prompt when the references are included
zh_Hans: 包含引用资料时的文本提示
type: string
required: false
default: '参考资料来自:'
- name: langflow-api
label:
en_US: Langflow API
@@ -350,6 +461,7 @@ stages:
zh_Hans: Langflow 服务器的基础 URL
type: string
required: true
default: 'http://localhost:7860'
- name: api-key
label:
en_US: API Key
@@ -359,6 +471,7 @@ stages:
zh_Hans: Langflow 服务器的 API 密钥
type: string
required: true
default: 'your-api-key'
- name: flow-id
label:
en_US: Flow ID
@@ -368,6 +481,7 @@ stages:
zh_Hans: 要运行的流程 ID
type: string
required: true
default: 'your-flow-id'
- name: input-type
label:
en_US: Input Type
@@ -397,57 +511,4 @@ stages:
zh_Hans: 可选的流程调整参数
type: json
required: false
default: '{}'
- name: coze-api
label:
en_US: coze API
zh_Hans: 扣子 API
description:
en_US: Configure the Coze API of the pipeline
zh_Hans: 配置Coze API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Coze server
zh_Hans: Coze服务器的 API 密钥
type: string
required: true
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: The ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
zh_Hans: Coze API 的基础 URL请使用 https://api.coze.com 用于全球 Coze 版coze.com
type: string
default: "https://api.coze.cn"
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
default: true
- name: timeout
label:
en_US: Request Timeout
zh_Hans: 请求超时
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
default: 120
default: '{}'

10
uv.lock generated
View File

@@ -1832,7 +1832,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.3"
version = "4.9.4"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1937,7 +1937,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.3" },
{ name = "langbot-plugin", specifier = "==0.3.5" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1993,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.3"
version = "0.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2011,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/be/78a0375aec6aad34bc2f00e2b4d2511ec597e8143cf8596d0736e8767904/langbot_plugin-0.3.3.tar.gz", hash = "sha256:5b07609b9b08f8b24fefcf29b97b9882838c140143de6d4bac2f320511ef4374", size = 170709, upload-time = "2026-03-19T12:40:23.877Z" }
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 = [
{ url = "https://files.pythonhosted.org/packages/92/53/c708f3ef9459420974f1b131cffd43cfb2f97220350c26a6b5fbadbd6211/langbot_plugin-0.3.3-py3-none-any.whl", hash = "sha256:cca94a2ed07c1d3ec4be33f97268746908ed304b528d0eb7f23308677fe619ca", size = 145188, upload-time = "2026-03-19T12:40:25.094Z" },
{ 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]]

View File

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

View File

@@ -25,6 +25,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.15",
@@ -33,6 +34,7 @@
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.8",

86
web/pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ dependencies:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-checkbox':
specifier: ^1.3.1
version: 1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
@@ -47,6 +50,9 @@ dependencies:
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.9
version: 1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
@@ -922,6 +928,30 @@ packages:
react-dom: 19.2.1(react@19.2.1)
dev: false
/@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.10)(react@19.2.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1)
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
dev: false
/@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
@@ -1050,6 +1080,19 @@ packages:
react: 19.2.1
dev: false
/@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.1):
resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 19.2.10
react: 19.2.1
dev: false
/@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
@@ -1425,6 +1468,27 @@ packages:
react-dom: 19.2.1(react@19.2.1)
dev: false
/@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
dev: false
/@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -1757,6 +1821,20 @@ packages:
react: 19.2.1
dev: false
/@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.10)(react@19.2.1):
resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 19.2.10
react: 19.2.1
use-sync-external-store: 1.6.0(react@19.2.1)
dev: false
/@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.10)(react@19.2.1):
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
@@ -6529,6 +6607,14 @@ packages:
tslib: 2.8.1
dev: false
/use-sync-external-store@1.6.0(react@19.2.1):
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
react: 19.2.1
dev: false
/uuidjs@5.1.0:
resolution: {integrity: sha512-HAQPtUkr7t5Ud3uCwRcqtBRNagu/2aerrrBQE6PzgSluGijvFF75UaOq22Xw545GGviRjSLhc4c8CaSMI5h4Ng==}
hasBin: true

View File

@@ -46,8 +46,12 @@ function SpaceOAuthCallbackContent() {
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
// If wizard state exists, redirect back to wizard instead of home
const wizardState = localStorage.getItem('langbot_wizard_state');
const redirectTo = wizardState ? '/wizard' : '/home';
setTimeout(() => {
router.push('/home');
router.push(redirectTo);
}, 1000);
} catch (err) {
setStatus('error');

View File

@@ -114,22 +114,23 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-twinkle: twinkle 1.5s ease-in-out infinite;
}
.dark {
--background: oklch(0.08 0.002 285.823);
--background: oklch(0.17 0.003 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.12 0.004 285.885);
--card: oklch(0.16 0.004 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.12 0.004 285.885);
--popover: oklch(0.16 0.004 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.2 255);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.18 0.004 286.033);
--secondary: oklch(0.27 0.005 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.18 0.004 286.033);
--muted: oklch(0.27 0.005 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.18 0.004 286.033);
--accent: oklch(0.27 0.005 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%);
@@ -140,7 +141,7 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.1 0.003 285.885);
--sidebar: oklch(0.05 0.002 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.62 0.2 255);
--sidebar-primary-foreground: oklch(1 0 0);
@@ -158,3 +159,23 @@
@apply bg-background text-foreground;
}
}
@keyframes twinkle {
0%,
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
25% {
opacity: 0.6;
transform: scale(0.85) rotate(-8deg);
}
50% {
opacity: 1;
transform: scale(1.15) rotate(4deg);
}
75% {
opacity: 0.7;
transform: scale(0.95) rotate(-4deg);
}
}

View File

@@ -0,0 +1,319 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const { t } = useTranslation();
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('bots.createBot'));
} else {
const bot = bots.find((b) => b.id === id);
setDetailEntityName(bot?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, bots, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// Enable state managed here so the header switch works
const [botEnabled, setBotEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
}
}, [id, isCreateMode]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
const prev = botEnabled;
setBotEnabled(checked);
try {
// Fetch current bot data to send a complete update
const res = await httpClient.getBot(id);
const bot = res.bot;
await httpClient.updateBot(id, {
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapter_config: bot.adapter_config,
enable: checked,
});
refreshBots();
} catch {
setBotEnabled(prev);
toast.error(t('bots.setBotEnableError'));
}
},
[id, botEnabled, refreshBots, t],
);
function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
});
refreshBots();
}
function handleBotDeleted() {
refreshBots();
router.push('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
function confirmDelete() {
httpClient
.deleteBot(id)
.then(() => {
setShowDeleteConfirm(false);
toast.success(t('bots.deleteSuccess'));
handleBotDeleted();
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
}
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
</div>
</div>
</div>
);
}
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* Sticky Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
/>
<Label
htmlFor="bot-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
</div>
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
{activeTab === 'sessions' && (
<button
type="button"
className="inline-flex items-center justify-center ml-0.5"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3 text-muted-foreground hover:text-foreground transition-colors',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</TabsTrigger>
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Tab: Logs */}
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
{/* Tab: Sessions */}
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
</Tabs>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('bots.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,297 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogDescription,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { httpClient } from '@/app/infra/http/HttpClient';
interface BotDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
botId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFormSubmit: (value: z.infer<any>) => void;
onFormCancel: () => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void;
}
export default function BotDetailDialog({
open,
onOpenChange,
botId: propBotId,
onFormSubmit,
onFormCancel,
onBotDeleted,
onNewBotCreated,
}: BotDetailDialogProps) {
const { t } = useTranslation();
const [botId, setBotId] = useState<string | undefined>(propBotId);
const [activeMenu, setActiveMenu] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
setBotId(propBotId);
setActiveMenu('config');
}, [propBotId, open]);
const menu = [
{
key: 'config',
label: t('bots.configuration'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
</svg>
),
},
{
key: 'logs',
label: t('bots.logs'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
{
key: 'sessions',
label: t('bots.sessionMonitor.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
),
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleFormSubmit = (value: any) => {
onFormSubmit(value);
};
const handleFormCancel = () => {
onFormCancel();
};
const handleBotDeleted = () => {
httpClient.deleteBot(botId ?? '').then(() => {
onBotDeleted();
});
};
const handleNewBotCreated = (newBotId: string) => {
setBotId(newBotId);
setActiveMenu('config');
onNewBotCreated(newBotId);
};
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
handleBotDeleted();
setShowDeleteConfirm(false);
};
if (!botId) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('bots.createBot')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.createBot')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
</main>
</DialogContent>
</Dialog>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex">
<SidebarProvider className="items-start w-full flex">
<Sidebar
collapsible="none"
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={activeMenu === item.key}
onClick={() => setActiveMenu(item.key)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-[75vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{activeMenu === 'config'
? t('bots.editBot')
: activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogTitle>
<DialogDescription className="sr-only">
{activeMenu === 'config'
? t('bots.editBot')
: activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogDescription>
</DialogHeader>
<div
className={
activeMenu === 'sessions'
? 'flex-1 min-h-0'
: 'flex-1 overflow-y-auto px-6 pb-6'
}
>
{activeMenu === 'config' && (
<BotForm
initBotId={botId}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
)}
{activeMenu === 'logs' && botId && (
<BotLogListComponent botId={botId} />
)}
{activeMenu === 'sessions' && botId && (
<BotSessionMonitor botId={botId} />
)}
</div>
{activeMenu === 'config' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
>
{t('common.delete')}
</Button>
<Button type="submit" form="bot-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
)}
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('bots.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -3,7 +3,7 @@
height: 10rem;
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
border: 1px solid #e4e4e7;
padding: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
@@ -11,15 +11,15 @@
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
border-color: #27272a;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
border-color: #a1a1aa;
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
border-color: #3f3f46;
}
.iconBasicInfoContainer {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
IChooseAdapterEntity,
IPipelineEntity,
@@ -19,20 +19,11 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -44,19 +35,28 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import {
groupByCategory,
getCategoryLabel,
} from '@/app/infra/entities/adapter-categories';
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('bots.botNameRequired') }),
description: z
.string()
.min(1, { message: t('bots.botDescriptionRequired') }),
description: z.string().optional(),
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(),
@@ -66,13 +66,13 @@ const getFormSchema = (t: (key: string) => string) =>
export default function BotForm({
initBotId,
onFormSubmit,
onBotDeleted,
onNewBotCreated,
onDirtyChange,
}: {
initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
@@ -81,7 +81,7 @@ export default function BotForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: t('bots.defaultDescription'),
description: '',
adapter: '',
adapter_config: {},
enable: true,
@@ -89,19 +89,16 @@ export default function BotForm({
},
});
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
// Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty.
const isInitializing = useRef(true);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
useState(new Map<string, IDynamicFormItemSchema[]>());
// const [form] = Form.useForm<IBotFormEntity>();
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
// const [dynamicForm] = Form.useForm();
const [adapterNameList, setAdapterNameList] = useState<
IChooseAdapterEntity[]
>([]);
const [adapterIconList, setAdapterIconList] = useState<
Record<string, string>
>({});
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
Record<string, string>
>({});
@@ -116,89 +113,45 @@ export default function BotForm({
const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
const [copied, setCopied] = useState<boolean>(false);
const [extraCopied, setExtraCopied] = useState<boolean>(false);
// Watch adapter and adapter_config for filtering
const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config');
// Derive the filtered config list via useMemo instead of useEffect+setState
// to avoid creating new array references that would cause DynamicFormComponent
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
// Only depend on the specific field we care about (enable-webhook) rather than
// 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]);
// Group adapters by category for the Select dropdown
const groupedAdapters = useMemo(
() => groupByCategory(adapterNameList),
[adapterNameList],
);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
setBotFormValues();
}, []);
// 复制到剪贴板的辅助函数
const copyToClipboard = (
text: string,
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setStatus(true);
setTimeout(() => setStatus(false), 2000);
})
.catch(() => {
// 降级创建临时textarea复制
fallbackCopy(text, setStatus);
});
} else {
fallbackCopy(text, setStatus);
}
};
const fallbackCopy = (
text: string,
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (successful) {
setStatus(true);
setTimeout(() => setStatus(false), 2000);
}
};
function setBotFormValues() {
isInitializing.current = true;
initBotFormComponent().then(() => {
// 拉取初始化表单信息
if (initBotId) {
getBotConfig(initBotId)
.then((val) => {
form.setValue('name', val.name);
form.setValue('description', val.description);
form.setValue('adapter', val.adapter);
form.setValue('adapter_config', val.adapter_config);
form.setValue('enable', val.enable);
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
});
handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
@@ -210,50 +163,42 @@ export default function BotForm({
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
});
}
async function initBotFormComponent() {
// 初始化流水线列表
const pipelinesRes = await httpClient.getPipelines();
setPipelineNameList(
pipelinesRes.pipelines.map((item) => {
return {
label: item.name,
value: item.uuid ?? '',
emoji: item.emoji,
};
}),
);
// 拉取adapter
const adaptersRes = await httpClient.getAdapters();
setAdapterNameList(
adaptersRes.adapters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
categories: item.spec.categories,
};
}),
);
// 初始化适配器图标列表
setAdapterIconList(
adaptersRes.adapters.reduce(
(acc, item) => {
acc[item.name] = httpClient.getAdapterIconURL(item.name);
return acc;
},
{} as Record<string, string>,
),
);
// 初始化适配器描述列表
setAdapterDescriptionList(
adaptersRes.adapters.reduce(
(acc, item) => {
@@ -264,7 +209,6 @@ export default function BotForm({
),
);
// 初始化适配器表单map
adaptersRes.adapters.forEach((rawAdapter) => {
adapterNameToDynamicConfigMap.set(
rawAdapter.name,
@@ -341,15 +285,13 @@ export default function BotForm({
}
}
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
function onDynamicFormSubmit() {
setIsLoading(true);
if (initBotId) {
// 编辑提交
const updateBot: Bot = {
uuid: initBotId,
name: form.getValues().name,
description: form.getValues().description,
description: form.getValues().description ?? '',
adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
@@ -358,6 +300,8 @@ export default function BotForm({
httpClient
.updateBot(initBotId, updateBot)
.then(() => {
// Reset dirty baseline to current values so isDirty becomes false
form.reset(form.getValues());
onFormSubmit(form.getValues());
toast.success(t('bots.saveSuccess'));
})
@@ -366,14 +310,11 @@ export default function BotForm({
})
.finally(() => {
setIsLoading(false);
// form.reset();
// dynamicForm.resetFields();
});
} else {
// 创建提交
const newBot: Bot = {
name: form.getValues().name,
description: form.getValues().description,
description: form.getValues().description ?? '',
adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config,
};
@@ -393,181 +334,24 @@ export default function BotForm({
.finally(() => {
setIsLoading(false);
form.reset();
// dynamicForm.resetFields();
});
}
}
function deleteBot() {
if (initBotId) {
httpClient
.deleteBot(initBotId)
.then(() => {
onBotDeleted();
toast.success(t('bots.deleteSuccess'));
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
}
}
return (
<div>
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
<Form {...form}>
<form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
</Button>
<Button
variant="destructive"
onClick={() => {
deleteBot();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && (
<>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl &&
(currentAdapter !== 'lark' || enableWebhook !== false) && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={webhookUrl}
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(webhookUrl, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{t('common.copy')}
</Button>
</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">
{extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
)}
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
@@ -575,7 +359,7 @@ export default function BotForm({
<FormItem>
<FormLabel>
{t('bots.botName')}
<span className="text-red-500">*</span>
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
@@ -589,10 +373,7 @@ export default function BotForm({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.botDescription')}
<span className="text-red-500">*</span>
</FormLabel>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -600,7 +381,84 @@ export default function BotForm({
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>{pipeline?.label ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
@@ -608,76 +466,91 @@ export default function BotForm({
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-red-500">*</span>
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="relative">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue placeholder={t('bots.selectAdapter')} />
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{adapterNameList.map((item) => (
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
))}
</SelectContent>
</Select>
</FormControl>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{form.watch('adapter') && (
<div className="flex items-start gap-3 p-4 rounded-lg border">
<img
src={adapterIconList[form.watch('adapter')]}
alt="adapter icon"
className="w-12 h-12 rounded-[8%]"
/>
<div className="flex flex-col gap-1">
<div className="font-medium">
{
adapterNameList.find(
(item) => item.value === form.watch('adapter'),
)?.label
}
</div>
<div className="text-sm text-gray-500">
{adapterDescriptionList[form.watch('adapter')]}
</div>
</div>
</div>
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
}}
/>
)}
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
<div className="space-y-4">
<div className="text-lg font-medium">
{t('bots.adapterConfig')}
</div>
<DynamicFormComponent
itemConfigList={filteredDynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values);
}}
/>
</div>
)}
</div>
</form>
</Form>
</div>
</CardContent>
</Card>
</form>
</Form>
);
}

View File

@@ -1,9 +1,11 @@
export interface IChooseAdapterEntity {
label: string;
value: string;
categories?: string[];
}
export interface IPipelineEntity {
label: string;
value: string;
emoji?: string;
}

View File

@@ -2,220 +2,187 @@
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
export function BotLogCard({ botLog }: { botLog: BotLog }) {
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
warning:
'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
debug: 'bg-muted text-muted-foreground',
};
const SHORT_TEXT_LIMIT = 120;
export function BotLogCard({
botLog,
defaultExpanded = false,
}: {
botLog: BotLog;
defaultExpanded?: boolean;
}) {
const { t } = useTranslation();
const baseURL = httpClient.getBaseUrl();
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(defaultExpanded);
function copySessionId() {
const text = botLog.message_session_id;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
// Fallback 复制方法,用于不支持 clipboard API 的环境
function fallbackCopy(text: string) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(textArea);
document.body.removeChild(ta);
}
function formatTime(timestamp: number) {
const now = new Date();
const date = new Date(timestamp * 1000);
// 获取各个时间部分
const year = date.getFullYear();
const month = date.getMonth() + 1; // 月份从0开始需要+1
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断时间范围
const isToday = now.toDateString() === date.toDateString();
const isYesterday =
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
date.toDateString();
const isThisYear = now.getFullYear() === year;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = yesterday.toDateString() === date.toDateString();
const isThisYear = now.getFullYear() === date.getFullYear();
if (isToday) {
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
} else if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
} else if (isThisYear) {
return t('bots.dateFormat', { month, day }); // 本年消息x月x日
} else {
return t('bots.earlier'); // 更早的消息:更久之前
}
if (isToday) return `${hours}:${minutes}`;
if (isYesterday) return `${t('bots.yesterday')} ${hours}:${minutes}`;
if (isThisYear)
return t('bots.dateFormat', {
month: date.getMonth() + 1,
day: date.getDate(),
});
return t('bots.earlier');
}
function getSubChatId(str: string) {
const strArr = str.split('');
return strArr;
}
// 根据日志级别返回对应的样式类
function getLevelStyles(level: string) {
switch (level.toLowerCase()) {
case 'error':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'warning':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'info':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'debug':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
}
}
// 截取文本的简短版本
function getShortText(text: string, maxLength: number = 100) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// 判断是否需要展开按钮
const needsExpand = botLog.text.length > 100 || botLog.images.length > 0;
const needsExpand =
botLog.text.length > SHORT_TEXT_LIMIT || botLog.images.length > 0;
const levelStyle =
LEVEL_STYLES[botLog.level.toLowerCase()] ?? LEVEL_STYLES.debug;
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-2 items-center`}>
<div
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
botLog.level,
)}`}
<div className="rounded-lg border bg-card px-3.5 py-3 transition-colors hover:border-border/80">
{/* Header: level badge, session id, expand toggle, timestamp */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{/* Level badge */}
<span
className={cn(
'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-none',
levelStyle,
)}
>
{botLog.level}
</div>
</span>
{/* Session ID */}
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag} relative`}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
// 兼容性更好的复制方法
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(botLog.message_session_id)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => {
// fallback
fallbackCopy(botLog.message_session_id);
});
} else {
fallbackCopy(botLog.message_session_id);
}
copySessionId();
}}
title={t('common.clickToCopy')}
className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors truncate max-w-48 cursor-pointer"
>
{copied ? (
<Check className="w-4 h-4 text-green-600" />
<Check className="size-3 shrink-0 text-green-600" />
) : (
<svg
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1664"
width="16"
height="16"
fill="currentColor"
>
<path
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1665"
fill="currentColor"
></path>
<path
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
p-id="1666"
fill="currentColor"
></path>
<path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
p-id="1667"
fill="currentColor"
></path>
</svg>
<Copy className="size-3 shrink-0" />
)}
<span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)}
</span>
</div>
<span className="truncate">{botLog.message_session_id}</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{needsExpand && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
className="flex items-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronDown className="w-3 h-3" />
<ChevronDown className="size-3" />
{t('bots.collapse')}
</>
) : (
<>
<ChevronRight className="w-3 h-3" />
<ChevronRight className="size-3" />
{t('bots.viewDetails')}
</>
)}
</button>
)}
<div className={`${styles.timestamp}`}>
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatTime(botLog.timestamp)}
</div>
</span>
</div>
</div>
{/* 日志内容 - 简化显示 */}
<div className={`${styles.cardText}`}>
{expanded ? botLog.text : getShortText(botLog.text)}
{/* Log text */}
<div className="mt-2 text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
{expanded
? botLog.text
: botLog.text.length > SHORT_TEXT_LIMIT
? botLog.text.slice(0, SHORT_TEXT_LIMIT) + '...'
: botLog.text}
</div>
{/* 图片 - 只在展开时显示 */}
{/* Images (expanded) */}
{expanded && botLog.images.length > 0 && (
<PhotoProvider>
<div className={`flex flex-wrap gap-2 mt-3`}>
<div className="flex flex-wrap gap-2 mt-2.5">
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
className="max-w-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
/>
))}
</div>
</PhotoProvider>
)}
{/* 图片数量提示 - 未展开时显示 */}
{/* Image count hint (collapsed) */}
{!expanded && botLog.images.length > 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
📷 {botLog.images.length} {t('bots.imagesAttached')}
<div className="mt-1.5 text-[11px] text-muted-foreground">
{botLog.images.length} {t('bots.imagesAttached')}
</div>
)}
</div>

View File

@@ -4,7 +4,6 @@ import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch';
import {
Popover,
@@ -18,7 +17,20 @@ import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
export function BotLogListComponent({ botId }: { botId: string }) {
export function BotLogListComponent({
botId,
autoExpandImages = false,
hideDetailedLogsLink = false,
hideToolbar = false,
}: {
botId: string;
/** When true, log entries with images are rendered expanded by default */
autoExpandImages?: boolean;
/** When true, hides the "View Detailed Logs" navigation button */
hideDetailedLogsLink?: boolean;
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
hideToolbar?: boolean;
}) {
const { t } = useTranslation();
const router = useRouter();
const manager = useRef(new BotLogManager(botId)).current;
@@ -50,7 +62,6 @@ export function BotLogListComponent({ botId }: { botId: string }) {
botLogListRef.current = botLogList;
}, [botLogList]);
// 根据级别过滤日志
const filteredLogs = useMemo(() => {
if (selectedLevels.length === 0) {
return botLogList;
@@ -75,18 +86,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (selectedLevels.length === logLevels.length) {
return t('bots.allLevels');
}
// 如果选中3个或以上显示数量
if (selectedLevels.length >= 3) {
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
}
// 显示选中级别的标签(大写形式)
return logLevels
.filter((level) => selectedLevels.includes(level.value))
.map((level) => level.label)
.join(', ');
};
// 观测自动刷新状态
useEffect(() => {
if (autoFlush) {
manager.startListenServerPush();
@@ -99,13 +107,10 @@ export function BotLogListComponent({ botId }: { botId: string }) {
}, [autoFlush]);
function initComponent() {
// 订阅日志推送
manager.subscribeLogPush(handleBotLogPush);
// 加载第一页日志
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
});
// 监听滚动
listenScroll();
}
@@ -115,28 +120,19 @@ export function BotLogListComponent({ botId }: { botId: string }) {
}
function listenScroll() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.addEventListener('scroll', handleScroll);
if (!listContainerRef.current) return;
listContainerRef.current.addEventListener('scroll', handleScroll);
}
function removeScrollListener() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.removeEventListener('scroll', handleScroll);
if (!listContainerRef.current) return;
listContainerRef.current.removeEventListener('scroll', handleScroll);
}
function loadMore() {
// 加载更多日志
const list = botLogListRef.current;
const lastSeq = list[list.length - 1].seq_id;
if (lastSeq === 0) {
return;
}
if (lastSeq === 0) return;
manager.loadMore(lastSeq - 1, 10).then((response) => {
setBotLogList([...list, ...response.reverse()]);
});
@@ -165,63 +161,101 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (!isTop && !isBottom) {
setAutoFlush(false);
}
}, 300), // 防抖延迟 300ms
[botLogList], // 依赖项为空
}, 300),
[botLogList],
);
return (
<div className={`${styles.botLogListContainer}`} ref={listContainerRef}>
<div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
<Popover>
<PopoverTrigger asChild>
<div
className="flex flex-col h-full min-h-0 overflow-y-auto"
ref={listContainerRef}
>
{/* Toolbar */}
{!hideToolbar && (
<div className="flex items-center gap-3 pb-3 shrink-0 flex-wrap">
{/* Auto-refresh toggle */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
{t('bots.enableAutoRefresh')}
</span>
<Switch
checked={autoFlush}
onCheckedChange={(v) => setAutoFlush(v)}
/>
</div>
{/* Level filter */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
{t('bots.logLevel')}
</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[160px] justify-between"
>
<span className="text-sm truncate">{getDisplayText()}</span>
<ChevronDownIcon className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div
key={level.value}
className="flex items-center space-x-2"
>
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none cursor-pointer"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Link to detailed logs */}
{!hideDetailedLogsLink && (
<Button
variant="outline"
size="sm"
className="w-[180px] flex items-center justify-between"
className="gap-1"
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
>
<span className="text-sm truncate flex-1 text-left">
{getDisplayText()}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
<ExternalLink className="size-3.5" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div key={level.value} className="flex items-center space-x-2">
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="ml-4 flex items-center gap-1"
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
>
<ExternalLink className="h-4 w-4" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
</Button>
</div>
)}
</div>
)}
{filteredLogs.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})}
{/* Log cards */}
{filteredLogs.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">{t('bots.noLogs')}</p>
</div>
) : (
<div className="flex flex-col gap-2">
{filteredLogs.map((botLog) => (
<BotLogCard
botLog={botLog}
key={botLog.seq_id}
defaultExpanded={autoExpandImages && botLog.images.length > 0}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,142 +0,0 @@
.botLogListContainer {
width: 100%;
max-width: 100%;
min-height: 10rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
}
.botLogCardContainer {
width: 100%;
max-width: 100%;
background-color: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
overflow: hidden;
box-sizing: border-box;
}
.botLogCardContainer:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
:global(.dark) .botLogCardContainer {
background-color: #1f1f22;
border: 1px solid #2a2a2e;
}
:global(.dark) .botLogCardContainer:hover {
border-color: #3a3a3e;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.listHeader {
width: 100%;
height: 2.5rem;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.tag {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: auto;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background-color: #dbeafe;
color: #1e40af;
font-size: 0.75rem;
font-weight: 500;
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.dark) .tag {
background-color: #1e3a8a;
color: #93c5fd;
}
.chatTag {
color: #4b5563;
background-color: #f3f4f6;
text-transform: none;
cursor: pointer;
transition: all 0.15s ease;
}
.chatTag:hover {
background-color: #e5e7eb;
}
:global(.dark) .chatTag {
color: #9ca3af;
background-color: #374151;
}
:global(.dark) .chatTag:hover {
background-color: #4b5563;
}
.chatId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Courier New', monospace;
font-size: 0.7rem;
}
.cardTitleContainer {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cardText {
color: #1e293b;
font-size: 0.875rem;
line-height: 1.7;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: anywhere;
hyphens: auto;
max-width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', sans-serif;
}
:global(.dark) .cardText {
color: #e2e8f0;
}
.timestamp {
color: #64748b;
font-size: 0.75rem;
white-space: nowrap;
}
:global(.dark) .timestamp {
color: #64748b;
}

View File

@@ -1,10 +1,16 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, {
useState,
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react';
import {
@@ -49,11 +55,18 @@ interface SessionMessage {
role?: string | null;
}
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
interface BotSessionMonitorProps {
botId: string;
}
export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle,
BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
@@ -97,6 +110,14 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
}
}, [botId]);
useImperativeHandle(
ref,
() => ({
refreshSessions: loadSessions,
}),
[loadSessions],
);
const loadMessages = useCallback(async (sessionId: string) => {
setLoadingMessages(true);
try {
@@ -235,7 +256,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
return (
<div
key={index}
className="mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80"
className="mb-2 pl-2.5 border-l-2 border-muted-foreground/50 opacity-80"
>
<div className="text-sm">
{quote.origin?.map((comp, idx) =>
@@ -278,9 +299,17 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
);
};
// Backend timestamps may lack timezone indicator; treat as UTC
const parseTimestamp = (timestamp: string): Date => {
if (!timestamp) return new Date(0);
const hasTimezone =
timestamp.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(timestamp);
return new Date(hasTimezone ? timestamp : timestamp + 'Z');
};
const formatTime = (timestamp: string): string => {
if (!timestamp) return '';
const date = new Date(timestamp);
const date = parseTimestamp(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
@@ -288,7 +317,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const formatRelativeTime = (timestamp: string): string => {
if (!timestamp) return '';
const date = new Date(timestamp);
const date = parseTimestamp(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
@@ -306,36 +335,9 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
);
return (
<div className="flex h-full min-h-0">
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0">
{/* Refresh Button */}
<div className="px-2 py-2 border-b shrink-0">
<Button
variant="ghost"
className="w-full h-9 text-sm text-muted-foreground"
onClick={loadSessions}
disabled={loadingSessions}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'w-3.5 h-3.5 mr-1.5',
loadingSessions && 'animate-spin',
)}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
{t('bots.sessionMonitor.refresh')}
</Button>
</div>
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
@@ -347,14 +349,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{t('bots.sessionMonitor.noSessions')}
</div>
) : (
<div className="p-1">
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
return (
<button
key={session.session_id}
type="button"
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
@@ -403,20 +406,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
{t('bots.sessionMonitor.selectSession')}
</div>
) : (
<>
{/* Chat Header */}
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
<div className="px-4 py-2.5 border-b shrink-0">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
@@ -433,6 +436,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
@@ -462,40 +466,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="w-8 h-8"
onClick={() => loadMessages(selectedSessionId)}
disabled={loadingMessages}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn('w-4 h-4', loadingMessages && 'animate-spin')}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
</Button>
</div>
{/* Messages Area — matches DebugDialog style */}
{/* Messages Area */}
<ScrollArea
ref={messagesContainerRef}
className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black"
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
>
<div className="space-y-6">
<div className="space-y-4">
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-lg">
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
@@ -511,21 +495,18 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
)}
>
{renderMessageContent(msg)}
{/* Role label + timestamp inside bubble, matching DebugDialog */}
{/* Role label + timestamp */}
<div
className={cn(
'text-xs mt-2 flex items-center gap-2',
isUser
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
)}
>
<span>
@@ -561,4 +542,6 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
</div>
</div>
);
}
});
export default BotSessionMonitor;

View File

@@ -1,144 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import styles from './botConfig.module.css';
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
import BotCard from '@/app/home/bots/components/bot-card/BotCard';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Bot, Adapter } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
import { CustomApiError } from '@/app/infra/entities/common';
import { systemInfo } from '@/app/infra/http';
import BotDetailContent from './BotDetailContent';
export default function BotConfigPage() {
const { t } = useTranslation();
// 机器人详情dialog
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
const [botList, setBotList] = useState<BotCardVO[]>([]);
const [selectedBotId, setSelectedBotId] = useState<string>('');
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
useEffect(() => {
getBotList();
}, []);
async function getBotList() {
const adapterListResp = await httpClient.getAdapters();
const adapterList = adapterListResp.adapters.map((adapter: Adapter) => {
return {
label: extractI18nObject(adapter.label),
value: adapter.name,
};
});
httpClient
.getBots()
.then((resp) => {
const botList: BotCardVO[] = resp.bots.map((bot: Bot) => {
return new BotCardVO({
id: bot.uuid || '',
iconURL: httpClient.getAdapterIconURL(bot.adapter),
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapterConfig: bot.adapter_config,
adapterLabel:
adapterList.find((item) => item.value === bot.adapter)?.label ||
bot.adapter.substring(0, 10),
usePipelineName: bot.use_pipeline_name || '',
enable: bot.enable || false,
});
});
setBotList(botList);
})
.catch((err) => {
console.error('get bot list error', err);
toast.error(t('bots.getBotListError') + (err as CustomApiError).msg);
});
}
function handleCreateBotClick() {
const maxBots = systemInfo.limitation?.max_bots ?? -1;
if (maxBots >= 0 && botList.length >= maxBots) {
toast.error(t('limitation.maxBotsReached', { max: maxBots }));
return;
}
setSelectedBotId('');
setDetailDialogOpen(true);
}
function selectBot(botUUID: string) {
setSelectedBotId(botUUID);
setDetailDialogOpen(true);
}
function handleFormSubmit() {
getBotList();
// setDetailDialogOpen(false);
}
function handleFormCancel() {
setDetailDialogOpen(false);
}
function handleBotDeleted() {
getBotList();
setDetailDialogOpen(false);
}
function handleNewBotCreated(botId: string) {
getBotList();
setSelectedBotId(botId);
if (detailId) {
return <BotDetailContent id={detailId} />;
}
return (
<div>
<BotDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
botId={selectedBotId || undefined}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
{/* 注意其余的返回内容需要保持在Spin组件外部 */}
<div className={`${styles.botListContainer}`}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateBotClick}
/>
{botList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectBot(cardVO.id);
}}
>
<BotCard
botCardVO={cardVO}
setBotEnableCallback={(id, enable) => {
setBotList(
botList.map((bot) => {
if (bot.id === id) {
return { ...bot, enable: enable };
}
return bot;
}),
);
}}
/>
</div>
);
})}
</div>
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('bots.selectFromSidebar')}</p>
</div>
);
}

View File

@@ -11,9 +11,138 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { systemInfo } from '@/app/infra/http';
/**
* Resolve the value referenced by a `show_if.field` string.
*
* Fields prefixed with `__system.` are looked up in the caller-supplied
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
* All other field names are resolved from the live form values first, then
* fall back to `externalDependentValues`.
*/
function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith('__system.')) {
const key = field.slice('__system.'.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
/**
* Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is
* read-only and comes from systemContext, not user input.
*/
function WebhookUrlField({
label,
description,
url,
extraUrl,
}: {
label: string;
description?: string;
url: string;
extraUrl?: string;
}) {
const [copied, setCopied] = useState(false);
const [extraCopied, setExtraCopied] = useState(false);
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true);
setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
};
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={url}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(url, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{extraUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(extraUrl, setExtraCopied)}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline font-medium"
>
{t('bots.webhookSaasLink')}
</a>
</p>
</div>
)}
</FormItem>
);
}
export default function DynamicFormComponent({
itemConfigList,
@@ -22,6 +151,7 @@ export default function DynamicFormComponent({
onFileUploaded,
isEditing,
externalDependentValues,
systemContext,
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
@@ -29,6 +159,9 @@ export default function DynamicFormComponent({
onFileUploaded?: (fileKey: string) => void;
isEditing?: boolean;
externalDependentValues?: Record<string, unknown>;
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record<string, unknown>;
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
@@ -60,12 +193,26 @@ export default function DynamicFormComponent({
fallbacks: [],
};
}
if (item.type === 'prompt-editor') {
if (Array.isArray(value)) {
return value;
}
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
return value;
};
// Filter out display-only field types (e.g. webhook-url) that should not
// participate in form state, validation, or value emission.
const editableItems = useMemo(
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
[itemConfigList],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
itemConfigList.reduce(
editableItems.reduce(
(acc, item) => {
let fieldSchema;
switch (item.type) {
@@ -143,7 +290,7 @@ export default function DynamicFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: itemConfigList.reduce((acc, item) => {
defaultValues: editableItems.reduce((acc, item) => {
// 优先使用 initialValues如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
@@ -171,7 +318,7 @@ export default function DynamicFormComponent({
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = itemConfigList.reduce(
const mergedValues = editableItems.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
@@ -186,7 +333,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = initialValues;
}
}, [initialValues, form, itemConfigList]);
}, [initialValues, form, editableItems]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
@@ -202,7 +349,7 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
const initialFinalValues = editableItems.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -222,7 +369,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = itemConfigList.reduce(
const finalValues = editableItems.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -233,21 +380,19 @@ export default function DynamicFormComponent({
previousInitialValues.current = finalValues as Record<string, object>;
});
return () => subscription.unsubscribe();
}, [form, itemConfigList]);
}, [form, editableItems]);
return (
<Form {...form}>
<div className="space-y-4">
{itemConfigList.map((config) => {
if (config.show_if) {
const dependValue =
watchedValues[
config.show_if.field as keyof typeof watchedValues
] !== undefined
? watchedValues[
config.show_if.field as keyof typeof watchedValues
]
: externalDependentValues?.[config.show_if.field];
const dependValue = resolveShowIfValue(
config.show_if.field,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
if (
config.show_if.operator === 'eq' &&
@@ -273,6 +418,69 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing;
// Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') {
const webhookUrl = (systemContext?.webhook_url as string) || '';
const extraWebhookUrl =
(systemContext?.extra_webhook_url as string) || '';
if (!webhookUrl) return null;
return (
<WebhookUrlField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
url={webhookUrl}
extraUrl={extraWebhookUrl || undefined}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
<FormField
key={config.id}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<div
className={cn(
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<div className="space-y-0.5">
<FormLabel className="text-base">
{extractI18nObject(config.label)}
</FormLabel>
{config.description && (
<p className="text-sm text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
</div>
<FormControl>
<DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
);
}
return (
<FormField
key={config.id}

View File

@@ -37,7 +37,29 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Eye, Wrench } from 'lucide-react';
import {
Plus,
X,
Eye,
Wrench,
Trash2,
Sparkles,
Info,
Settings,
ChevronDown,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
export default function DynamicFormItemComponent({
config,
@@ -57,6 +79,25 @@ export default function DynamicFormItemComponent({
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const fetchLlmModels = () => {
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
}
};
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -88,26 +129,35 @@ export default function DynamicFormItemComponent({
}
};
// Whether to show Space login CTA in model selectors
const showSpaceLoginCTA =
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.then((response) => {
window.location.href = response.authorize_url;
})
.catch(() => {
toast.error(t('common.spaceLoginFailed'));
});
} catch {
toast.error(t('common.spaceLoginFailed'));
}
};
useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
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(t('models.getModelListError') + err.msg);
});
fetchLlmModels();
}
}, [config.type]);
@@ -126,23 +176,7 @@ export default function DynamicFormItemComponent({
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);
});
fetchLlmModels();
}
}, [config.type]);
@@ -181,27 +215,62 @@ export default function DynamicFormItemComponent({
return (
<Input
type="number"
className="max-w-xs"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
);
case DynamicFormItemType.STRING:
return <Input {...field} />;
if (config.options && config.options.length > 0) {
return (
<div className="flex items-center gap-1.5 max-w-md">
<Input className="flex-1" {...field} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
type="button"
className="h-9 w-9 shrink-0 text-muted-foreground"
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{config.options.map((option) => (
<DropdownMenuItem
key={option.name}
onClick={() => field.onChange(option.name)}
>
<div className="flex flex-col gap-0.5">
<span>{extractI18nObject(option.label)}</span>
<span className="text-xs text-muted-foreground">
{option.name}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
return <Input className="max-w-md" {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />;
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY:
return (
<div className="space-y-2">
<div className="space-y-2 max-w-md">
{field.value.map((item: string, index: number) => (
<div key={index} className="flex gap-2 items-center">
<div key={index} className="flex gap-1.5 items-center">
<Input
className="w-[200px]"
className="flex-1"
value={item}
onChange={(e) => {
const newValue = [...field.value];
@@ -209,9 +278,11 @@ export default function DynamicFormItemComponent({
field.onChange(newValue);
}}
/>
<button
<Button
type="button"
className="p-2 hover:bg-gray-100 rounded"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => {
const newValue = field.value.filter(
(_: string, i: number) => i !== index,
@@ -219,24 +290,19 @@ export default function DynamicFormItemComponent({
field.onChange(newValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange([...field.value, '']);
}}
>
<Plus className="size-4 mr-1.5" />
{t('common.add')}
</Button>
</div>
@@ -245,13 +311,17 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options?.map((option) => (
<SelectItem key={option.name} value={option.name}>
<SelectItem
key={option.name}
value={option.name}
description={option.name}
>
{extractI18nObject(option.label)}
</SelectItem>
))}
@@ -261,8 +331,16 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Group models by provider
const groupedModels = llmModels.reduce(
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModels = regularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -273,32 +351,181 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const groupedSpaceModels = spaceModels.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[]>,
);
// Hardcoded preview model names for CTA when no space models are synced
const previewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).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>
<div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).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>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(groupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</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>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
@@ -314,30 +541,40 @@ export default function DynamicFormItemComponent({
);
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
</div>
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider
const groupedModelsForFallback = llmModels.reduce(
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModelsForFallback = fbRegularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -348,6 +585,27 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const fbGroupedSpaceModels = fbSpaceModels.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[]>,
);
// Hardcoded preview model names for CTA
const fbPreviewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null &&
@@ -404,6 +662,112 @@ export default function DynamicFormItemComponent({
</SelectGroup>
),
)}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(fbGroupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</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>
),
)
) : null}
</SelectContent>
</Select>
);
@@ -446,11 +810,35 @@ export default function DynamicFormItemComponent({
<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 className="flex items-center gap-1.5">
<div className="flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
</div>
{/* Fallback models */}
@@ -495,11 +883,11 @@ export default function DynamicFormItemComponent({
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeFallbackModel(index)}
>
<X className="h-4 w-4" />
<Trash2 className="size-4" />
</Button>
</div>
</div>
@@ -512,10 +900,10 @@ export default function DynamicFormItemComponent({
type="button"
variant="outline"
size="sm"
className="w-full"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={addFallbackModel}
>
<Plus className="h-4 w-4 mr-1" />
<Plus className="size-4 mr-1.5" />
{t('models.fallback.addFallback')}
</Button>
</div>
@@ -541,7 +929,25 @@ export default function DynamicFormItemComponent({
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
<div className="flex items-center gap-2">
{selectedKb?.emoji && (
<span className="text-sm shrink-0">
{selectedKb.emoji}
</span>
)}
<span>{selectedKb?.name ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -553,7 +959,12 @@ export default function DynamicFormItemComponent({
<SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
{base.name}
<div className="flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">{base.emoji}</span>
)}
<span>{base.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
@@ -597,6 +1008,11 @@ export default function DynamicFormItemComponent({
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2">
{currentKb.emoji && (
<span className="text-sm shrink-0">
{currentKb.emoji}
</span>
)}
{currentKb.name}
{currentKb.knowledge_engine?.name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
@@ -686,7 +1102,14 @@ export default function DynamicFormItemComponent({
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
<div className="font-medium flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">
{base.emoji}
</span>
)}
{base.name}
</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
@@ -738,10 +1161,18 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.PROMPT_EDITOR:
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
// single system-prompt entry to prevent the .map() crash.
const promptItems: { role: string; content: string }[] = Array.isArray(
field.value,
)
? field.value
: [{ role: 'system', content: '' }];
return (
<div className="space-y-2">
{field.value.map(
{promptItems.map(
(item: { role: string; content: string }, index: number) => (
<div key={index} className="flex gap-2 items-center">
{/* 角色选择 */}
@@ -753,7 +1184,7 @@ export default function DynamicFormItemComponent({
<Select
value={item.role}
onValueChange={(value) => {
const newValue = [...field.value];
const newValue = [...(field.value ?? promptItems)];
newValue[index] = { ...newValue[index], role: value };
field.onChange(newValue);
}}
@@ -774,7 +1205,7 @@ export default function DynamicFormItemComponent({
className="w-[300px]"
value={item.content}
onChange={(e) => {
const newValue = [...field.value];
const newValue = [...(field.value ?? promptItems)];
newValue[index] = {
...newValue[index],
content: e.target.value,
@@ -788,7 +1219,7 @@ export default function DynamicFormItemComponent({
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => {
const newValue = field.value.filter(
const newValue = (field.value ?? promptItems).filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_: any, i: number) => i !== index,
);
@@ -812,13 +1243,17 @@ export default function DynamicFormItemComponent({
type="button"
variant="outline"
onClick={() => {
field.onChange([...field.value, { role: 'user', content: '' }]);
field.onChange([
...(field.value ?? promptItems),
{ role: 'user', content: '' },
]);
}}
>
{t('common.addRound')}
</Button>
</div>
);
}
case DynamicFormItemType.FILE:
return (

View File

@@ -1,159 +0,0 @@
.sidebarContainer {
box-sizing: border-box;
width: 11rem;
height: 100vh;
background-color: #eee;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding-block: 1rem;
padding-left: 0.4rem;
user-select: none;
/* box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); */
}
:global(.dark) .sidebarContainer {
background-color: #0a0a0b !important;
}
.langbotIconContainer {
width: 200px;
height: 70px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.langbotIcon {
width: 2.8rem;
height: 2.8rem;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
:global(.dark) .langbotIcon {
box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.1);
}
.langbotTextContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.1rem;
}
.langbotText {
font-size: 1.4rem;
font-weight: 500;
color: #1a1a1a;
}
:global(.dark) .langbotText {
font-size: 1.4rem;
font-weight: 500;
color: #f0f0f0 !important;
}
.langbotVersion {
font-size: 0.8rem;
font-weight: 700;
color: #6c6c6c;
}
:global(.dark) .langbotVersion {
font-size: 0.8rem;
font-weight: 700;
color: #a0a0a0 !important;
}
.sidebarTopContainer {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.sidebarItemsContainer {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.sidebarChildContainer {
width: 9.8rem;
height: 3rem;
padding-left: 1.6rem;
font-size: 1rem;
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
gap: 0.5rem;
transition: all 0.2s ease;
/* background-color: aqua; */
}
.sidebarSelected {
background-color: #2288ee;
color: white;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .sidebarSelected {
background-color: #2288ee;
color: white;
box-shadow: 0 0 10px 0 rgba(34, 136, 238, 0.3);
}
.sidebarUnselected {
color: #6c6c6c;
}
:global(.dark) .sidebarUnselected {
color: #a0a0a0 !important;
}
.sidebarUnselected:hover {
background-color: rgba(34, 136, 238, 0.1);
color: #2288ee;
}
:global(.dark) .sidebarUnselected:hover {
background-color: rgba(34, 136, 238, 0.2);
color: #66baff;
}
.sidebarChildIcon {
width: 20px;
height: 20px;
background-color: rgba(96, 149, 209, 0);
}
.sidebarChildName {
color: inherit;
}
.sidebarBottomContainer {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: auto;
padding-bottom: 1rem;
}
.sidebarBottomChildContainer {
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import styles from './HomeSidebar.module.css';
import { I18nObject } from '@/app/infra/entities/common';
export type SidebarSection = 'home' | 'extensions' | 'standalone';
export interface ISidebarChildVO {
id: string;
icon: React.ReactNode;
@@ -8,6 +9,7 @@ export interface ISidebarChildVO {
route: string;
description: string;
helpLink: I18nObject;
section?: SidebarSection;
}
export class SidebarChildVO {
@@ -17,6 +19,7 @@ export class SidebarChildVO {
route: string;
description: string;
helpLink: I18nObject;
section: SidebarSection;
constructor(props: ISidebarChildVO) {
this.id = props.id;
@@ -25,29 +28,6 @@ export class SidebarChildVO {
this.route = props.route;
this.description = props.description;
this.helpLink = props.helpLink;
this.section = props.section ?? 'home';
}
}
export function SidebarChild({
icon,
name,
isSelected,
onClick,
}: {
icon: React.ReactNode;
name: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={`${styles.sidebarChildContainer} ${
isSelected ? styles.sidebarSelected : styles.sidebarUnselected
}`}
onClick={onClick}
>
<div className={`${styles.sidebarChildIcon}`}>{icon}</div>
<span className={`${styles.sidebarChildName}`}>{name}</span>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { isNewerVersion } from '@/app/utils/versionCompare';
// Lightweight entity item for sidebar display
export interface SidebarEntityItem {
id: string;
name: string;
description?: string;
emoji?: string;
iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited
// Bot-specific fields
enabled?: boolean;
// MCP-specific fields
runtimeStatus?: 'connecting' | 'connected' | 'error';
// Plugin-specific fields
installSource?: string;
installInfo?: Record<string, unknown>;
hasUpdate?: boolean;
debug?: boolean;
}
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue {
bots: SidebarEntityItem[];
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Pending plugin install action triggered from sidebar
pendingPluginInstallAction: PluginInstallAction;
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
}
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
export function SidebarDataProvider({
children,
}: {
children: React.ReactNode;
}) {
const [bots, setBots] = useState<SidebarEntityItem[]>([]);
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const refreshBots = useCallback(async () => {
try {
const resp = await httpClient.getBots();
setBots(
resp.bots.map((bot) => ({
id: bot.uuid || '',
name: bot.name,
description: bot.description,
iconURL: httpClient.getAdapterIconURL(bot.adapter),
updatedAt: bot.updated_at,
enabled: bot.enable ?? true,
})),
);
} catch (error) {
console.error('Failed to fetch bots for sidebar:', error);
}
}, []);
const refreshPipelines = useCallback(async () => {
try {
const resp = await httpClient.getPipelines();
setPipelines(
resp.pipelines.map((p) => ({
id: p.uuid || '',
name: p.name,
description: p.description,
emoji: p.emoji,
updatedAt: p.updated_at,
})),
);
} catch (error) {
console.error('Failed to fetch pipelines for sidebar:', error);
}
}, []);
const refreshKnowledgeBases = useCallback(async () => {
try {
const resp = await httpClient.getKnowledgeBases();
setKnowledgeBases(
resp.bases.map((kb) => ({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
emoji: kb.emoji,
updatedAt: kb.updated_at,
})),
);
} catch (error) {
console.error('Failed to fetch knowledge bases for sidebar:', error);
}
}, []);
const refreshPlugins = useCallback(async () => {
try {
const [pluginsResp, marketplaceResp] = await Promise.all([
httpClient.getPlugins(),
getCloudServiceClientSync()
.getMarketplacePlugins(1, 100)
.catch(() => ({ plugins: [] })),
]);
// Build marketplace version lookup: "author/name" -> latest_version
const marketplaceVersions = new Map<string, string>();
for (const mp of marketplaceResp.plugins) {
if (mp.latest_version) {
marketplaceVersions.set(`${mp.author}/${mp.name}`, mp.latest_version);
}
}
setPlugins(
pluginsResp.plugins.map((plugin) => {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
let hasUpdate = false;
if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion);
}
}
return {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
}),
);
} catch (error) {
console.error('Failed to fetch plugins for sidebar:', error);
}
}, []);
const refreshMCPServers = useCallback(async () => {
try {
const resp = await httpClient.getMCPServers();
setMCPServers(
resp.servers.map((server) => ({
id: server.name,
name: server.name,
enabled: server.enable,
runtimeStatus: server.runtime_info?.status,
})),
);
} catch (error) {
console.error('Failed to fetch MCP servers for sidebar:', error);
}
}, []);
const refreshAll = useCallback(async () => {
await Promise.all([
refreshBots(),
refreshPipelines(),
refreshKnowledgeBases(),
refreshPlugins(),
refreshMCPServers(),
]);
}, [
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
]);
// Fetch all entity lists on mount
useEffect(() => {
refreshAll();
}, [refreshAll]);
return (
<SidebarDataContext.Provider
value={{
bots,
pipelines,
knowledgeBases,
plugins,
mcpServers,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshAll,
detailEntityName,
setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
}}
>
{children}
</SidebarDataContext.Provider>
);
}
export function useSidebarData(): SidebarDataContextValue {
const ctx = useContext(SidebarDataContext);
if (!ctx) {
throw new Error('useSidebarData must be used within a SidebarDataProvider');
}
return ctx;
}

View File

@@ -1,5 +1,4 @@
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import styles from './HomeSidebar.module.css';
import i18n from '@/i18n';
const t = (key: string) => {
@@ -7,15 +6,60 @@ const t = (key: string) => {
};
export const sidebarConfigList = [
// ── Quick Start ──
new SidebarChildVO({
id: 'wizard',
name: t('sidebar.quickStart'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
</svg>
),
route: '/wizard',
description: t('wizard.sidebarDescription'),
helpLink: {
en_US: '',
zh_Hans: '',
},
section: 'standalone',
}),
// ── Home section ──
new SidebarChildVO({
id: 'monitoring',
name: t('monitoring.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
</svg>
),
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
en_US: '',
zh_Hans: '',
},
section: 'home',
}),
new SidebarChildVO({
id: 'bots',
name: t('bots.title'),
icon: (
<svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
</svg>
@@ -27,16 +71,17 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
},
section: 'home',
}),
new SidebarChildVO({
id: 'pipelines',
name: t('pipelines.title'),
icon: (
<svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
@@ -48,26 +93,7 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
},
}),
new SidebarChildVO({
id: 'monitoring',
name: t('monitoring.title'),
icon: (
<svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
</svg>
),
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
en_US: '',
zh_Hans: '',
},
section: 'home',
}),
new SidebarChildVO({
id: 'knowledge',
@@ -77,6 +103,7 @@ export const sidebarConfigList = [
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
</svg>
@@ -88,16 +115,19 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
},
section: 'home',
}),
// ── Extensions section ──
new SidebarChildVO({
id: 'plugins',
name: t('plugins.title'),
name: t('sidebar.installedPlugins'),
icon: (
<svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
@@ -109,5 +139,49 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
},
section: 'extensions',
}),
new SidebarChildVO({
id: 'market',
name: t('sidebar.pluginMarket'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
</svg>
),
route: '/home/market',
description: t('plugins.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
},
section: 'extensions',
}),
new SidebarChildVO({
id: 'mcp',
name: t('sidebar.mcpServers'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
),
route: '/home/mcp',
description: t('mcp.title'),
helpLink: {
en_US: '',
zh_Hans: '',
},
section: 'extensions',
}),
];

View File

@@ -1,39 +0,0 @@
import { extractI18nObject } from '@/i18n/I18nProvider';
import styles from './HomeTittleBar.module.css';
import { I18nObject } from '@/app/infra/entities/common';
export default function HomeTitleBar({
title,
subtitle,
helpLink,
}: {
title: string;
subtitle: string;
helpLink: I18nObject;
}) {
return (
<div className={`${styles.titleBarContainer}`}>
<div className={`${styles.titleText}`}>{title}</div>
<div className={`${styles.subtitleText}`}>
{subtitle}
<span className={`${styles.helpLink}`}>
<div
onClick={() => {
window.open(extractI18nObject(helpLink), '_blank');
}}
className="cursor-pointer"
>
<svg
className="w-[1rem] h-[1rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
</div>
</span>
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
.titleBarContainer {
width: 100%;
padding-top: 1rem;
height: 4rem;
opacity: 1;
font-size: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
.titleText {
margin-left: 3.2rem;
font-size: 1.4rem;
font-weight: 500;
color: #585858;
}
:global(.dark) .titleText {
color: #e0e0e0;
}
.subtitleText {
margin-left: 3.2rem;
font-size: 0.8rem;
color: #808080;
display: flex;
align-items: center;
}
:global(.dark) .subtitleText {
color: #b0b0b0;
}
.helpLink {
margin-left: 0.2rem;
font-size: 0.8rem;
color: #8b8b8b;
}
:global(.dark) .helpLink {
color: #a0a0a0;
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const { t } = useTranslation();
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('knowledge.createKnowledgeBase'));
} else {
const kb = knowledgeBases.find((k) => k.id === id);
setDetailEntityName(kb?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, knowledgeBases, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false);
const loadKbInfo = useCallback(
async (kbId: string) => {
try {
const resp = await httpClient.getKnowledgeBase(kbId);
setKbInfo(resp.base);
} catch (e) {
console.error('Failed to load KB info:', e);
toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
}
},
[t],
);
// Load KB info for determining capabilities (e.g. doc_ingestion)
useEffect(() => {
if (!isCreateMode) {
loadKbInfo(id);
}
}, [id, isCreateMode, loadKbInfo]);
const hasDocumentCapability = (): boolean => {
if (!kbInfo || !kbInfo.knowledge_engine) return false;
return (
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
);
};
function handleKbDeleted() {
refreshKnowledgeBases();
router.push('/home/knowledge');
}
function handleNewKbCreated(newKbId: string) {
refreshKnowledgeBases();
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
}
function handleKbUpdated() {
refreshKnowledgeBases();
loadKbInfo(id);
}
async function confirmDelete() {
try {
await httpClient.deleteKnowledgeBase(id);
setShowDeleteConfirm(false);
handleKbDeleted();
} catch (e) {
toast.error(
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
}
}
const retrieveFunction = async (kbId: string, query: string) => {
return await httpClient.retrieveKnowledgeBase(kbId, query);
};
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('knowledge.createKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<KBForm
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
</div>
</div>
</div>
);
}
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="metadata" className="gap-1.5">
<FileText className="size-3.5" />
{t('knowledge.metadata')}
</TabsTrigger>
{hasDocumentCapability() && (
<TabsTrigger value="documents" className="gap-1.5">
<FolderOpen className="size-3.5" />
{t('knowledge.documents')}
</TabsTrigger>
)}
<TabsTrigger value="retrieve" className="gap-1.5">
<Search className="size-3.5" />
{t('knowledge.retrieve')}
</TabsTrigger>
</TabsList>
{/* Tab: Metadata */}
<TabsContent
value="metadata"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<KBForm
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
{/* Danger Zone Card */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Tab: Documents */}
{hasDocumentCapability() && (
<TabsContent
value="documents"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBDoc
kbId={id}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
/>
</TabsContent>
)}
{/* Tab: Retrieve */}
<TabsContent
value="retrieve"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBRetrieveGeneric kbId={id} retrieveFunction={retrieveFunction} />
</TabsContent>
</Tabs>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('knowledge.deleteKnowledgeBaseConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{t('knowledge.deleteKnowledgeBaseConfirmation')}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,305 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
interface KBDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
kbId?: string;
onFormCancel: () => void;
onKbDeleted: () => void;
onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void;
}
export default function KBDetailDialog({
open,
onOpenChange,
kbId: propKbId,
onFormCancel,
onKbDeleted,
onNewKbCreated,
onKbUpdated,
}: KBDetailDialogProps) {
const { t } = useTranslation();
const [kbId, setKbId] = useState<string | undefined>(propKbId);
const [activeMenu, setActiveMenu] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
useEffect(() => {
setKbId(propKbId);
setActiveMenu('metadata');
if (propKbId) {
loadKbInfo(propKbId);
} else {
setKbInfo(null);
}
}, [propKbId, open]);
async function loadKbInfo(id: string) {
try {
const resp = await httpClient.getKnowledgeBase(id);
setKbInfo(resp.base);
} catch (e) {
console.error('Failed to load KB info:', e);
toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
}
}
// Check if this KB supports document management
const hasDocumentCapability = (): boolean => {
if (!kbInfo || !kbInfo.knowledge_engine) {
return false;
}
return (
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
);
};
// Build menu based on KB capabilities
const menu = [
{
key: 'metadata',
label: t('knowledge.metadata'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
</svg>
),
},
// Show documents only if capability is present
...(hasDocumentCapability()
? [
{
key: 'documents',
label: t('knowledge.documents'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
]
: []),
{
key: 'retrieve',
label: t('knowledge.retrieve'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z"></path>
</svg>
),
},
];
const confirmDelete = async () => {
try {
await httpClient.deleteKnowledgeBase(kbId ?? '');
onKbDeleted();
} catch (e) {
console.error('Failed to delete KB:', e);
toast.error(
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
);
} finally {
setShowDeleteConfirm(false);
}
};
// Retrieve function
const retrieveFunction = async (id: string, query: string) => {
return await httpClient.retrieveKnowledgeBase(id, query);
};
if (!kbId) {
// New KB creation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<KBForm
initKbId={undefined}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
</main>
</DialogContent>
</Dialog>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex">
<SidebarProvider className="items-start w-full flex">
<Sidebar
collapsible="none"
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={activeMenu === item.key}
onClick={() => setActiveMenu(item.key)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-[75vh] min-w-0 overflow-x-hidden">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{activeMenu === 'metadata'
? t('knowledge.editKnowledgeBase')
: activeMenu === 'documents'
? t('knowledge.editDocument')
: t('knowledge.retrieveTest')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{activeMenu === 'metadata' && (
<KBForm
initKbId={kbId}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
)}
{activeMenu === 'documents' && hasDocumentCapability() && (
<KBDoc
kbId={kbId}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={
kbInfo?.knowledge_engine?.capabilities
}
/>
)}
{activeMenu === 'retrieve' && (
<KBRetrieveGeneric
kbId={kbId}
retrieveFunction={retrieveFunction}
/>
)}
</div>
{activeMenu === 'metadata' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
>
{t('common.delete')}
</Button>
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={onFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
)}
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">
{t('knowledge.deleteKnowledgeBaseConfirmation')}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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