Compare commits

...

21 Commits

Author SHA1 Message Date
WangCham
f8979056eb fix: optimize configuration function 2026-03-28 09:43:49 +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
80 changed files with 11310 additions and 5075 deletions

View File

@@ -9,16 +9,14 @@ repos:
# Run the formatter of backend. # Run the formatter of backend.
- id: ruff-format - 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 - repo: local
hooks: 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 - id: lint-staged
name: lint-staged name: lint-staged
entry: cd web && pnpm lint-staged entry: cd web && pnpm lint-staged

View File

@@ -272,15 +272,30 @@ class DingTalkClient:
message_data['Type'] = 'audio' message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file': elif incoming_message.message_type == 'file':
down_list = incoming_message.get_down_list() # 获取原始数据字典并提取嵌套的文件信息
if len(down_list) >= 2: raw_data = incoming_message.to_dict()
message_data['File'] = await self.get_file_url(down_list[0]) file_info = raw_data.get('content', {})
message_data['Name'] = down_list[1]
# 兼容处理:如果 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: else:
if self.logger: 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['File'] = None
message_data['Name'] = None message_data['Name'] = None
message_data['Type'] = 'file' message_data['Type'] = 'file'
copy_message_data = message_data.copy() copy_message_data = message_data.copy()

View File

@@ -6,7 +6,8 @@ import traceback
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass, field 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 from urllib.parse import unquote
import httpx import httpx
@@ -199,52 +200,139 @@ class StreamSessionManager:
self._msg_index.pop(msg_id, None) self._msg_index.pop(msg_id, None)
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]: def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
"""Download an AES-encrypted file from WeChat Work and return as data URI. """Decrypt AES-256-CBC encrypted file data.
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
Args: Args:
download_url: The encrypted file download URL. encrypted_data: The raw encrypted bytes.
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '='). aes_key_str: Base64-encoded AES key (may lack padding).
logger: Logger instance.
Returns: 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: if not encrypted_data:
return None raise ValueError('encrypted_data is empty')
async with httpx.AsyncClient() as client: if not aes_key_str:
response = await client.get(download_url) raise ValueError('aes_key is empty')
if response.status_code != 200:
await logger.error(f'failed to get file: {response.text}')
return None
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + '=') # Python's base64.b64decode requires proper padding (length % 4 == 0).
iv = aes_key[:16] # 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) iv = key[:16]
decrypted = cipher.decrypt(encrypted_bytes)
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] 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' mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'): elif data.startswith(b'\x89PNG'):
mime_type = 'image/png' mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')): elif data.startswith((b'GIF87a', b'GIF89a')):
mime_type = 'image/gif' mime_type = 'image/gif'
elif decrypted.startswith(b'BM'): elif data.startswith(b'BM'):
mime_type = 'image/bmp' 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' 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: else:
mime_type = 'application/octet-stream' 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}' 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( async def parse_wecom_bot_message(
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -273,10 +361,22 @@ async def parse_wecom_bot_message(
max_inline_file_size = 5 * 1024 * 1024 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: if not url:
return None return None, None
return await download_encrypted_file(url, encoding_aes_key, logger) 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': if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content') message_data['content'] = msg_json.get('text', {}).get('content')
@@ -285,14 +385,17 @@ async def parse_wecom_bot_message(
'content', '' 'content', ''
) )
elif msg_type == 'image': elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '') image_info = msg_json.get('image', {})
base64_data = await _safe_download(picurl) 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: if base64_data:
message_data['picurl'] = base64_data message_data['picurl'] = base64_data
message_data['images'] = [base64_data] message_data['images'] = [base64_data]
elif msg_type == 'voice': elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {} voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url') download_url = voice_info.get('url')
per_msg_aeskey = voice_info.get('aeskey', '')
message_data['voice'] = { message_data['voice'] = {
'url': download_url, 'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -302,12 +405,13 @@ async def parse_wecom_bot_message(
if voice_info.get('content'): if voice_info.get('content'):
message_data['content'] = voice_info.get('content') message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size: 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: if voice_base64:
message_data['voice']['base64'] = voice_base64 message_data['voice']['base64'] = voice_base64
elif msg_type == 'video': elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {} video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url') download_url = video_info.get('url')
per_msg_aeskey = video_info.get('aeskey', '')
video_data = { video_data = {
'url': download_url, 'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'), 'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -316,13 +420,14 @@ async def parse_wecom_bot_message(
'filename': video_info.get('filename') or video_info.get('name'), 'filename': video_info.get('filename') or video_info.get('name'),
} }
if (video_data.get('filesize') or 0) <= max_inline_file_size: 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: if video_base64:
video_data['base64'] = video_base64 video_data['base64'] = video_base64
message_data['video'] = video_data message_data['video'] = video_data
elif msg_type == 'file': elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {} file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl') download_url = file_info.get('url') or file_info.get('fileurl')
per_msg_aeskey = file_info.get('aeskey', '')
file_data = { file_data = {
'filename': file_info.get('filename') or file_info.get('name'), 'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'), 'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -332,9 +437,11 @@ async def parse_wecom_bot_message(
'extra': file_info, 'extra': file_info,
} }
if (file_data.get('filesize') or 0) <= max_inline_file_size: if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url) file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_base64: if file_bytes:
file_data['base64'] = file_base64 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 message_data['file'] = file_data
elif msg_type == 'link': elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {}) message_data['link'] = msg_json.get('link', {})
@@ -355,13 +462,16 @@ async def parse_wecom_bot_message(
if item_type == 'text': if item_type == 'text':
texts.append(item.get('text', {}).get('content', '')) texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image': elif item_type == 'image':
img_url = item.get('image', {}).get('url') img_info = item.get('image', {})
base64_data = await _safe_download(img_url) 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: if base64_data:
images.append(base64_data) images.append(base64_data)
elif item_type == 'file': elif item_type == 'file':
file_info = item.get('file', {}) or {} file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl') download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = { file_data = {
'filename': file_info.get('filename') or file_info.get('name'), 'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'), 'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -371,13 +481,16 @@ async def parse_wecom_bot_message(
'extra': file_info, 'extra': file_info,
} }
if (file_data.get('filesize') or 0) <= max_inline_file_size: if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url) file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
if file_base64: if file_bytes:
file_data['base64'] = file_base64 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) files.append(file_data)
elif item_type == 'voice': elif item_type == 'voice':
voice_info = item.get('voice', {}) or {} voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url') download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = { voice_data = {
'url': download_url, 'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -387,13 +500,14 @@ async def parse_wecom_bot_message(
if voice_info.get('content'): if voice_info.get('content'):
texts.append(voice_info.get('content')) texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size: 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: if voice_base64:
voice_data['base64'] = voice_base64 voice_data['base64'] = voice_base64
voices.append(voice_data) voices.append(voice_data)
elif item_type == 'video': elif item_type == 'video':
video_info = item.get('video', {}) or {} video_info = item.get('video', {}) or {}
download_url = video_info.get('url') download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = { video_data = {
'url': download_url, 'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'), 'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -402,7 +516,7 @@ async def parse_wecom_bot_message(
'filename': video_info.get('filename') or video_info.get('name'), 'filename': video_info.get('filename') or video_info.get('name'),
} }
if (video_data.get('filesize') or 0) <= max_inline_file_size: 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: if video_base64:
video_data['base64'] = video_base64 video_data['base64'] = video_base64
videos.append(video_data) videos.append(video_data)
@@ -770,7 +884,10 @@ class WecomBotClient:
return decorator return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key): async def download_url_to_base64(self, download_url, encoding_aes_key):
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): async def run_task(self, host: str, port: int, *args, **kwargs):
""" """

View File

@@ -105,6 +105,28 @@ class HTTPController:
): ):
if os.path.exists(os.path.join(frontend_path, path + '.html')): if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html' path += '.html'
elif path.startswith('home/'):
# 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: else:
return await quart.send_from_directory(frontend_path, '404.html') return await quart.send_from_directory(frontend_path, '404.html')

View File

@@ -16,6 +16,57 @@ class MonitoringService:
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap 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 ========== # ========== Recording Methods ==========
async def record_message( async def record_message(

View File

@@ -188,6 +188,34 @@ class Application:
scopes=[core_entities.LifecycleControlScope.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( self.task_mgr.create_task(
never_ending(), never_ending(),
name='never-ending-task', name='never-ending-task',

View File

@@ -2,18 +2,16 @@ from __future__ import annotations
import datetime import datetime
import typing import typing
import json
import uuid
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy import sqlalchemy
from . import database, migration from . import database, migration
from ..entity.persistence import base, pipeline, metadata, model as persistence_model from ..entity.persistence import base, metadata, model as persistence_model
from ..entity import persistence from ..entity import persistence
from ..core import app from ..core import app
from ..utils import constants, importutil from ..utils import constants, importutil
from ..api.http.service import pipeline as pipeline_service
from . import databases, migrations from . import databases, migrations
importutil.import_modules_in_pkg(databases) 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}.') self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
await self.write_default_pipeline()
await self.write_space_model_providers() await self.write_space_model_providers()
async def create_tables(self): async def create_tables(self):
@@ -101,29 +98,6 @@ class PersistenceManager:
if row is None: if row is None:
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) 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): async def write_space_model_providers(self):
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get( space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1' 'models_gateway_api_url', 'https://api.langbot.cloud/v1'

View File

@@ -277,14 +277,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': success} return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
"""智能机器人侧默认开启流式能力。 """Whether streaming output is enabled for this bot instance."""
return self.config.get('enable-stream-reply', True)
Returns:
bool: 恒定返回 True。
Example:
流水线执行阶段会调用此方法以确认是否启用流式。"""
return True
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)

View File

@@ -55,6 +55,10 @@ spec:
type: string type: string
required: false required: false
default: "" default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: Token - name: Token
label: label:
en_US: Token en_US: Token
@@ -65,6 +69,10 @@ spec:
type: string type: string
required: false required: false
default: "" default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: EncodingAESKey - name: EncodingAESKey
label: label:
en_US: EncodingAESKey en_US: EncodingAESKey
@@ -75,6 +83,20 @@ spec:
type: string type: string
required: false required: false
default: "" default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
description:
en_US: If enabled, the bot will use streaming mode to reply messages
zh_Hans: 如果启用,机器人将使用流式模式回复消息
type: boolean
required: false
default: true
execution: execution:
python: python:
path: ./wecombot.py path: ./wecombot.py

View File

@@ -78,6 +78,14 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' 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:
# Space service URL for OAuth and API # Space service URL for OAuth and API
url: 'https://space.langbot.app' url: 'https://space.langbot.app'

View File

@@ -74,6 +74,10 @@ stages:
type: integer type: integer
required: true required: true
default: 10 default: 10
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: prompt - name: prompt
label: label:
en_US: Prompt en_US: Prompt
@@ -83,6 +87,9 @@ stages:
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词 zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor type: prompt-editor
required: true required: true
default:
- role: system
content: "You are a helpful assistant."
- name: knowledge-bases - name: knowledge-bases
label: label:
en_US: Knowledge Bases en_US: Knowledge Bases
@@ -93,6 +100,10 @@ stages:
type: knowledge-base-multi-selector type: knowledge-base-multi-selector
required: false required: false
default: [] default: []
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: tbox-app-api - name: tbox-app-api
label: label:
en_US: Tbox App API en_US: Tbox App API
@@ -107,12 +118,14 @@ stages:
zh_Hans: API 密钥 zh_Hans: API 密钥
type: string type: string
required: true required: true
default: ''
- name: app-id - name: app-id
label: label:
en_US: App ID en_US: App ID
zh_Hans: 应用 ID zh_Hans: 应用 ID
type: string type: string
required: true required: true
default: ''
- name: dify-service-api - name: dify-service-api
label: label:
en_US: Dify Service API en_US: Dify Service API
@@ -127,6 +140,7 @@ stages:
zh_Hans: 基础 URL zh_Hans: 基础 URL
type: string type: string
required: true required: true
default: 'https://api.dify.ai/v1'
- name: base-prompt - name: base-prompt
label: label:
en_US: Base PROMPT en_US: Base PROMPT
@@ -163,6 +177,7 @@ stages:
zh_Hans: API 密钥 zh_Hans: API 密钥
type: string type: string
required: true required: true
default: 'your-api-key'
- name: dashscope-app-api - name: dashscope-app-api
label: label:
en_US: Aliyun Dashscope App API en_US: Aliyun Dashscope App API
@@ -193,12 +208,14 @@ stages:
zh_Hans: API 密钥 zh_Hans: API 密钥
type: string type: string
required: true required: true
default: 'your-api-key'
- name: app-id - name: app-id
label: label:
en_US: App ID en_US: App ID
zh_Hans: 应用 ID zh_Hans: 应用 ID
type: string type: string
required: true required: true
default: 'your-app-id'
- name: references_quote - name: references_quote
label: label:
en_US: References Quote en_US: References Quote
@@ -226,6 +243,7 @@ stages:
zh_Hans: n8n 工作流的 webhook URL zh_Hans: n8n 工作流的 webhook URL
type: string type: string
required: true required: true
default: 'http://your-n8n-webhook-url'
- name: auth-type - name: auth-type
label: label:
en_US: Authentication Type en_US: Authentication Type
@@ -263,6 +281,10 @@ stages:
type: string type: string
required: false required: false
default: '' default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: basic-password - name: basic-password
label: label:
en_US: Password en_US: Password
@@ -273,6 +295,10 @@ stages:
type: string type: string
required: false required: false
default: '' default: ''
show_if:
field: auth-type
operator: eq
value: 'basic'
- name: jwt-secret - name: jwt-secret
label: label:
en_US: Secret en_US: Secret
@@ -283,6 +309,10 @@ stages:
type: string type: string
required: false required: false
default: '' default: ''
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: jwt-algorithm - name: jwt-algorithm
label: label:
en_US: Algorithm en_US: Algorithm
@@ -293,6 +323,10 @@ stages:
type: string type: string
required: false required: false
default: 'HS256' default: 'HS256'
show_if:
field: auth-type
operator: eq
value: 'jwt'
- name: header-name - name: header-name
label: label:
en_US: Header Name en_US: Header Name
@@ -303,6 +337,10 @@ stages:
type: string type: string
required: false required: false
default: '' default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: header-value - name: header-value
label: label:
en_US: Header Value en_US: Header Value
@@ -313,6 +351,10 @@ stages:
type: string type: string
required: false required: false
default: '' default: ''
show_if:
field: auth-type
operator: eq
value: 'header'
- name: timeout - name: timeout
label: label:
en_US: Timeout en_US: Timeout
@@ -350,6 +392,7 @@ stages:
zh_Hans: Langflow 服务器的基础 URL zh_Hans: Langflow 服务器的基础 URL
type: string type: string
required: true required: true
default: 'http://localhost:7860'
- name: api-key - name: api-key
label: label:
en_US: API Key en_US: API Key
@@ -359,6 +402,7 @@ stages:
zh_Hans: Langflow 服务器的 API 密钥 zh_Hans: Langflow 服务器的 API 密钥
type: string type: string
required: true required: true
default: 'your-api-key'
- name: flow-id - name: flow-id
label: label:
en_US: Flow ID en_US: Flow ID
@@ -368,6 +412,7 @@ stages:
zh_Hans: 要运行的流程 ID zh_Hans: 要运行的流程 ID
type: string type: string
required: true required: true
default: 'your-flow-id'
- name: input-type - name: input-type
label: label:
en_US: Input Type en_US: Input Type
@@ -415,6 +460,7 @@ stages:
zh_Hans: Coze服务器的 API 密钥 zh_Hans: Coze服务器的 API 密钥
type: string type: string
required: true required: true
default: ''
- name: bot-id - name: bot-id
label: label:
en_US: Bot ID en_US: Bot ID
@@ -424,6 +470,7 @@ stages:
zh_Hans: 要运行的机器人 ID zh_Hans: 要运行的机器人 ID
type: string type: string
required: true required: true
default: ''
- name: api-base - name: api-base
label: label:
en_US: API Base URL en_US: API Base URL

View File

@@ -25,6 +25,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-context-menu": "^2.2.15",

4424
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,8 +46,12 @@ function SpaceOAuthCallbackContent() {
} }
setStatus('success'); setStatus('success');
toast.success(t('common.spaceLoginSuccess')); 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(() => { setTimeout(() => {
router.push('/home'); router.push(redirectTo);
}, 1000); }, 1000);
} catch (err) { } catch (err) {
setStatus('error'); setStatus('error');

View File

@@ -114,22 +114,23 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--animate-twinkle: twinkle 1.5s ease-in-out infinite;
} }
.dark { .dark {
--background: oklch(0.08 0.002 285.823); --background: oklch(0.17 0.003 285.823);
--foreground: oklch(0.985 0 0); --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); --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); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.2 255); --primary: oklch(0.62 0.2 255);
--primary-foreground: oklch(1 0 0); --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); --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); --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); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%); --border: oklch(1 0 0 / 8%);
@@ -140,7 +141,7 @@
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --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-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.62 0.2 255); --sidebar-primary: oklch(0.62 0.2 255);
--sidebar-primary-foreground: oklch(1 0 0); --sidebar-primary-foreground: oklch(1 0 0);
@@ -158,3 +159,23 @@
@apply bg-background text-foreground; @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; height: 10rem;
background-color: #fff; background-color: #fff;
border-radius: 10px; border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2); border: 1px solid #e4e4e7;
padding: 1.2rem; padding: 1.2rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -11,15 +11,15 @@
:global(.dark) .cardContainer { :global(.dark) .cardContainer {
background-color: #1f1f22; background-color: #1f1f22;
box-shadow: 0; border-color: #27272a;
} }
.cardContainer:hover { .cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1); border-color: #a1a1aa;
} }
:global(.dark) .cardContainer:hover { :global(.dark) .cardContainer:hover {
box-shadow: 0; border-color: #3f3f46;
} }
.iconBasicInfoContainer { .iconBasicInfoContainer {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
IChooseAdapterEntity, IChooseAdapterEntity,
IPipelineEntity, IPipelineEntity,
@@ -21,18 +21,11 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -47,16 +40,20 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
const getFormSchema = (t: (key: string) => string) => const getFormSchema = (t: (key: string) => string) =>
z.object({ z.object({
name: z.string().min(1, { message: t('bots.botNameRequired') }), name: z.string().min(1, { message: t('bots.botNameRequired') }),
description: z description: z.string().optional(),
.string()
.min(1, { message: t('bots.botDescriptionRequired') }),
adapter: z.string().min(1, { message: t('bots.adapterRequired') }), adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()), adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(), enable: z.boolean().optional(),
@@ -66,13 +63,13 @@ const getFormSchema = (t: (key: string) => string) =>
export default function BotForm({ export default function BotForm({
initBotId, initBotId,
onFormSubmit, onFormSubmit,
onBotDeleted,
onNewBotCreated, onNewBotCreated,
onDirtyChange,
}: { }: {
initBotId?: string; initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
@@ -81,7 +78,7 @@ export default function BotForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: '', name: '',
description: t('bots.defaultDescription'), description: '',
adapter: '', adapter: '',
adapter_config: {}, adapter_config: {},
enable: true, enable: true,
@@ -89,19 +86,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] = const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
useState(new Map<string, IDynamicFormItemSchema[]>()); useState(new Map<string, IDynamicFormItemSchema[]>());
// const [form] = Form.useForm<IBotFormEntity>();
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false); const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
// const [dynamicForm] = Form.useForm();
const [adapterNameList, setAdapterNameList] = useState< const [adapterNameList, setAdapterNameList] = useState<
IChooseAdapterEntity[] IChooseAdapterEntity[]
>([]); >([]);
const [adapterIconList, setAdapterIconList] = useState<
Record<string, string>
>({});
const [adapterDescriptionList, setAdapterDescriptionList] = useState< const [adapterDescriptionList, setAdapterDescriptionList] = useState<
Record<string, string> Record<string, string>
>({}); >({});
@@ -140,11 +134,16 @@ export default function BotForm({
return dynamicFormConfigList; return dynamicFormConfigList;
}, [currentAdapter, enableWebhook, dynamicFormConfigList]); }, [currentAdapter, enableWebhook, dynamicFormConfigList]);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
// 复制到剪贴板的辅助函数
const copyToClipboard = ( const copyToClipboard = (
text: string, text: string,
setStatus: React.Dispatch<React.SetStateAction<boolean>>, setStatus: React.Dispatch<React.SetStateAction<boolean>>,
@@ -157,7 +156,6 @@ export default function BotForm({
setTimeout(() => setStatus(false), 2000); setTimeout(() => setStatus(false), 2000);
}) })
.catch(() => { .catch(() => {
// 降级创建临时textarea复制
fallbackCopy(text, setStatus); fallbackCopy(text, setStatus);
}); });
} else { } else {
@@ -184,21 +182,23 @@ export default function BotForm({
}; };
function setBotFormValues() { function setBotFormValues() {
isInitializing.current = true;
initBotFormComponent().then(() => { initBotFormComponent().then(() => {
// 拉取初始化表单信息
if (initBotId) { if (initBotId) {
getBotConfig(initBotId) getBotConfig(initBotId)
.then((val) => { .then((val) => {
form.setValue('name', val.name); // Use form.reset() to set values AND update the dirty baseline,
form.setValue('description', val.description); // so isDirty stays false after initial load.
form.setValue('adapter', val.adapter); form.reset({
form.setValue('adapter_config', val.adapter_config); name: val.name,
form.setValue('enable', val.enable); description: val.description,
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || ''); adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
});
handleAdapterSelect(val.adapter); handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) { if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url); setWebhookUrl(val.webhook_full_url);
} else { } else {
@@ -210,28 +210,31 @@ export default function BotForm({
toast.error( toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg, t('bots.getBotConfigError') + (err as CustomApiError).msg,
); );
})
.finally(() => {
isInitializing.current = false;
}); });
} else { } else {
form.reset(); form.reset();
setWebhookUrl(''); setWebhookUrl('');
setExtraWebhookUrl(''); setExtraWebhookUrl('');
isInitializing.current = false;
} }
}); });
} }
async function initBotFormComponent() { async function initBotFormComponent() {
// 初始化流水线列表
const pipelinesRes = await httpClient.getPipelines(); const pipelinesRes = await httpClient.getPipelines();
setPipelineNameList( setPipelineNameList(
pipelinesRes.pipelines.map((item) => { pipelinesRes.pipelines.map((item) => {
return { return {
label: item.name, label: item.name,
value: item.uuid ?? '', value: item.uuid ?? '',
emoji: item.emoji,
}; };
}), }),
); );
// 拉取adapter
const adaptersRes = await httpClient.getAdapters(); const adaptersRes = await httpClient.getAdapters();
setAdapterNameList( setAdapterNameList(
adaptersRes.adapters.map((item) => { adaptersRes.adapters.map((item) => {
@@ -242,18 +245,6 @@ export default function BotForm({
}), }),
); );
// 初始化适配器图标列表
setAdapterIconList(
adaptersRes.adapters.reduce(
(acc, item) => {
acc[item.name] = httpClient.getAdapterIconURL(item.name);
return acc;
},
{} as Record<string, string>,
),
);
// 初始化适配器描述列表
setAdapterDescriptionList( setAdapterDescriptionList(
adaptersRes.adapters.reduce( adaptersRes.adapters.reduce(
(acc, item) => { (acc, item) => {
@@ -264,7 +255,6 @@ export default function BotForm({
), ),
); );
// 初始化适配器表单map
adaptersRes.adapters.forEach((rawAdapter) => { adaptersRes.adapters.forEach((rawAdapter) => {
adapterNameToDynamicConfigMap.set( adapterNameToDynamicConfigMap.set(
rawAdapter.name, rawAdapter.name,
@@ -341,15 +331,13 @@ export default function BotForm({
} }
} }
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
function onDynamicFormSubmit() { function onDynamicFormSubmit() {
setIsLoading(true); setIsLoading(true);
if (initBotId) { if (initBotId) {
// 编辑提交
const updateBot: Bot = { const updateBot: Bot = {
uuid: initBotId, uuid: initBotId,
name: form.getValues().name, name: form.getValues().name,
description: form.getValues().description, description: form.getValues().description ?? '',
adapter: form.getValues().adapter, adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config, adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable, enable: form.getValues().enable,
@@ -358,6 +346,8 @@ export default function BotForm({
httpClient httpClient
.updateBot(initBotId, updateBot) .updateBot(initBotId, updateBot)
.then(() => { .then(() => {
// Reset dirty baseline to current values so isDirty becomes false
form.reset(form.getValues());
onFormSubmit(form.getValues()); onFormSubmit(form.getValues());
toast.success(t('bots.saveSuccess')); toast.success(t('bots.saveSuccess'));
}) })
@@ -366,14 +356,11 @@ export default function BotForm({
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
// form.reset();
// dynamicForm.resetFields();
}); });
} else { } else {
// 创建提交
const newBot: Bot = { const newBot: Bot = {
name: form.getValues().name, name: form.getValues().name,
description: form.getValues().description, description: form.getValues().description ?? '',
adapter: form.getValues().adapter, adapter: form.getValues().adapter,
adapter_config: form.getValues().adapter_config, adapter_config: form.getValues().adapter_config,
}; };
@@ -393,181 +380,30 @@ export default function BotForm({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
form.reset(); form.reset();
// dynamicForm.resetFields();
}); });
} }
} }
function deleteBot() { // --- Webhook URL display helper ---
if (initBotId) { const showWebhook =
httpClient initBotId &&
.deleteBot(initBotId) webhookUrl &&
.then(() => { (currentAdapter !== 'lark' || enableWebhook !== false);
onBotDeleted();
toast.success(t('bots.deleteSuccess'));
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
}
}
return ( return (
<div> <Form {...form}>
<Dialog <form
open={showDeleteConfirmModal} id="bot-form"
onOpenChange={setShowDeleteConfirmModal} onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
> >
<DialogContent> {/* Card 1: Basic Information */}
<DialogHeader> <Card>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> <CardHeader>
</DialogHeader> <CardTitle>{t('bots.basicInfo')}</CardTitle>
<DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription> <CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
<DialogFooter> </CardHeader>
<Button <CardContent className="space-y-4">
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>
)}
</>
)}
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -575,7 +411,7 @@ export default function BotForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('bots.botName')} {t('bots.botName')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -589,10 +425,7 @@ export default function BotForm({
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t('bots.botDescription')}</FormLabel>
{t('bots.botDescription')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -600,7 +433,84 @@ export default function BotForm({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="adapter" name="adapter"
@@ -608,76 +518,136 @@ export default function BotForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('bots.platformAdapter')} {t('bots.platformAdapter')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="relative"> <Select
<Select onValueChange={(value) => {
onValueChange={(value) => { field.onChange(value);
field.onChange(value); handleAdapterSelect(value);
handleAdapterSelect(value); }}
}} value={field.value}
value={field.value} >
> <SelectTrigger className="w-[240px]">
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]"> {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')} /> <SelectValue placeholder={t('bots.selectAdapter')} />
</SelectTrigger> )}
<SelectContent className="fixed z-[1000]"> </SelectTrigger>
<SelectGroup> <SelectContent>
{adapterNameList.map((item) => ( <SelectGroup>
<SelectItem key={item.value} value={item.value}> {adapterNameList.map((item) => (
{item.label} <SelectItem key={item.value} value={item.value}>
</SelectItem> <div className="flex items-center gap-2">
))} <img
</SelectGroup> src={httpClient.getAdapterIconURL(item.value)}
</SelectContent> alt=""
</Select> className="h-5 w-5 rounded"
</div> />
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl> </FormControl>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{form.watch('adapter') && ( {/* Webhook URL: shown after adapter is selected (edit mode only) */}
<div className="flex items-start gap-3 p-4 rounded-lg border"> {showWebhook && (
<img <FormItem>
src={adapterIconList[form.watch('adapter')]} <FormLabel>{t('bots.webhookUrl')}</FormLabel>
alt="adapter icon" <div className="flex items-center gap-2">
className="w-12 h-12 rounded-[8%]" <Input
/> value={webhookUrl}
<div className="flex flex-col gap-1"> readOnly
<div className="font-medium"> className="flex-1 bg-muted"
{ onClick={(e) => {
adapterNameList.find( (e.target as HTMLInputElement).select();
(item) => item.value === form.watch('adapter'), }}
)?.label />
} <Button
</div> type="button"
<div className="text-sm text-gray-500"> variant="outline"
{adapterDescriptionList[form.watch('adapter')]} size="sm"
</div> onClick={() => copyToClipboard(webhookUrl, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div> </div>
</div> {extraWebhookUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraWebhookUrl}
readOnly
className="flex-1 bg-muted"
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" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
<FormDescription>
{extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</FormDescription>
</FormItem>
)} )}
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && ( {showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
<div className="space-y-4"> <DynamicFormComponent
<div className="text-lg font-medium"> itemConfigList={filteredDynamicFormConfigList}
{t('bots.adapterConfig')} initialValues={currentAdapterConfig}
</div> onSubmit={(values) => {
<DynamicFormComponent form.setValue('adapter_config', values, {
itemConfigList={filteredDynamicFormConfigList} shouldDirty: !isInitializing.current,
initialValues={currentAdapterConfig} });
onSubmit={(values) => { }}
form.setValue('adapter_config', values); />
}}
/>
</div>
)} )}
</div> </CardContent>
</form> </Card>
</Form> </form>
</div> </Form>
); );
} }

View File

@@ -6,4 +6,5 @@ export interface IChooseAdapterEntity {
export interface IPipelineEntity { export interface IPipelineEntity {
label: string; label: string;
value: string; value: string;
emoji?: string;
} }

View File

@@ -2,220 +2,187 @@
import { useState } from 'react'; import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view'; import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner'; 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 { t } = useTranslation();
const baseURL = httpClient.getBaseUrl(); const baseURL = httpClient.getBaseUrl();
const [copied, setCopied] = useState(false); 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) { function fallbackCopy(text: string) {
const textArea = document.createElement('textarea'); const ta = document.createElement('textarea');
textArea.value = text; ta.value = text;
textArea.style.position = 'fixed'; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
textArea.style.left = '-9999px'; document.body.appendChild(ta);
textArea.style.top = '-9999px'; ta.focus();
document.body.appendChild(textArea); ta.select();
textArea.focus();
textArea.select();
try { try {
document.execCommand('copy'); document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess')); toast.success(t('common.copySuccess'));
} catch { } catch {
toast.error(t('common.copyFailed')); toast.error(t('common.copyFailed'));
} }
document.body.removeChild(textArea); document.body.removeChild(ta);
} }
function formatTime(timestamp: number) { function formatTime(timestamp: number) {
const now = new Date(); const now = new Date();
const date = new Date(timestamp * 1000); 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 hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断时间范围
const isToday = now.toDateString() === date.toDateString(); const isToday = now.toDateString() === date.toDateString();
const isYesterday = const yesterday = new Date(now);
new Date(now.setDate(now.getDate() - 1)).toDateString() === yesterday.setDate(yesterday.getDate() - 1);
date.toDateString(); const isYesterday = yesterday.toDateString() === date.toDateString();
const isThisYear = now.getFullYear() === year; const isThisYear = now.getFullYear() === date.getFullYear();
if (isToday) { if (isToday) return `${hours}:${minutes}`;
return `${hours}:${minutes}`; // 今天的消息:小时:分钟 if (isYesterday) return `${t('bots.yesterday')} ${hours}:${minutes}`;
} else if (isYesterday) { if (isThisYear)
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟 return t('bots.dateFormat', {
} else if (isThisYear) { month: date.getMonth() + 1,
return t('bots.dateFormat', { month, day }); // 本年消息x月x日 day: date.getDate(),
} else { });
return t('bots.earlier'); // 更早的消息:更久之前 return t('bots.earlier');
}
} }
function getSubChatId(str: string) { const needsExpand =
const strArr = str.split(''); botLog.text.length > SHORT_TEXT_LIMIT || botLog.images.length > 0;
return strArr; const levelStyle =
} LEVEL_STYLES[botLog.level.toLowerCase()] ?? LEVEL_STYLES.debug;
// 根据日志级别返回对应的样式类
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;
return ( return (
<div className={`${styles.botLogCardContainer}`}> <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={`${styles.cardTitleContainer}`}> <div className="flex items-center justify-between gap-2">
<div className={`flex flex-row gap-2 items-center`}> <div className="flex items-center gap-2 min-w-0">
<div {/* Level badge */}
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles( <span
botLog.level, 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} {botLog.level}
</div> </span>
{/* Session ID */}
{botLog.message_session_id && ( {botLog.message_session_id && (
<div <button
className={`${styles.tag} ${styles.chatTag} relative`} type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// 兼容性更好的复制方法 copySessionId();
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);
}
}} }}
title={t('common.clickToCopy')} 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 ? ( {copied ? (
<Check className="w-4 h-4 text-green-600" /> <Check className="size-3 shrink-0 text-green-600" />
) : ( ) : (
<svg <Copy className="size-3 shrink-0" />
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>
)} )}
<span className="truncate">{botLog.message_session_id}</span>
<span className={`${styles.chatId}`}> </button>
{getSubChatId(botLog.message_session_id)}
</span>
</div>
)} )}
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{needsExpand && ( {needsExpand && (
<button <button
type="button"
onClick={() => setExpanded(!expanded)} 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 ? ( {expanded ? (
<> <>
<ChevronDown className="w-3 h-3" /> <ChevronDown className="size-3" />
{t('bots.collapse')} {t('bots.collapse')}
</> </>
) : ( ) : (
<> <>
<ChevronRight className="w-3 h-3" /> <ChevronRight className="size-3" />
{t('bots.viewDetails')} {t('bots.viewDetails')}
</> </>
)} )}
</button> </button>
)} )}
<div className={`${styles.timestamp}`}> <span className="text-[11px] text-muted-foreground tabular-nums">
{formatTime(botLog.timestamp)} {formatTime(botLog.timestamp)}
</div> </span>
</div> </div>
</div> </div>
{/* 日志内容 - 简化显示 */} {/* Log text */}
<div className={`${styles.cardText}`}> <div className="mt-2 text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
{expanded ? botLog.text : getShortText(botLog.text)} {expanded
? botLog.text
: botLog.text.length > SHORT_TEXT_LIMIT
? botLog.text.slice(0, SHORT_TEXT_LIMIT) + '...'
: botLog.text}
</div> </div>
{/* 图片 - 只在展开时显示 */} {/* Images (expanded) */}
{expanded && botLog.images.length > 0 && ( {expanded && botLog.images.length > 0 && (
<PhotoProvider> <PhotoProvider>
<div className={`flex flex-wrap gap-2 mt-3`}> <div className="flex flex-wrap gap-2 mt-2.5">
{botLog.images.map((item) => ( {botLog.images.map((item) => (
<img <img
key={item} key={item}
src={`${baseURL}/api/v1/files/image/${item}`} src={`${baseURL}/api/v1/files/image/${item}`}
alt="" 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> </div>
</PhotoProvider> </PhotoProvider>
)} )}
{/* 图片数量提示 - 未展开时显示 */} {/* Image count hint (collapsed) */}
{!expanded && botLog.images.length > 0 && ( {!expanded && botLog.images.length > 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1.5 text-[11px] text-muted-foreground">
📷 {botLog.images.length} {t('bots.imagesAttached')} {botLog.images.length} {t('bots.imagesAttached')}
</div> </div>
)} )}
</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 { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard'; import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import {
Popover, Popover,
@@ -18,7 +17,20 @@ import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation'; 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 { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const manager = useRef(new BotLogManager(botId)).current; const manager = useRef(new BotLogManager(botId)).current;
@@ -50,7 +62,6 @@ export function BotLogListComponent({ botId }: { botId: string }) {
botLogListRef.current = botLogList; botLogListRef.current = botLogList;
}, [botLogList]); }, [botLogList]);
// 根据级别过滤日志
const filteredLogs = useMemo(() => { const filteredLogs = useMemo(() => {
if (selectedLevels.length === 0) { if (selectedLevels.length === 0) {
return botLogList; return botLogList;
@@ -75,18 +86,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (selectedLevels.length === logLevels.length) { if (selectedLevels.length === logLevels.length) {
return t('bots.allLevels'); return t('bots.allLevels');
} }
// 如果选中3个或以上显示数量
if (selectedLevels.length >= 3) { if (selectedLevels.length >= 3) {
return `${selectedLevels.length} ${t('bots.levelsSelected')}`; return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
} }
// 显示选中级别的标签(大写形式)
return logLevels return logLevels
.filter((level) => selectedLevels.includes(level.value)) .filter((level) => selectedLevels.includes(level.value))
.map((level) => level.label) .map((level) => level.label)
.join(', '); .join(', ');
}; };
// 观测自动刷新状态
useEffect(() => { useEffect(() => {
if (autoFlush) { if (autoFlush) {
manager.startListenServerPush(); manager.startListenServerPush();
@@ -99,13 +107,10 @@ export function BotLogListComponent({ botId }: { botId: string }) {
}, [autoFlush]); }, [autoFlush]);
function initComponent() { function initComponent() {
// 订阅日志推送
manager.subscribeLogPush(handleBotLogPush); manager.subscribeLogPush(handleBotLogPush);
// 加载第一页日志
manager.loadFirstPage().then((response) => { manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse()); setBotLogList(response.reverse());
}); });
// 监听滚动
listenScroll(); listenScroll();
} }
@@ -115,28 +120,19 @@ export function BotLogListComponent({ botId }: { botId: string }) {
} }
function listenScroll() { function listenScroll() {
if (!listContainerRef.current) { if (!listContainerRef.current) return;
return; listContainerRef.current.addEventListener('scroll', handleScroll);
}
const list = listContainerRef.current;
list.addEventListener('scroll', handleScroll);
} }
function removeScrollListener() { function removeScrollListener() {
if (!listContainerRef.current) { if (!listContainerRef.current) return;
return; listContainerRef.current.removeEventListener('scroll', handleScroll);
}
const list = listContainerRef.current;
list.removeEventListener('scroll', handleScroll);
} }
function loadMore() { function loadMore() {
// 加载更多日志
const list = botLogListRef.current; const list = botLogListRef.current;
const lastSeq = list[list.length - 1].seq_id; const lastSeq = list[list.length - 1].seq_id;
if (lastSeq === 0) { if (lastSeq === 0) return;
return;
}
manager.loadMore(lastSeq - 1, 10).then((response) => { manager.loadMore(lastSeq - 1, 10).then((response) => {
setBotLogList([...list, ...response.reverse()]); setBotLogList([...list, ...response.reverse()]);
}); });
@@ -165,63 +161,101 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (!isTop && !isBottom) { if (!isTop && !isBottom) {
setAutoFlush(false); setAutoFlush(false);
} }
}, 300), // 防抖延迟 300ms }, 300),
[botLogList], // 依赖项为空 [botLogList],
); );
return ( return (
<div className={`${styles.botLogListContainer}`} ref={listContainerRef}> <div
<div className={`${styles.listHeader}`}> className="flex flex-col h-full min-h-0 overflow-y-auto"
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div> ref={listContainerRef}
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} /> >
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div> {/* Toolbar */}
<Popover> {!hideToolbar && (
<PopoverTrigger asChild> <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 <Button
variant="outline" variant="outline"
size="sm" 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"> <ExternalLink className="size-3.5" />
{getDisplayText()} <span className="text-sm">{t('bots.viewDetailedLogs')}</span>
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
</Button> </Button>
</PopoverTrigger> )}
<PopoverContent className="w-[180px] p-2"> </div>
<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>
{filteredLogs.map((botLog) => { {/* Log cards */}
return <BotLogCard botLog={botLog} key={botLog.seq_id} />; {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> </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'; '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 { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import { import {
@@ -49,11 +55,18 @@ interface SessionMessage {
role?: string | null; role?: string | null;
} }
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
interface BotSessionMonitorProps { interface BotSessionMonitorProps {
botId: string; botId: string;
} }
export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) { const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle,
BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>( const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
@@ -97,6 +110,14 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
} }
}, [botId]); }, [botId]);
useImperativeHandle(
ref,
() => ({
refreshSessions: loadSessions,
}),
[loadSessions],
);
const loadMessages = useCallback(async (sessionId: string) => { const loadMessages = useCallback(async (sessionId: string) => {
setLoadingMessages(true); setLoadingMessages(true);
try { try {
@@ -235,7 +256,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
return ( return (
<div <div
key={index} 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"> <div className="text-sm">
{quote.origin?.map((comp, idx) => {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 => { const formatTime = (timestamp: string): string => {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); const date = parseTimestamp(timestamp);
const hours = date.getHours().toString().padStart(2, '0'); const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
@@ -288,7 +317,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const formatRelativeTime = (timestamp: string): string => { const formatRelativeTime = (timestamp: string): string => {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); const date = parseTimestamp(timestamp);
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000); const diffMins = Math.floor(diffMs / 60000);
@@ -306,36 +335,9 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
); );
return ( 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 */} {/* Left Panel: Session List */}
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0"> <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">
{/* 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>
{/* Session List */} {/* Session List */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? ( {loadingSessions && sessions.length === 0 ? (
@@ -347,14 +349,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{t('bots.sessionMonitor.noSessions')} {t('bots.sessionMonitor.noSessions')}
</div> </div>
) : ( ) : (
<div className="p-1"> <div className="p-1.5">
{sessions.map((session) => { {sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id; const isSelected = selectedSessionId === session.session_id;
return ( return (
<button <button
key={session.session_id} key={session.session_id}
type="button"
className={cn( 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', isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)} )}
onClick={() => setSelectedSessionId(session.session_id)} onClick={() => setSelectedSessionId(session.session_id)}
@@ -403,20 +406,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{/* Right Panel: Messages */} {/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0"> <div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? ( {!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')} {t('bots.sessionMonitor.selectSession')}
</div> </div>
) : ( ) : (
<> <>
{/* Chat Header */} {/* 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="min-w-0">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{selectedSession?.user_name || {selectedSession?.user_name ||
selectedSession?.user_id || selectedSession?.user_id ||
selectedSessionId.slice(0, 20)} selectedSessionId.slice(0, 20)}
</div> </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) && ( {parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span> <span>{parseSessionType(selectedSessionId)}</span>
)} )}
@@ -433,6 +436,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{selectedSession.user_id} {selectedSession.user_id}
</span> </span>
<button <button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)} onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')} title={t('common.copy')}
@@ -462,40 +466,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
)} )}
</div> </div>
</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> </div>
{/* Messages Area — matches DebugDialog style */} {/* Messages Area */}
<ScrollArea <ScrollArea
ref={messagesContainerRef} 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 ? ( {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')} {t('bots.sessionMonitor.loading')}
</div> </div>
) : messages.length === 0 ? ( ) : 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')} {t('bots.sessionMonitor.noMessages')}
</div> </div>
) : ( ) : (
@@ -511,21 +495,18 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
> >
<div <div
className={cn( className={cn(
'max-w-3xl px-5 py-3 rounded-2xl', 'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser isUser
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none' ? 'bg-primary/10 rounded-br-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none', : 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50', msg.status === 'error' && 'ring-1 ring-red-400/50',
)} )}
> >
{renderMessageContent(msg)} {renderMessageContent(msg)}
{/* Role label + timestamp inside bubble, matching DebugDialog */} {/* Role label + timestamp */}
<div <div
className={cn( className={cn(
'text-xs mt-2 flex items-center gap-2', 'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
isUser
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
)} )}
> >
<span> <span>
@@ -561,4 +542,6 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
</div> </div>
</div> </div>
); );
} });
export default BotSessionMonitor;

View File

@@ -1,144 +1,21 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation';
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 { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import BotDetailContent from './BotDetailContent';
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
import { CustomApiError } from '@/app/infra/entities/common';
import { systemInfo } from '@/app/infra/http';
export default function BotConfigPage() { export default function BotConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
// 机器人详情dialog const searchParams = useSearchParams();
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false); const detailId = searchParams.get('id');
const [botList, setBotList] = useState<BotCardVO[]>([]);
const [selectedBotId, setSelectedBotId] = useState<string>('');
useEffect(() => { if (detailId) {
getBotList(); return <BotDetailContent id={detailId} />;
}, []);
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);
} }
return ( return (
<div> <div className="flex h-full items-center justify-center text-muted-foreground">
<BotDetailDialog <p>{t('bots.selectFromSidebar')}</p>
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> </div>
); );
} }

View File

@@ -14,6 +14,31 @@ import DynamicFormItemComponent from '@/app/home/components/dynamic-form/Dynamic
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
/**
* 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];
}
export default function DynamicFormComponent({ export default function DynamicFormComponent({
itemConfigList, itemConfigList,
@@ -22,6 +47,7 @@ export default function DynamicFormComponent({
onFileUploaded, onFileUploaded,
isEditing, isEditing,
externalDependentValues, externalDependentValues,
systemContext,
}: { }: {
itemConfigList: IDynamicFormItemSchema[]; itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown; onSubmit?: (val: object) => unknown;
@@ -29,6 +55,9 @@ export default function DynamicFormComponent({
onFileUploaded?: (fileKey: string) => void; onFileUploaded?: (fileKey: string) => void;
isEditing?: boolean; isEditing?: boolean;
externalDependentValues?: Record<string, unknown>; 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 isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
@@ -60,6 +89,13 @@ export default function DynamicFormComponent({
fallbacks: [], 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; return value;
}; };
@@ -240,14 +276,12 @@ export default function DynamicFormComponent({
<div className="space-y-4"> <div className="space-y-4">
{itemConfigList.map((config) => { {itemConfigList.map((config) => {
if (config.show_if) { if (config.show_if) {
const dependValue = const dependValue = resolveShowIfValue(
watchedValues[ config.show_if.field,
config.show_if.field as keyof typeof watchedValues watchedValues as Record<string, unknown>,
] !== undefined externalDependentValues,
? watchedValues[ systemContext,
config.show_if.field as keyof typeof watchedValues );
]
: externalDependentValues?.[config.show_if.field];
if ( if (
config.show_if.operator === 'eq' && config.show_if.operator === 'eq' &&
@@ -273,6 +307,46 @@ export default function DynamicFormComponent({
// All fields are disabled when editing (creation_settings are immutable) // All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing; const isFieldDisabled = !!isEditing;
// 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 ( return (
<FormField <FormField
key={config.id} key={config.id}

View File

@@ -37,7 +37,7 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Eye, Wrench } from 'lucide-react'; import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
@@ -181,27 +181,28 @@ export default function DynamicFormItemComponent({
return ( return (
<Input <Input
type="number" type="number"
className="max-w-xs"
{...field} {...field}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(Number(e.target.value))}
/> />
); );
case DynamicFormItemType.STRING: case DynamicFormItemType.STRING:
return <Input {...field} />; return <Input className="max-w-md" {...field} />;
case DynamicFormItemType.TEXT: case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />; return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />; return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY: case DynamicFormItemType.STRING_ARRAY:
return ( return (
<div className="space-y-2"> <div className="space-y-2 max-w-md">
{field.value.map((item: string, index: number) => ( {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 <Input
className="w-[200px]" className="flex-1"
value={item} value={item}
onChange={(e) => { onChange={(e) => {
const newValue = [...field.value]; const newValue = [...field.value];
@@ -209,9 +210,11 @@ export default function DynamicFormItemComponent({
field.onChange(newValue); field.onChange(newValue);
}} }}
/> />
<button <Button
type="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={() => { onClick={() => {
const newValue = field.value.filter( const newValue = field.value.filter(
(_: string, i: number) => i !== index, (_: string, i: number) => i !== index,
@@ -219,24 +222,19 @@ export default function DynamicFormItemComponent({
field.onChange(newValue); field.onChange(newValue);
}} }}
> >
<svg <Trash2 className="size-4" />
xmlns="http://www.w3.org/2000/svg" </Button>
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>
</div> </div>
))} ))}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => {
field.onChange([...field.value, '']); field.onChange([...field.value, '']);
}} }}
> >
<Plus className="size-4 mr-1.5" />
{t('common.add')} {t('common.add')}
</Button> </Button>
</div> </div>
@@ -245,7 +243,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT: case DynamicFormItemType.SELECT:
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -274,31 +272,33 @@ export default function DynamicFormItemComponent({
); );
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <div className="max-w-md">
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <Select value={field.value} onValueChange={field.onChange}>
<SelectValue placeholder={t('models.selectModel')} /> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
</SelectTrigger> <SelectValue placeholder={t('models.selectModel')} />
<SelectContent> </SelectTrigger>
{Object.entries(groupedModels).map(([providerName, models]) => ( <SelectContent>
<SelectGroup key={providerName}> {Object.entries(groupedModels).map(([providerName, models]) => (
<SelectLabel>{providerName}</SelectLabel> <SelectGroup key={providerName}>
{models.map((model) => ( <SelectLabel>{providerName}</SelectLabel>
<SelectItem key={model.uuid} value={model.uuid}> {models.map((model) => (
<span className="inline-flex items-center gap-1"> <SelectItem key={model.uuid} value={model.uuid}>
{model.name} <span className="inline-flex items-center gap-1">
{model.abilities?.includes('vision') && ( {model.name}
<Eye className="h-3 w-3 text-muted-foreground" /> {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" /> {model.abilities?.includes('func_call') && (
)} <Wrench className="h-3 w-3 text-muted-foreground" />
</span> )}
</SelectItem> </span>
))} </SelectItem>
</SelectGroup> ))}
))} </SelectGroup>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
</div>
); );
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
@@ -314,25 +314,27 @@ export default function DynamicFormItemComponent({
); );
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <div className="max-w-md">
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <Select value={field.value} onValueChange={field.onChange}>
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} /> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
</SelectTrigger> <SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
<SelectContent> </SelectTrigger>
{Object.entries(groupedEmbeddingModels).map( <SelectContent>
([providerName, models]) => ( {Object.entries(groupedEmbeddingModels).map(
<SelectGroup key={providerName}> ([providerName, models]) => (
<SelectLabel>{providerName}</SelectLabel> <SelectGroup key={providerName}>
{models.map((model) => ( <SelectLabel>{providerName}</SelectLabel>
<SelectItem key={model.uuid} value={model.uuid}> {models.map((model) => (
{model.name} <SelectItem key={model.uuid} value={model.uuid}>
</SelectItem> {model.name}
))} </SelectItem>
</SelectGroup> ))}
), </SelectGroup>
)} ),
</SelectContent> )}
</Select> </SelectContent>
</Select>
</div>
); );
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
@@ -495,11 +497,11 @@ export default function DynamicFormItemComponent({
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="icon"
className="h-8 w-8 p-0 text-destructive" className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeFallbackModel(index)} onClick={() => removeFallbackModel(index)}
> >
<X className="h-4 w-4" /> <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -512,10 +514,10 @@ export default function DynamicFormItemComponent({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={addFallbackModel} onClick={addFallbackModel}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="size-4 mr-1.5" />
{t('models.fallback.addFallback')} {t('models.fallback.addFallback')}
</Button> </Button>
</div> </div>
@@ -541,7 +543,25 @@ export default function DynamicFormItemComponent({
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('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> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -553,7 +573,12 @@ export default function DynamicFormItemComponent({
<SelectLabel>{engineName}</SelectLabel> <SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => ( {kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}> <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> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -597,6 +622,11 @@ export default function DynamicFormItemComponent({
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2"> <div className="font-medium flex items-center gap-2">
{currentKb.emoji && (
<span className="text-sm shrink-0">
{currentKb.emoji}
</span>
)}
{currentKb.name} {currentKb.name}
{currentKb.knowledge_engine?.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"> <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 +716,14 @@ export default function DynamicFormItemComponent({
aria-label={`Select ${base.name}`} aria-label={`Select ${base.name}`}
/> />
<div className="flex-1"> <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 && ( {base.description && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{base.description} {base.description}
@@ -738,10 +775,18 @@ export default function DynamicFormItemComponent({
</Select> </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 ( return (
<div className="space-y-2"> <div className="space-y-2">
{field.value.map( {promptItems.map(
(item: { role: string; content: string }, index: number) => ( (item: { role: string; content: string }, index: number) => (
<div key={index} className="flex gap-2 items-center"> <div key={index} className="flex gap-2 items-center">
{/* 角色选择 */} {/* 角色选择 */}
@@ -753,7 +798,7 @@ export default function DynamicFormItemComponent({
<Select <Select
value={item.role} value={item.role}
onValueChange={(value) => { onValueChange={(value) => {
const newValue = [...field.value]; const newValue = [...(field.value ?? promptItems)];
newValue[index] = { ...newValue[index], role: value }; newValue[index] = { ...newValue[index], role: value };
field.onChange(newValue); field.onChange(newValue);
}} }}
@@ -774,7 +819,7 @@ export default function DynamicFormItemComponent({
className="w-[300px]" className="w-[300px]"
value={item.content} value={item.content}
onChange={(e) => { onChange={(e) => {
const newValue = [...field.value]; const newValue = [...(field.value ?? promptItems)];
newValue[index] = { newValue[index] = {
...newValue[index], ...newValue[index],
content: e.target.value, content: e.target.value,
@@ -788,7 +833,7 @@ export default function DynamicFormItemComponent({
type="button" type="button"
className="p-2 hover:bg-gray-100 rounded" className="p-2 hover:bg-gray-100 rounded"
onClick={() => { onClick={() => {
const newValue = field.value.filter( const newValue = (field.value ?? promptItems).filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(_: any, i: number) => i !== index, (_: any, i: number) => i !== index,
); );
@@ -812,13 +857,17 @@ export default function DynamicFormItemComponent({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
field.onChange([...field.value, { role: 'user', content: '' }]); field.onChange([
...(field.value ?? promptItems),
{ role: 'user', content: '' },
]);
}} }}
> >
{t('common.addRound')} {t('common.addRound')}
</Button> </Button>
</div> </div>
); );
}
case DynamicFormItemType.FILE: case DynamicFormItemType.FILE:
return ( 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'; import { I18nObject } from '@/app/infra/entities/common';
export type SidebarSection = 'home' | 'extensions' | 'standalone';
export interface ISidebarChildVO { export interface ISidebarChildVO {
id: string; id: string;
icon: React.ReactNode; icon: React.ReactNode;
@@ -8,6 +9,7 @@ export interface ISidebarChildVO {
route: string; route: string;
description: string; description: string;
helpLink: I18nObject; helpLink: I18nObject;
section?: SidebarSection;
} }
export class SidebarChildVO { export class SidebarChildVO {
@@ -17,6 +19,7 @@ export class SidebarChildVO {
route: string; route: string;
description: string; description: string;
helpLink: I18nObject; helpLink: I18nObject;
section: SidebarSection;
constructor(props: ISidebarChildVO) { constructor(props: ISidebarChildVO) {
this.id = props.id; this.id = props.id;
@@ -25,29 +28,6 @@ export class SidebarChildVO {
this.route = props.route; this.route = props.route;
this.description = props.description; this.description = props.description;
this.helpLink = props.helpLink; 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 { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import styles from './HomeSidebar.module.css';
import i18n from '@/i18n'; import i18n from '@/i18n';
const t = (key: string) => { const t = (key: string) => {
@@ -7,12 +6,54 @@ const t = (key: string) => {
}; };
export const sidebarConfigList = [ 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"
>
<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"
>
<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({ new SidebarChildVO({
id: 'bots', id: 'bots',
name: t('bots.title'), name: t('bots.title'),
icon: ( icon: (
<svg <svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
@@ -27,13 +68,13 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme', zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme', ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
}, },
section: 'home',
}), }),
new SidebarChildVO({ new SidebarChildVO({
id: 'pipelines', id: 'pipelines',
name: t('pipelines.title'), name: t('pipelines.title'),
icon: ( icon: (
<svg <svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
@@ -48,26 +89,7 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme', zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme', ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
}, },
}), section: 'home',
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: '',
},
}), }),
new SidebarChildVO({ new SidebarChildVO({
id: 'knowledge', id: 'knowledge',
@@ -88,13 +110,15 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme', zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme', ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
}, },
section: 'home',
}), }),
// ── Extensions section ──
new SidebarChildVO({ new SidebarChildVO({
id: 'plugins', id: 'plugins',
name: t('plugins.title'), name: t('sidebar.installedPlugins'),
icon: ( icon: (
<svg <svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
@@ -109,5 +133,47 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/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', 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"
>
<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"
>
<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>
</>
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -15,6 +15,13 @@ import {
FormMessage, FormMessage,
FormDescription, FormDescription,
} from '@/components/ui/form'; } from '@/components/ui/form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { import {
Select, Select,
@@ -39,9 +46,7 @@ import { UUID } from 'uuidjs';
const getFormSchema = (t: (key: string) => string) => const getFormSchema = (t: (key: string) => string) =>
z.object({ z.object({
name: z.string().min(1, { message: t('knowledge.kbNameRequired') }), name: z.string().min(1, { message: t('knowledge.kbNameRequired') }),
description: z description: z.string().optional(),
.string()
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
emoji: z.string().optional(), emoji: z.string().optional(),
ragEngineId: z ragEngineId: z
.string() .string()
@@ -50,17 +55,13 @@ const getFormSchema = (t: (key: string) => string) =>
/** /**
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[] * Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
* Same pattern as ExternalKBForm uses for retriever config
*/ */
function parseCreationSchema( function parseCreationSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
schemaItems: any | any[] | undefined, schemaItems: any | any[] | undefined,
): IDynamicFormItemSchema[] { ): IDynamicFormItemSchema[] {
if (!schemaItems) return []; if (!schemaItems) return [];
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema; const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
if (!items || !Array.isArray(items)) return []; if (!items || !Array.isArray(items)) return [];
return items.map( return items.map(
@@ -83,10 +84,12 @@ export default function KBForm({
initKbId, initKbId,
onNewKbCreated, onNewKbCreated,
onKbUpdated, onKbUpdated,
onDirtyChange,
}: { }: {
initKbId?: string; initKbId?: string;
onNewKbCreated: (kbId: string) => void; onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void; onKbUpdated: (kbId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]); const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
@@ -100,13 +103,17 @@ export default function KBForm({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison
const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true);
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: '', name: '',
description: t('knowledge.defaultDescription'), description: '',
emoji: '📚', emoji: '📚',
ragEngineId: '', ragEngineId: '',
}, },
@@ -117,6 +124,27 @@ export default function KBForm({
(e) => e.plugin_id === selectedEngineId, (e) => e.plugin_id === selectedEngineId,
); );
// Dirty tracking: compare current form + dynamic settings against saved snapshot
const watchedFormValues = form.watch();
useEffect(() => {
if (!savedSnapshotRef.current || isInitializing.current) return;
const currentSnapshot = JSON.stringify({
form: watchedFormValues,
config: configSettings,
retrieval: retrievalSettings,
});
const dirty = currentSnapshot !== savedSnapshotRef.current;
onDirtyChange?.(dirty);
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
const captureSnapshot = () => {
savedSnapshotRef.current = JSON.stringify({
form: form.getValues(),
config: configSettings,
retrieval: retrievalSettings,
});
};
useEffect(() => { useEffect(() => {
loadRagEngines().then(() => { loadRagEngines().then(() => {
if (initKbId) { if (initKbId) {
@@ -131,7 +159,6 @@ export default function KBForm({
const firstEngine = ragEngines[0]; const firstEngine = ragEngines[0];
setSelectedEngineId(firstEngine.plugin_id); setSelectedEngineId(firstEngine.plugin_id);
form.setValue('ragEngineId', firstEngine.plugin_id); form.setValue('ragEngineId', firstEngine.plugin_id);
// Initialize config settings with defaults
const formItems = parseCreationSchema(firstEngine.creation_schema); const formItems = parseCreationSchema(firstEngine.creation_schema);
if (formItems.length > 0) { if (formItems.length > 0) {
setConfigSettings(getDefaultValues(formItems)); setConfigSettings(getDefaultValues(formItems));
@@ -157,6 +184,7 @@ export default function KBForm({
const loadKbConfig = async (kbId: string) => { const loadKbConfig = async (kbId: string) => {
try { try {
isInitializing.current = true;
setIsEditing(true); setIsEditing(true);
const res = await httpClient.getKnowledgeBase(kbId); const res = await httpClient.getKnowledgeBase(kbId);
@@ -165,15 +193,24 @@ export default function KBForm({
const engineId = kb.knowledge_engine_plugin_id || ''; const engineId = kb.knowledge_engine_plugin_id || '';
setSelectedEngineId(engineId); setSelectedEngineId(engineId);
form.setValue('name', kb.name); form.reset({
form.setValue('description', kb.description); name: kb.name,
form.setValue('emoji', kb.emoji || '📚'); description: kb.description,
form.setValue('ragEngineId', engineId); emoji: kb.emoji || '📚',
ragEngineId: engineId,
});
setConfigSettings(kb.creation_settings || {}); setConfigSettings(kb.creation_settings || {});
setRetrievalSettings(kb.retrieval_settings || {}); setRetrievalSettings(kb.retrieval_settings || {});
// Capture snapshot after a tick so dynamic forms have emitted initial values
setTimeout(() => {
captureSnapshot();
isInitializing.current = false;
}, 500);
} catch (err) { } catch (err) {
console.error('Failed to load KB config:', err); console.error('Failed to load KB config:', err);
isInitializing.current = false;
} }
}; };
@@ -181,7 +218,6 @@ export default function KBForm({
setSelectedEngineId(engineId); setSelectedEngineId(engineId);
form.setValue('ragEngineId', engineId); form.setValue('ragEngineId', engineId);
// Find engine and initialize config settings with defaults from schema
const engine = ragEngines.find((e) => e.plugin_id === engineId); const engine = ragEngines.find((e) => e.plugin_id === engineId);
if (engine) { if (engine) {
const formItems = parseCreationSchema(engine.creation_schema); const formItems = parseCreationSchema(engine.creation_schema);
@@ -202,7 +238,7 @@ export default function KBForm({
const onSubmit = (data: z.infer<typeof formSchema>) => { const onSubmit = (data: z.infer<typeof formSchema>) => {
const kbData: KnowledgeBase = { const kbData: KnowledgeBase = {
name: data.name, name: data.name,
description: data.description, description: data.description ?? '',
emoji: data.emoji, emoji: data.emoji,
knowledge_engine_plugin_id: selectedEngineId, knowledge_engine_plugin_id: selectedEngineId,
creation_settings: configSettings, creation_settings: configSettings,
@@ -210,10 +246,11 @@ export default function KBForm({
}; };
if (initKbId) { if (initKbId) {
// Update knowledge base
httpClient httpClient
.updateKnowledgeBase(initKbId, kbData) .updateKnowledgeBase(initKbId, kbData)
.then((res) => { .then((res) => {
captureSnapshot();
onDirtyChange?.(false);
onKbUpdated(res.uuid); onKbUpdated(res.uuid);
toast.success(t('knowledge.updateKnowledgeBaseSuccess')); toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
}) })
@@ -225,7 +262,6 @@ export default function KBForm({
); );
}); });
} else { } else {
// Create knowledge base
httpClient httpClient
.createKnowledgeBase(kbData) .createKnowledgeBase(kbData)
.then((res) => { .then((res) => {
@@ -241,15 +277,11 @@ export default function KBForm({
} }
}; };
// Convert creation schema to dynamic form items (same as ExternalKBForm)
// Memoize to avoid regenerating UUIDs on every render, which would cause
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
const configFormItems = useMemo( const configFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.creation_schema), () => parseCreationSchema(selectedEngine?.creation_schema),
[selectedEngine?.creation_schema], [selectedEngine?.creation_schema],
); );
// Convert retrieval schema to dynamic form items
const retrievalFormItems = useMemo( const retrievalFormItems = useMemo(
() => parseCreationSchema(selectedEngine?.retrieval_schema), () => parseCreationSchema(selectedEngine?.retrieval_schema),
[selectedEngine?.retrieval_schema], [selectedEngine?.retrieval_schema],
@@ -282,65 +314,21 @@ export default function KBForm({
} }
return ( return (
<> <Form {...form}>
<Form {...form}> <form
<form onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit)} id="kb-form"
id="kb-form" className="space-y-6"
className="space-y-8" >
> {/* Card 1: Basic Information */}
<div className="space-y-4"> <Card>
{/* Knowledge Engine Selector */} <CardHeader>
<FormField <CardTitle>{t('knowledge.basicInfo')}</CardTitle>
control={form.control} <CardDescription>
name="ragEngineId" {t('knowledge.basicInfoDescription')}
render={({ field }) => ( </CardDescription>
<FormItem> </CardHeader>
<FormLabel> <CardContent className="space-y-4">
{t('knowledge.knowledgeEngine')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
disabled={isEditing}
onValueChange={(value) => {
field.onChange(value);
handleEngineChange(value);
}}
value={field.value}
>
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectKnowledgeEngine')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
{ragEngines.map((engine) => (
<SelectItem
key={engine.plugin_id}
value={engine.plugin_id}
>
{extractI18nObject(engine.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{selectedEngine?.description && (
<FormDescription>
{extractI18nObject(selectedEngine.description)}
</FormDescription>
)}
{isEditing && (
<FormDescription>
{t('knowledge.cannotChangeKnowledgeEngine')}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{/* Name and Emoji in same row */} {/* Name and Emoji in same row */}
<div className="flex gap-4 items-start"> <div className="flex gap-4 items-start">
<FormField <FormField
@@ -350,7 +338,7 @@ export default function KBForm({
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel> <FormLabel>
{t('knowledge.kbName')} {t('knowledge.kbName')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -383,10 +371,7 @@ export default function KBForm({
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t('knowledge.kbDescription')}</FormLabel>
{t('knowledge.kbDescription')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -395,47 +380,143 @@ export default function KBForm({
)} )}
/> />
{/* Engine specific fields (dynamic form from creation_schema) */} {/* Knowledge Engine Selector */}
{configFormItems.length > 0 && ( <FormField
<div className="space-y-4 pt-2 border-t"> control={form.control}
<div className="text-sm font-medium text-muted-foreground"> name="ragEngineId"
{t('knowledge.engineSettings')} render={({ field }) => (
</div> <FormItem>
<div> <FormLabel>
<DynamicFormComponent {t('knowledge.knowledgeEngine')}
itemConfigList={configFormItems} <span className="text-destructive">*</span>
initialValues={configSettings as Record<string, object>} </FormLabel>
onSubmit={(val) => <FormControl>
setConfigSettings(val as Record<string, unknown>) <Select
} disabled={isEditing}
isEditing={isEditing} onValueChange={(value) => {
externalDependentValues={retrievalSettings} field.onChange(value);
/> handleEngineChange(value);
</div> }}
</div> value={field.value}
)} >
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value ? (
(() => {
const [author, name] = field.value.split('/');
const engine = ragEngines.find(
(e) => e.plugin_id === field.value,
);
return (
<div className="flex items-center gap-2">
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{engine
? extractI18nObject(engine.name)
: field.value}
</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('knowledge.selectKnowledgeEngine')}
/>
)}
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
{ragEngines.map((engine) => {
const [author, name] = engine.plugin_id.split('/');
return (
<SelectItem
key={engine.plugin_id}
value={engine.plugin_id}
>
<div className="flex items-center gap-2">
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{extractI18nObject(engine.name)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormControl>
{selectedEngine?.description && (
<FormDescription>
{extractI18nObject(selectedEngine.description)}
</FormDescription>
)}
{isEditing && (
<FormDescription>
{t('knowledge.cannotChangeKnowledgeEngine')}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Retrieval settings (dynamic form from retrieval_schema) */} {/* Card 2: Engine Settings (dynamic form from creation_schema) */}
{retrievalFormItems.length > 0 && ( {configFormItems.length > 0 && (
<div className="space-y-4 pt-2 border-t"> <Card>
<div className="text-sm font-medium text-muted-foreground"> <CardHeader>
{t('knowledge.retrievalSettings')} <CardTitle>{t('knowledge.engineSettings')}</CardTitle>
</div> <CardDescription>
<div> {t('knowledge.engineSettingsDescription')}
<DynamicFormComponent </CardDescription>
itemConfigList={retrievalFormItems} </CardHeader>
initialValues={retrievalSettings as Record<string, object>} <CardContent>
onSubmit={(val) => <DynamicFormComponent
setRetrievalSettings(val as Record<string, unknown>) itemConfigList={configFormItems}
} initialValues={configSettings as Record<string, object>}
externalDependentValues={configSettings} onSubmit={(val) =>
/> setConfigSettings(val as Record<string, unknown>)
</div> }
</div> isEditing={isEditing}
)} externalDependentValues={retrievalSettings}
</div> />
</form> </CardContent>
</Form> </Card>
</> )}
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
<CardDescription>
{t('knowledge.retrievalSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={retrievalFormItems}
initialValues={retrievalSettings as Record<string, object>}
onSubmit={(val) =>
setRetrievalSettings(val as Record<string, unknown>)
}
externalDependentValues={configSettings}
/>
</CardContent>
</Card>
)}
</form>
</Form>
); );
} }

View File

@@ -1,31 +1,25 @@
'use client'; 'use client';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent'; import { useSearchParams } from 'next/navigation';
import styles from './knowledgeBase.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api'; import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import KBDetailContent from './KBDetailContent';
export default function KnowledgePage() { export default function KnowledgePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>( const searchParams = useSearchParams();
[], const detailId = searchParams.get('id');
); const { refreshKnowledgeBases } = useSidebarData();
const [selectedKbId, setSelectedKbId] = useState<string>('');
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// Migration dialog state // Migration dialog state — checked on page load regardless of detail view
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [migrationInternalCount, setMigrationInternalCount] = useState(0); const [migrationInternalCount, setMigrationInternalCount] = useState(0);
const [migrationExternalCount, setMigrationExternalCount] = useState(0); const [migrationExternalCount, setMigrationExternalCount] = useState(0);
useEffect(() => { useEffect(() => {
getKnowledgeBaseList();
checkMigrationStatus(); checkMigrationStatus();
}, []); }, []);
@@ -42,75 +36,27 @@ export default function KnowledgePage() {
} }
} }
async function getKnowledgeBaseList() { function handleMigrationComplete() {
const resp = await httpClient.getKnowledgeBases(); refreshKnowledgeBases();
const currentTime = new Date();
const kbs = resp.bases.map((kb: KnowledgeBase) => {
const lastUpdatedTimeAgo = Math.floor(
(currentTime.getTime() -
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
1000 /
60 /
60 /
24,
);
const lastUpdatedTimeAgoText =
lastUpdatedTimeAgo > 0
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
: t('knowledge.today');
return new KnowledgeBaseVO({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
emoji: kb.emoji,
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
ragEngine: kb.knowledge_engine,
ragEnginePluginId: kb.knowledge_engine_plugin_id,
});
});
setKnowledgeBaseList(kbs);
} }
const handleKBCardClick = (kbId: string) => { if (detailId) {
setSelectedKbId(kbId); return (
setDetailDialogOpen(true); <>
}; <KBMigrationDialog
open={migrationDialogOpen}
const handleCreateKBClick = () => { onOpenChange={setMigrationDialogOpen}
setSelectedKbId(''); internalKbCount={migrationInternalCount}
setDetailDialogOpen(true); externalKbCount={migrationExternalCount}
}; onMigrationComplete={handleMigrationComplete}
/>
const handleFormCancel = () => { <KBDetailContent id={detailId} />
setDetailDialogOpen(false); </>
}; );
}
const handleKbDeleted = () => {
getKnowledgeBaseList();
setDetailDialogOpen(false);
};
const handleNewKbCreated = (newKbId: string) => {
getKnowledgeBaseList();
setSelectedKbId(newKbId);
setDetailDialogOpen(true);
};
const handleKbUpdated = () => {
getKnowledgeBaseList();
};
const handleMigrationComplete = () => {
getKnowledgeBaseList();
};
return ( return (
<div> <>
<KBMigrationDialog <KBMigrationDialog
open={migrationDialogOpen} open={migrationDialogOpen}
onOpenChange={setMigrationDialogOpen} onOpenChange={setMigrationDialogOpen}
@@ -118,33 +64,9 @@ export default function KnowledgePage() {
externalKbCount={migrationExternalCount} externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete} onMigrationComplete={handleMigrationComplete}
/> />
<div className="flex h-full items-center justify-center text-muted-foreground">
<KBDetailDialog <p>{t('knowledge.selectFromSidebar')}</p>
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
kbId={selectedKbId || undefined}
onFormCancel={handleFormCancel}
onKbDeleted={handleKbDeleted}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
</div>
);
})}
</div> </div>
</div> </>
); );
} }

View File

@@ -1,54 +0,0 @@
/* 主布局容器 */
.homeLayoutContainer {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
background-color: #eee;
}
:global(.dark) .homeLayoutContainer {
background-color: #0a0a0b;
}
/* 侧边栏区域 */
.sidebar {
background-color: #eee;
}
:global(.dark) .sidebar {
background-color: #0a0a0b;
}
/* 主内容区域 */
.main {
background-color: #fafafa;
flex: 1;
display: flex;
flex-direction: column;
/* height: 100vh; */
width: calc(100% - 1.2rem);
height: calc(100% - 1.2rem);
overflow: hidden;
border-radius: 1.5rem 0 0 1.5rem;
margin-left: 0.6rem;
margin-top: 0.6rem;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.05);
}
:global(.dark) .main {
background-color: #151518;
box-shadow: 0 0 6px 0 rgba(255, 255, 255, 0.05);
}
.mainContent {
padding: 1.5rem;
padding-left: 2rem;
flex: 1;
overflow-y: auto;
background-color: #fafafa;
}
:global(.dark) .mainContent {
background-color: #151518;
}

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import styles from './layout.module.css';
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar'; import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
import SurveyWidget from '@/app/home/components/survey/SurveyWidget'; import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
import React, { import React, {
useState, useState,
@@ -12,21 +10,46 @@ import React, {
Suspense, Suspense,
} from 'react'; } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import {
SidebarDataProvider,
useSidebarData,
} from '@/app/home/components/home-sidebar/SidebarDataContext';
import { I18nObject } from '@/app/infra/entities/common'; import { I18nObject } from '@/app/infra/entities/common';
import { userInfo, initializeUserInfo } from '@/app/infra/http'; import { userInfo, initializeUserInfo } from '@/app/infra/http';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CircleHelp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
function isExtensionsRoute(pathname: string): boolean {
return EXTENSIONS_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + '/'),
);
}
export default function HomeLayout({ export default function HomeLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const [title, setTitle] = useState<string>('');
const [subtitle, setSubtitle] = useState<string>('');
const [helpLink, setHelpLink] = useState<I18nObject>({
en_US: '',
zh_Hans: '',
});
// Initialize user info if not already initialized // Initialize user info if not already initialized
useEffect(() => { useEffect(() => {
if (!userInfo) { if (!userInfo) {
@@ -34,30 +57,96 @@ export default function HomeLayout({
} }
}, []); }, []);
return (
<SidebarDataProvider>
<HomeLayoutInner>{children}</HomeLayoutInner>
</SidebarDataProvider>
);
}
function HomeLayoutInner({ children }: { children: React.ReactNode }) {
const [title, setTitle] = useState<string>('');
const [helpLink, setHelpLink] = useState<I18nObject>({
en_US: '',
zh_Hans: '',
});
const { detailEntityName } = useSidebarData();
const pathname = usePathname();
const { t } = useTranslation();
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => { const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
setTitle(child.name); setTitle(child.name);
setSubtitle(child.description);
setHelpLink(child.helpLink); setHelpLink(child.helpLink);
}, []); }, []);
// Memoize the main content area to prevent re-renders when sidebar state changes // Memoize the main content area to prevent re-renders when sidebar state changes
const mainContent = useMemo(() => children, [children]); const mainContent = useMemo(() => children, [children]);
const resolvedHelpLink = extractI18nObject(helpLink);
// Determine breadcrumb section label and default link based on current route
const isExtensions = isExtensionsRoute(pathname);
const sectionLabel = isExtensions
? t('sidebar.extensions')
: t('sidebar.home');
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
return ( return (
<div className={styles.homeLayoutContainer}> <SidebarProvider>
<aside className={styles.sidebar}> <Suspense fallback={<div />}>
<Suspense fallback={<div />}> <HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} /> </Suspense>
</Suspense>
</aside>
<div className={styles.main}> <SidebarInset>
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} /> <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink asChild>
<Link href={sectionLink}>{sectionLabel}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
{detailEntityName && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{detailEntityName}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
{resolvedHelpLink && (
<>
<BreadcrumbItem>
<a
href={resolvedHelpLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<CircleHelp className="size-3.5" />
</a>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<main className={styles.mainContent}>{mainContent}</main> <div className="flex-1 overflow-hidden p-4 pt-0">{mainContent}</div>
<SurveyWidget /> <SurveyWidget />
</div> </SidebarInset>
</div> </SidebarProvider>
); );
} }

View File

@@ -0,0 +1,196 @@
'use client';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import React, { useState, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function MarketplacePage() {
const { t } = useTranslation();
if (!systemInfo?.enable_marketplace) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
</div>
);
}
return <MarketplaceContent />;
}
function MarketplaceContent() {
const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
function watchTask(taskId: number) {
let alreadySuccess = false;
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR);
} else {
if (!alreadySuccess) {
toast.success(t('plugins.installSuccess'));
alreadySuccess = true;
}
setModalOpen(false);
refreshPlugins();
}
}
});
}, 1000);
}
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[t],
);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<>
<div className="h-full overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} />
</div>
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,301 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
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 MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('mcp.createServer'));
} else {
const server = mcpServers.find((s) => s.id === id);
setDetailEntityName(server?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// Ref to MCPForm for triggering test from header
const formRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false);
// Enable state managed here so the header switch works
const [serverEnabled, setServerEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
// Fetch server enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setEnableLoaded(true);
});
}
}, [id, isCreateMode]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
const prev = serverEnabled;
setServerEnabled(checked);
try {
await httpClient.toggleMCPServer(id, checked);
refreshMCPServers();
} catch {
setServerEnabled(prev);
toast.error(t('mcp.modifyFailed'));
}
},
[id, serverEnabled, refreshMCPServers, t],
);
function handleFormSubmit() {
// Re-sync enable state after form save
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
});
refreshMCPServers();
}
function handleServerDeleted() {
refreshMCPServers();
router.push('/home/mcp');
}
function handleNewServerCreated(serverName: string) {
refreshMCPServers();
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
}
function confirmDelete() {
httpClient
.deleteMCPServer(id)
.then(() => {
setShowDeleteConfirm(false);
toast.success(t('mcp.deleteSuccess'));
handleServerDeleted();
})
.catch((err) => {
toast.error(t('mcp.deleteFailed') + (err.msg || ''));
});
}
// Check extensions limit before creating
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
// ==================== 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('mcp.createServer')}</h1>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="submit"
form="mcp-form"
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
}
}}
>
{t('common.submit')}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<MCPForm
ref={formRef}
initServerName={undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
/>
</div>
</div>
</div>
);
}
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* 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('mcp.editServer')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
/>
<Label
htmlFor="mcp-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button type="submit" form="mcp-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<MCPForm
ref={formRef}
initServerName={id}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
/>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>
{t('mcp.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('mcp.deleteMCPAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</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>
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
<DialogDescription className="sr-only">
{t('mcp.confirmDeleteServer')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('mcp.confirmDeleteServer')}</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

@@ -0,0 +1,926 @@
'use client';
import React, {
useState,
useEffect,
useRef,
forwardRef,
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPServer,
MCPSessionStatus,
MCPServerExtraArgsSSE,
MCPServerExtraArgsHttp,
MCPServerExtraArgsStdio,
} from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
// Status display for test / connecting / error states
function StatusDisplay({
testing,
runtimeInfo,
t,
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
t: (key: string) => string;
}) {
if (testing) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
}
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message}
</div>
)}
</div>
);
}
// Tools list component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
))}
</div>
);
}
const getFormSchema = (t: (key: string) => string) =>
z
.object({
name: z
.string({ required_error: t('mcp.nameRequired') })
.min(1, { message: t('mcp.nameRequired') }),
mode: z.enum(['sse', 'stdio', 'http']),
timeout: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(30),
ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(300),
url: z.string().optional(),
command: z.string().optional(),
args: z.array(z.object({ value: z.string() })).optional(),
extra_args: z
.array(
z.object({
key: z.string(),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
}),
)
.optional(),
})
.superRefine((data, ctx) => {
if (data.mode === 'sse' || data.mode === 'http') {
if (!data.url || data.url.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.urlRequired'),
path: ['url'],
});
}
} else if (data.mode === 'stdio') {
if (!data.command || data.command.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.commandRequired'),
path: ['command'],
});
}
}
});
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number;
ssereadtimeout: number;
};
interface MCPFormProps {
initServerName?: string;
onFormSubmit: () => void;
onNewServerCreated: (serverName: string) => void;
onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void;
}
// Handle exposed to parent via ref
export interface MCPFormHandle {
testMcp: () => void;
isTesting: boolean;
}
const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
{
initServerName,
onFormSubmit,
onNewServerCreated,
onDirtyChange,
onTestingChange,
},
ref,
) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const isEditMode = !!initServerName;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
defaultValues: {
name: '',
mode: 'sse',
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
},
});
// Track whether initial data loading is complete (to avoid marking form dirty)
const isInitializing = useRef(true);
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
const [mcpTesting, setMcpTesting] = useState(false);
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null,
);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
// Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
// Notify parent when testing state changes
useEffect(() => {
onTestingChange?.(mcpTesting);
}, [mcpTesting, onTestingChange]);
// Expose test action and testing state to parent
useImperativeHandle(
ref,
() => ({
testMcp: () => testMcp(),
isTesting: mcpTesting,
}),
[mcpTesting],
);
// Load server data
useEffect(() => {
isInitializing.current = true;
if (isEditMode && initServerName) {
loadServerForEdit(initServerName).finally(() => {
isInitializing.current = false;
});
} else {
form.reset({
name: '',
mode: 'sse',
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
});
setExtraArgs([]);
setStdioArgs([]);
setRuntimeInfo(null);
isInitializing.current = false;
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [initServerName]);
// Poll for updates when runtime_info status is CONNECTING
useEffect(() => {
if (
!isEditMode ||
!initServerName ||
!runtimeInfo ||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return;
}
if (!pollingIntervalRef.current) {
pollingIntervalRef.current = setInterval(() => {
loadServerForEdit(initServerName);
}, 3000);
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [isEditMode, initServerName, runtimeInfo?.status]);
async function loadServerForEdit(serverName: string) {
try {
const resp = await httpClient.getMCPServer(serverName);
const server = resp.server ?? resp;
const formValues: FormValues = {
name: server.name,
mode: server.mode,
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
};
let newExtraArgs: {
key: string;
type: 'string' | 'number' | 'boolean';
value: string;
}[] = [];
let newStdioArgs: { value: string }[] = [];
if (server.mode === 'sse' || server.mode === 'http') {
formValues.url = server.extra_args.url;
formValues.timeout = server.extra_args.timeout;
if (server.mode === 'sse') {
formValues.ssereadtimeout = server.extra_args.ssereadtimeout;
}
if (server.extra_args.headers) {
newExtraArgs = Object.entries(server.extra_args.headers).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
formValues.extra_args = newExtraArgs;
}
} else if (server.mode === 'stdio') {
formValues.command = server.extra_args.command;
newStdioArgs = (server.extra_args.args || []).map((arg: string) => ({
value: arg,
}));
formValues.args = newStdioArgs;
if (server.extra_args.env) {
newExtraArgs = Object.entries(server.extra_args.env).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
formValues.extra_args = newExtraArgs;
}
}
setExtraArgs(newExtraArgs);
setStdioArgs(newStdioArgs);
// Use form.reset so isDirty stays false after initial load
form.reset(formValues);
if (server.runtime_info) {
setRuntimeInfo(server.runtime_info);
} else {
setRuntimeInfo(null);
}
} catch (error) {
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
}
}
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
try {
let serverConfig: MCPServer;
if (value.mode === 'sse' || value.mode === 'http') {
const headers: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
headers[arg.key] = String(arg.value);
});
if (value.mode === 'sse') {
serverConfig = {
name: value.name,
mode: 'sse',
enable: true,
extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
};
} else {
serverConfig = {
name: value.name,
mode: 'http',
enable: true,
extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
},
};
}
} else {
const env: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
env[arg.key] = String(arg.value);
});
const args = value.args?.map((arg) => arg.value) || [];
serverConfig = {
name: value.name,
mode: 'stdio',
enable: true,
extra_args: {
command: value.command!,
args: args,
env: env,
},
};
}
if (isEditMode && initServerName) {
await httpClient.updateMCPServer(initServerName, serverConfig);
toast.success(t('mcp.updateSuccess'));
// Reset dirty baseline to current values
form.reset(form.getValues());
onFormSubmit();
} else {
await httpClient.createMCPServer(serverConfig);
toast.success(t('mcp.createSuccess'));
onNewServerCreated(value.name);
}
} catch (error) {
console.error('Failed to save MCP server:', error);
const errMsg = (error as CustomApiError).msg || '';
toast.error(
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
);
}
}
async function testMcp() {
setMcpTesting(true);
try {
const mode = form.getValues('mode');
let extraArgsData:
| MCPServerExtraArgsSSE
| MCPServerExtraArgsHttp
| MCPServerExtraArgsStdio;
if (mode === 'sse') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
ssereadtimeout: form.getValues('ssereadtimeout'),
};
} else if (mode === 'http') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
};
} else {
extraArgsData = {
command: form.getValues('command')!,
args: stdioArgs.map((arg) => arg.value),
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
};
}
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
mode: mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
if (!task_id) {
throw new Error(t('mcp.noTaskId'));
}
const interval = setInterval(async () => {
try {
const taskResp = await httpClient.getAsyncTask(task_id);
if (taskResp.runtime?.done) {
clearInterval(interval);
setMcpTesting(false);
if (taskResp.runtime.exception) {
const errorMsg =
taskResp.runtime.exception || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
setRuntimeInfo({
status: MCPSessionStatus.ERROR,
error_message: errorMsg,
tool_count: 0,
tools: [],
});
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
}
toast.success(t('mcp.testSuccess'));
}
}
} catch (err) {
clearInterval(interval);
setMcpTesting(false);
const errorMsg =
(err as CustomApiError).msg || t('mcp.getTaskFailed');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}, 1000);
} catch (err) {
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}
const addExtraArg = () => {
const newArgs = [
...extraArgs,
{ key: '', type: 'string' as const, value: '' },
];
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs, {
shouldDirty: !isInitializing.current,
});
};
const removeExtraArg = (index: number) => {
const newArgs = extraArgs.filter((_, i) => i !== index);
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs, {
shouldDirty: !isInitializing.current,
});
};
const updateExtraArg = (
index: number,
field: 'key' | 'type' | 'value',
value: string,
) => {
const newArgs = [...extraArgs];
newArgs[index] = { ...newArgs[index], [field]: value };
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs, {
shouldDirty: !isInitializing.current,
});
};
const addStdioArg = () => {
const newArgs = [...stdioArgs, { value: '' }];
setStdioArgs(newArgs);
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
};
const removeStdioArg = (index: number) => {
const newArgs = stdioArgs.filter((_, i) => i !== index);
setStdioArgs(newArgs);
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
};
const updateStdioArg = (index: number, value: string) => {
const newArgs = [...stdioArgs];
newArgs[index] = { value };
setStdioArgs(newArgs);
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
};
return (
<Form {...form}>
<form
id="mcp-form"
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-6"
>
{/* Runtime info: status + tools (edit mode only) */}
{isEditMode && runtimeInfo && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
</CardContent>
</Card>
)}
{/* Server configuration */}
<Card>
<CardHeader>
<CardTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</CardTitle>
<CardDescription>
{t('mcp.extraParametersDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} disabled={isEditMode} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchMode === 'sse' || watchMode === 'http') && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.url')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('mcp.command')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('mcp.args')}</FormLabel>
<div className="space-y-2">
{stdioArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('mcp.args')}
value={arg.value}
onChange={(e) =>
updateStdioArg(index, e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeStdioArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
</svg>
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addStdioArg}
>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem>
<FormLabel>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-red-500 hover:text-red-600"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
</svg>
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</CardContent>
</Card>
</form>
</Form>
);
});
export default MCPForm;

View File

@@ -0,0 +1,21 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import MCPDetailContent from './MCPDetailContent';
export default function MCPPage() {
const { t } = useTranslation();
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
return <MCPDetailContent id={detailId} />;
}
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('mcp.selectFromSidebar')}</p>
</div>
);
}

View File

@@ -172,7 +172,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0" className="shadow-sm flex-shrink-0"
disabled={exporting !== null} disabled={exporting !== null}
> >
{exporting ? ( {exporting ? (

View File

@@ -76,7 +76,7 @@ export function MessageContentRenderer({
return ( return (
<span <span
key={index} key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm" className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
> >
[Image] [Image]
</span> </span>
@@ -87,8 +87,8 @@ export function MessageContentRenderer({
<span key={index} className="inline-block align-middle mx-1"> <span key={index} className="inline-block align-middle mx-1">
<img <img
src={imageUrl} src={imageUrl}
alt="Image" alt="Message attachment"
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border border-gray-200 dark:border-gray-700" className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewImageUrl(imageUrl); setPreviewImageUrl(imageUrl);
@@ -104,7 +104,7 @@ export function MessageContentRenderer({
return ( return (
<span <span
key={index} key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm" className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
> >
<svg <svg
className="w-3.5 h-3.5 mr-1" className="w-3.5 h-3.5 mr-1"
@@ -123,7 +123,7 @@ export function MessageContentRenderer({
return ( return (
<span <span
key={index} key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm" className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
> >
<svg <svg
className="w-3.5 h-3.5 mr-1" className="w-3.5 h-3.5 mr-1"
@@ -142,7 +142,7 @@ export function MessageContentRenderer({
return ( return (
<span <span
key={index} key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm border-l-2 border-gray-400" className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
> >
{quote.origin {quote.origin
?.filter((c) => (c as MessageChainComponent).type === 'Plain') ?.filter((c) => (c as MessageChainComponent).type === 'Plain')
@@ -159,7 +159,7 @@ export function MessageContentRenderer({
return ( return (
<span <span
key={index} key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm" className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
> >
[{component.type}] [{component.type}]
</span> </span>
@@ -188,9 +188,7 @@ export function MessageContentRenderer({
// If no visible components, show placeholder // If no visible components, show placeholder
if (visibleComponents.length === 0) { if (visibleComponents.length === 0) {
return ( return (
<span className="text-gray-400 dark:text-gray-500 italic"> <span className="text-muted-foreground italic">[Empty message]</span>
[Empty message]
</span>
); );
} }
@@ -219,9 +217,7 @@ export function MessageContentRenderer({
content === '""' content === '""'
) { ) {
return ( return (
<span className="text-gray-400 dark:text-gray-500 italic"> <span className="text-muted-foreground italic">[Empty message]</span>
[Empty message]
</span>
); );
} }

View File

@@ -22,11 +22,11 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
}, [details.message?.variables]); }, [details.message?.variables]);
return ( return (
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4"> <div className="space-y-4 pl-8 border-l-2 border-border ml-4">
{/* Context Info Section */} {/* Context Info Section */}
{details.message && ( {details.message && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> <h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -41,37 +41,37 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* Metadata Grid */} {/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{details.message.platform && ( {details.message.platform && (
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.platform')} {t('monitoring.messageList.platform')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-foreground">
{details.message.platform} {details.message.platform}
</div> </div>
</div> </div>
)} )}
{details.message.userId && ( {details.message.userId && (
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.user')} {t('monitoring.messageList.user')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white truncate"> <div className="font-medium text-foreground truncate">
{details.message.userId} {details.message.userId}
</div> </div>
</div> </div>
)} )}
{details.message.runnerName && ( {details.message.runnerName && (
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.runner')} {t('monitoring.messageList.runner')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-foreground">
{details.message.runnerName} {details.message.runnerName}
</div> </div>
</div> </div>
)} )}
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.level')} {t('monitoring.messageList.level')}
</div> </div>
<div <div
@@ -80,7 +80,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
? 'text-red-600 dark:text-red-400' ? 'text-red-600 dark:text-red-400'
: details.message.level === 'warning' : details.message.level === 'warning'
? 'text-yellow-600 dark:text-yellow-400' ? 'text-yellow-600 dark:text-yellow-400'
: 'text-gray-900 dark:text-white' : 'text-foreground'
}`} }`}
> >
{details.message.level.toUpperCase()} {details.message.level.toUpperCase()}
@@ -92,8 +92,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* LLM Calls Section */} {/* LLM Calls Section */}
{details.llmCalls && details.llmCalls.length > 0 && ( {details.llmCalls && details.llmCalls.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> <h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -136,13 +136,10 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* Individual LLM Calls */} {/* Individual LLM Calls */}
<div className="space-y-2"> <div className="space-y-2">
{details.llmCalls.map((call, index) => ( {details.llmCalls.map((call, index) => (
<div <div key={call.id} className="bg-background rounded p-2 text-sm">
key={call.id}
className="bg-white dark:bg-gray-900 rounded p-2 text-sm"
>
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<div> <div>
<span className="font-medium text-gray-900 dark:text-white"> <span className="font-medium text-foreground">
#{index + 1} {call.modelName} #{index + 1} {call.modelName}
</span> </span>
<span <span
@@ -155,27 +152,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{call.status} {call.status}
</span> </span>
</div> </div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-muted-foreground">
{call.duration}ms {call.duration}ms
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400"> <div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div> <div>
<span className="text-gray-500 dark:text-gray-500"> <span className="text-muted-foreground">In:</span>{' '}
In:
</span>{' '}
{call.tokens.input.toLocaleString()} {call.tokens.input.toLocaleString()}
</div> </div>
<div> <div>
<span className="text-gray-500 dark:text-gray-500"> <span className="text-muted-foreground">Out:</span>{' '}
Out:
</span>{' '}
{call.tokens.output.toLocaleString()} {call.tokens.output.toLocaleString()}
</div> </div>
<div> <div>
<span className="text-gray-500 dark:text-gray-500"> <span className="text-muted-foreground">Total:</span>{' '}
Total:
</span>{' '}
{call.tokens.total.toLocaleString()} {call.tokens.total.toLocaleString()}
</div> </div>
</div> </div>
@@ -192,7 +183,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{/* Errors Section */} {/* Errors Section */}
{details.errors && details.errors.length > 0 && ( {details.errors && details.errors.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center"> <h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
@@ -236,8 +227,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{queryVariables && {queryVariables &&
Object.keys(queryVariables).length > 0 && Object.keys(queryVariables).length > 0 &&
details.message?.runnerName !== 'local-agent' && ( details.message?.runnerName !== 'local-agent' && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> <h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -250,22 +241,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</h4> </h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => ( {Object.entries(queryVariables).map(([key, value]) => (
<div <div key={key} className="bg-background rounded p-2">
key={key} <div className="text-muted-foreground">{key}</div>
className="bg-white dark:bg-gray-900 rounded p-2"
>
<div className="text-gray-500 dark:text-gray-400">{key}</div>
<div <div
className="font-medium text-gray-900 dark:text-white truncate" className="font-medium text-foreground truncate"
title={ title={
typeof value === 'string' ? value : JSON.stringify(value) typeof value === 'string' ? value : JSON.stringify(value)
} }
> >
{value === null || value === undefined ? ( {value === null || value === undefined ? (
<span className="text-gray-400 italic">null</span> <span className="text-muted-foreground italic">null</span>
) : typeof value === 'string' ? ( ) : typeof value === 'string' ? (
value || ( value || (
<span className="text-gray-400 italic">empty</span> <span className="text-muted-foreground italic">
empty
</span>
) )
) : ( ) : (
JSON.stringify(value) JSON.stringify(value)
@@ -283,7 +273,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
(details.message?.runnerName === 'local-agent' || (details.message?.runnerName === 'local-agent' ||
!queryVariables || !queryVariables ||
Object.keys(queryVariables).length === 0) && ( Object.keys(queryVariables).length === 0) && (
<div className="text-sm text-gray-500 dark:text-gray-400 text-center py-4"> <div className="text-sm text-muted-foreground text-center py-4">
{t('monitoring.messageDetails.noData')} {t('monitoring.messageDetails.noData')}
</div> </div>
)} )}

View File

@@ -114,7 +114,7 @@ export default function MonitoringFilters({
<div className="flex flex-wrap items-center gap-6"> <div className="flex flex-wrap items-center gap-6">
{/* Bot Filter */} {/* Bot Filter */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap"> <label className="text-sm font-medium text-foreground whitespace-nowrap">
{t('monitoring.filters.bot')} {t('monitoring.filters.bot')}
</label> </label>
<Select <Select
@@ -122,7 +122,7 @@ export default function MonitoringFilters({
onValueChange={handleBotChange} onValueChange={handleBotChange}
disabled={loadingBots} disabled={loadingBots}
> >
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]"> <SelectTrigger className="h-9 w-[140px]">
<SelectValue <SelectValue
placeholder={ placeholder={
loadingBots loadingBots
@@ -146,7 +146,7 @@ export default function MonitoringFilters({
{/* Pipeline Filter */} {/* Pipeline Filter */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap"> <label className="text-sm font-medium text-foreground whitespace-nowrap">
{t('monitoring.filters.pipeline')} {t('monitoring.filters.pipeline')}
</label> </label>
<Select <Select
@@ -154,7 +154,7 @@ export default function MonitoringFilters({
onValueChange={handlePipelineChange} onValueChange={handlePipelineChange}
disabled={loadingPipelines} disabled={loadingPipelines}
> >
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]"> <SelectTrigger className="h-9 w-[140px]">
<SelectValue <SelectValue
placeholder={ placeholder={
loadingPipelines loadingPipelines
@@ -178,11 +178,11 @@ export default function MonitoringFilters({
{/* Time Range Filter */} {/* Time Range Filter */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap"> <label className="text-sm font-medium text-foreground whitespace-nowrap">
{t('monitoring.filters.timeRange')} {t('monitoring.filters.timeRange')}
</label> </label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}> <Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[150px]"> <SelectTrigger className="h-9 w-[150px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -23,9 +23,9 @@ export default function MetricCard({
}: MetricCardProps) { }: MetricCardProps) {
if (loading) { if (loading) {
return ( return (
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300"> <Card className="transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400"> <CardTitle className="text-sm font-medium text-muted-foreground">
{title} {title}
</CardTitle> </CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center"> <div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
@@ -35,17 +35,17 @@ export default function MetricCard({
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div> <div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
<div className="h-4 w-20 bg-gray-100 dark:bg-gray-800 animate-pulse rounded mt-2"></div> <div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
return ( return (
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300 group"> <Card className="transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400"> <CardTitle className="text-sm font-medium text-muted-foreground">
{title} {title}
</CardTitle> </CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300"> <div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
@@ -53,9 +53,7 @@ export default function MetricCard({
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <div className="text-3xl font-bold text-foreground mb-2">{value}</div>
{value}
</div>
{trend && ( {trend && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
@@ -82,7 +80,7 @@ export default function MetricCard({
</svg> </svg>
{Math.abs(trend.value)}% {Math.abs(trend.value)}%
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-muted-foreground">
vs previous period vs previous period
</span> </span>
</div> </div>

View File

@@ -126,16 +126,16 @@ export default function TrafficChart({
if (loading) { if (loading) {
return ( return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6"> <div className="bg-card rounded-xl border p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div> <div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div> <div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div> <div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
</div> </div>
</div> </div>
<div className="h-[300px] flex items-center justify-center"> <div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse w-full h-full bg-gray-100 dark:bg-gray-800 rounded"></div> <div className="animate-pulse w-full h-full bg-muted rounded"></div>
</div> </div>
</div> </div>
); );
@@ -143,13 +143,13 @@ export default function TrafficChart({
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6"> <div className="bg-card rounded-xl border p-6">
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-4"> <h3 className="text-base font-semibold text-foreground mb-4">
{t('monitoring.trafficChart.title')} {t('monitoring.trafficChart.title')}
</h3> </h3>
<div className="h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500"> <div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
<svg <svg
className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600" className="w-16 h-16 mb-4 text-muted-foreground/30"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -170,8 +170,8 @@ export default function TrafficChart({
} }
return ( return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6 hover:shadow-md transition-shadow duration-300"> <div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-6"> <h3 className="text-base font-semibold text-foreground mb-6">
{t('monitoring.trafficChart.title')} {t('monitoring.trafficChart.title')}
</h3> </h3>
<div className="h-[300px]"> <div className="h-[300px]">
@@ -192,38 +192,38 @@ export default function TrafficChart({
</defs> </defs>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
stroke="#e5e7eb" stroke="var(--border)"
className="dark:stroke-gray-700"
vertical={false} vertical={false}
/> />
<XAxis <XAxis
dataKey="time" dataKey="time"
tick={{ fontSize: 12, fill: '#9ca3af' }} tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: 'var(--border)' }}
dy={10} dy={10}
/> />
<YAxis <YAxis
tick={{ fontSize: 12, fill: '#9ca3af' }} tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: 'var(--border)' }}
width={40} width={40}
allowDecimals={false} allowDecimals={false}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)', backgroundColor: 'var(--card)',
border: '1px solid #e5e7eb', border: '1px solid var(--border)',
borderRadius: '12px', borderRadius: '12px',
boxShadow: boxShadow:
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
fontSize: '13px', fontSize: '13px',
padding: '12px', padding: '12px',
color: 'var(--foreground)',
}} }}
labelStyle={{ labelStyle={{
fontWeight: 600, fontWeight: 600,
marginBottom: '8px', marginBottom: '8px',
color: '#374151', color: 'var(--foreground)',
}} }}
itemStyle={{ padding: '4px 0' }} itemStyle={{ padding: '4px 0' }}
/> />

View File

@@ -188,11 +188,11 @@ function MonitoringPageContent() {
}; };
return ( return (
<div className="w-full h-full"> <div className="w-full h-full overflow-y-auto">
{/* Filters and Refresh Button - Sticky */} {/* Filters and Refresh Button - Sticky */}
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-[#fafafa] dark:bg-[#151518]"> <div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]"> <div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"> <div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
<MonitoringFilters <MonitoringFilters
selectedBots={filterState.selectedBots} selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines} selectedPipelines={filterState.selectedPipelines}
@@ -207,7 +207,7 @@ function MonitoringPageContent() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={refetch} onClick={refetch}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0" className="shadow-sm flex-shrink-0"
> >
<svg <svg
className="w-4 h-4 mr-2" className="w-4 h-4 mr-2"
@@ -235,30 +235,21 @@ function MonitoringPageContent() {
/> />
{/* Tabs Section */} {/* Tabs Section */}
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden"> <div className="bg-card rounded-xl border overflow-hidden">
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="w-full" className="w-full"
> >
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<TabsList className="bg-gray-100 dark:bg-[#1a1a1e] h-12 p-1"> <TabsList className="h-12 p-1">
<TabsTrigger <TabsTrigger value="messages" className="px-6 py-2">
value="messages"
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.messages')} {t('monitoring.tabs.messages')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="modelCalls" className="px-6 py-2">
value="modelCalls"
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.modelCalls')} {t('monitoring.tabs.modelCalls')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="errors" className="px-6 py-2">
value="errors"
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.errors')} {t('monitoring.tabs.errors')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -290,11 +281,11 @@ function MonitoringPageContent() {
.map((msg) => ( .map((msg) => (
<div <div
key={msg.id} key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200" className="border rounded-xl overflow-hidden transition-all duration-200"
> >
{/* Message Header - Always Visible */} {/* Message Header - Always Visible */}
<div <div
className="p-5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors" className="p-5 cursor-pointer hover:bg-accent transition-colors"
onClick={() => toggleMessageExpand(msg.id)} onClick={() => toggleMessageExpand(msg.id)}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -302,39 +293,41 @@ function MonitoringPageContent() {
{/* Expand Icon */} {/* Expand Icon */}
<div className="mr-3 mt-0.5"> <div className="mr-3 mt-0.5">
{expandedMessageId === msg.id ? ( {expandedMessageId === msg.id ? (
<ChevronDown className="w-5 h-5 text-gray-500" /> <ChevronDown className="w-5 h-5 text-muted-foreground" />
) : ( ) : (
<ChevronRight className="w-5 h-5 text-gray-500" /> <ChevronRight className="w-5 h-5 text-muted-foreground" />
)} )}
</div> </div>
{/* Message Info */} {/* Message Info */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono"> <span className="text-xs text-muted-foreground font-mono">
ID: {msg.id} ID: {msg.id}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-gray-700 dark:text-gray-300"> <span className="font-medium text-sm text-foreground">
{msg.botName} {msg.botName}
</span> </span>
<span className="text-gray-400"></span> <span className="text-muted-foreground">
<span className="text-sm text-gray-600 dark:text-gray-400">
</span>
<span className="text-sm text-muted-foreground">
{msg.pipelineName} {msg.pipelineName}
</span> </span>
{msg.runnerName && ( {msg.runnerName && (
<> <>
<span className="text-gray-400"> <span className="text-muted-foreground">
</span> </span>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="text-sm text-muted-foreground">
{msg.runnerName} {msg.runnerName}
</span> </span>
</> </>
)} )}
</div> </div>
<div className="text-base text-gray-800 dark:text-gray-200"> <div className="text-base text-foreground">
<MessageContentRenderer <MessageContentRenderer
content={msg.messageContent} content={msg.messageContent}
maxLines={3} maxLines={3}
@@ -345,7 +338,7 @@ function MonitoringPageContent() {
{/* Status and Timestamp */} {/* Status and Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4"> <div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{msg.timestamp.toLocaleString()} {msg.timestamp.toLocaleString()}
</span> </span>
<span <span
@@ -365,7 +358,7 @@ function MonitoringPageContent() {
{/* Expanded Details */} {/* Expanded Details */}
{expandedMessageId === msg.id && ( {expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900"> <div className="border-t p-4 bg-muted">
{loadingDetails[msg.id] && ( {loadingDetails[msg.id] && (
<div className="py-4 flex justify-center"> <div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" /> <LoadingSpinner size="sm" text="" />
@@ -386,9 +379,9 @@ function MonitoringPageContent() {
{!loading && {!loading &&
(!data || !data.messages || data.messages.length === 0) && ( (!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16"> <div className="text-center text-muted-foreground py-16">
<svg <svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -427,14 +420,14 @@ function MonitoringPageContent() {
{data.modelCalls.map((call) => ( {data.modelCalls.map((call) => (
<div <div
key={call.id} key={call.id}
className="border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:shadow-md transition-all duration-200" className="border rounded-xl p-5 transition-all duration-200"
> >
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div className="flex-1"> <div className="flex-1">
{/* Query ID - only show if messageId exists */} {/* Query ID - only show if messageId exists */}
{call.messageId && ( {call.messageId && (
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono"> <span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId} Query ID: {call.messageId}
</span> </span>
<Button <Button
@@ -496,19 +489,19 @@ function MonitoringPageContent() {
</span> </span>
</div> </div>
{/* Model Name */} {/* Model Name */}
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2"> <div className="font-medium text-sm text-foreground mb-2">
{call.modelName} {call.modelName}
</div> </div>
{/* Context Info - only for LLM calls */} {/* Context Info - only for LLM calls */}
{call.modelType === 'llm' && {call.modelType === 'llm' &&
call.botName && call.botName &&
call.pipelineName && ( call.pipelineName && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1"> <div className="text-xs text-muted-foreground mb-1">
{call.botName} {call.pipelineName} {call.botName} {call.pipelineName}
</div> </div>
)} )}
{/* Token Info */} {/* Token Info */}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{call.modelType === 'llm' && call.tokens && ( {call.modelType === 'llm' && call.tokens && (
<> <>
@@ -572,14 +565,14 @@ function MonitoringPageContent() {
{/* Query Text for Embedding Retrieve */} {/* Query Text for Embedding Retrieve */}
{call.modelType === 'embedding' && {call.modelType === 'embedding' &&
call.queryText && ( call.queryText && (
<div className="mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm"> <div className="mt-2 p-2 bg-muted rounded text-sm">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-muted-foreground">
{t( {t(
'monitoring.embeddingCalls.queryText', 'monitoring.embeddingCalls.queryText',
)} )}
:{' '} :{' '}
</span> </span>
<span className="text-gray-700 dark:text-gray-300"> <span className="text-foreground">
{call.queryText.length > 100 {call.queryText.length > 100
? call.queryText.substring(0, 100) + ? call.queryText.substring(0, 100) +
'...' '...'
@@ -594,7 +587,7 @@ function MonitoringPageContent() {
</div> </div>
)} )}
</div> </div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4"> <span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()} {call.timestamp.toLocaleString()}
</span> </span>
</div> </div>
@@ -607,9 +600,9 @@ function MonitoringPageContent() {
(!data || (!data ||
!data.modelCalls || !data.modelCalls ||
data.modelCalls.length === 0) && ( data.modelCalls.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16"> <div className="text-center text-muted-foreground py-16">
<svg <svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -642,7 +635,7 @@ function MonitoringPageContent() {
{data.errors.map((error) => ( {data.errors.map((error) => (
<div <div
key={error.id} key={error.id}
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200" className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
> >
{/* Error Header - Always Visible */} {/* Error Header - Always Visible */}
<div <div
@@ -664,7 +657,7 @@ function MonitoringPageContent() {
<div className="flex-1"> <div className="flex-1">
{/* Query ID */} {/* Query ID */}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono"> <span className="text-xs text-muted-foreground font-mono">
Query ID: {error.messageId || '-'} Query ID: {error.messageId || '-'}
</span> </span>
{error.messageId && ( {error.messageId && (
@@ -689,11 +682,11 @@ function MonitoringPageContent() {
{error.errorType} {error.errorType}
</span> </span>
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="text-sm text-muted-foreground">
{error.botName} {error.botName}
</span> </span>
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="text-sm text-muted-foreground">
{error.pipelineName} {error.pipelineName}
</span> </span>
</div> </div>
@@ -705,7 +698,7 @@ function MonitoringPageContent() {
{/* Timestamp */} {/* Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4"> <div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{error.timestamp.toLocaleString()} {error.timestamp.toLocaleString()}
</span> </span>
</div> </div>
@@ -714,7 +707,7 @@ function MonitoringPageContent() {
{/* Expanded Details */} {/* Expanded Details */}
{expandedErrorId === error.id && ( {expandedErrorId === error.id && (
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-white dark:bg-gray-900"> <div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4"> <div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
{/* Error Details */} {/* Error Details */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3"> <div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
@@ -727,33 +720,33 @@ function MonitoringPageContent() {
</div> </div>
{/* Context Info */} {/* Context Info */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> <h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.messageList.viewDetails')} {t('monitoring.messageList.viewDetails')}
</h4> </h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.bot')} {t('monitoring.messageList.bot')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-foreground">
{error.botName} {error.botName}
</div> </div>
</div> </div>
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.messageList.pipeline')} {t('monitoring.messageList.pipeline')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-foreground">
{error.pipelineName} {error.pipelineName}
</div> </div>
</div> </div>
{error.sessionId && ( {error.sessionId && (
<div className="bg-white dark:bg-gray-900 rounded p-2"> <div className="bg-background rounded p-2">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground">
{t('monitoring.sessions.sessionId')} {t('monitoring.sessions.sessionId')}
</div> </div>
<div className="font-medium text-gray-900 dark:text-white truncate"> <div className="font-medium text-foreground truncate">
{error.sessionId} {error.sessionId}
</div> </div>
</div> </div>
@@ -763,11 +756,11 @@ function MonitoringPageContent() {
{/* Stack Trace */} {/* Stack Trace */}
{error.stackTrace && ( {error.stackTrace && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3"> <h4 className="text-sm font-semibold text-foreground mb-3">
{t('monitoring.errors.stackTrace')} {t('monitoring.errors.stackTrace')}
</h4> </h4>
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-3 rounded whitespace-pre-wrap break-words"> <pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
{error.stackTrace} {error.stackTrace}
</pre> </pre>
</div> </div>
@@ -782,7 +775,7 @@ function MonitoringPageContent() {
{!loading && {!loading &&
(!data || !data.errors || data.errors.length === 0) && ( (!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16"> <div className="text-center text-muted-foreground py-16">
<svg <svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600" className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none" fill="none"

View File

@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, Bug, BarChart3 } from 'lucide-react';
export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const { t } = useTranslation();
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('pipelines.createPipeline'));
} else {
const pipeline = pipelines.find((p) => p.id === id);
setDetailEntityName(pipeline?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
function handleFinish() {
refreshPipelines();
}
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
}
// ==================== 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('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6">
<PipelineFormComponent
pipelineId={undefined}
isEditMode={false}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
/>
</div>
</div>
</div>
);
}
function handleDeletePipeline() {
refreshPipelines();
router.push('/home/pipelines');
}
// ==================== 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('pipelines.editPipeline')}</h1>
<Button type="submit" form="pipeline-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('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => router.push('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</TabsContent>
{/* Tab: Debug */}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
{/* Tab: Monitoring */}
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
router.push('/home/monitoring');
}}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,278 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import DebugDialog from './components/debug-dialog/DebugDialog';
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
import PipelineMonitoringTab from './components/monitoring-tab/PipelineMonitoringTab';
interface PipelineDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pipelineId?: string;
isEditMode?: boolean;
isDefaultPipeline?: boolean;
onFinish: () => void;
onNewPipelineCreated?: (pipelineId: string) => void;
onDeletePipeline: () => void;
onCancel: () => void;
}
type DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';
export default function PipelineDialog({
open,
onOpenChange,
pipelineId: propPipelineId,
isEditMode = false,
onFinish,
onNewPipelineCreated,
onDeletePipeline,
onCancel,
}: PipelineDialogProps) {
const { t } = useTranslation();
const router = useRouter();
const [pipelineId, setPipelineId] = useState<string | undefined>(
propPipelineId,
);
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
useEffect(() => {
setPipelineId(propPipelineId);
setCurrentMode('config');
}, [propPipelineId, open]);
const handleFinish = () => {
onFinish();
};
const handleNewPipelineCreated = (newPipelineId: string) => {
setPipelineId(newPipelineId);
setCurrentMode('config');
if (onNewPipelineCreated) {
onNewPipelineCreated(newPipelineId);
}
};
const menu = [
{
key: 'config',
label: t('pipelines.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: 'extensions',
label: t('pipelines.extensions.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
),
},
{
key: 'debug',
label: t('pipelines.debugChat'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
</svg>
),
},
{
key: 'monitoring',
label: t('pipelines.monitoring.title'),
icon: (
<svg
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>
),
},
];
const getDialogTitle = () => {
if (currentMode === 'config') {
return isEditMode
? t('pipelines.editPipeline')
: t('pipelines.createPipeline');
}
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
if (currentMode === 'monitoring') {
return t('pipelines.monitoring.title');
}
return t('pipelines.debugDialog.title');
};
// 创建新流水线时的对话框
if (!isEditMode) {
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('pipelines.createPipeline')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<PipelineFormComponent
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}
pipelineId={pipelineId}
disableForm={false}
showButtons={true}
onDeletePipeline={onDeletePipeline}
onCancel={() => {
onCancel();
}}
/>
</div>
</main>
</DialogContent>
</Dialog>
);
}
// 编辑流水线时的对话框
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex">
<SidebarProvider className="items-start w-full flex h-full min-h-0">
<Sidebar
collapsible="none"
className="hidden md:flex h-full min-h-0 w-40 border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={currentMode === item.key}
onClick={() => setCurrentMode(item.key as DialogMode)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-full min-h-0">
<DialogHeader
className="px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start"
style={{ height: '4rem' }}
>
<DialogTitle>{getDialogTitle()}</DialogTitle>
{currentMode === 'debug' && (
<div className="flex items-center gap-2 ml-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
title={
isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')
}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')}
</span>
</div>
)}
</DialogHeader>
<div
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
style={{ height: 'calc(100% - 4rem)' }}
>
{currentMode === 'config' && (
<PipelineFormComponent
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}
pipelineId={pipelineId}
disableForm={false}
showButtons={true}
onDeletePipeline={onDeletePipeline}
onCancel={() => {
onCancel();
}}
/>
)}
{currentMode === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
{currentMode === 'debug' && pipelineId && (
<DebugDialog
open={true}
pipelineId={pipelineId}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
)}
{currentMode === 'monitoring' && pipelineId && (
<PipelineMonitoringTab
pipelineId={pipelineId}
onNavigateToMonitoring={() => {
router.push(`/home/monitoring?pipelineId=${pipelineId}`);
onOpenChange(false);
}}
/>
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}

View File

@@ -29,6 +29,17 @@ import rehypeSanitize from 'rehype-sanitize';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import '@/styles/github-markdown.css'; import '@/styles/github-markdown.css';
import {
User,
Users,
ImageIcon,
Paperclip,
Send,
Reply,
Music,
Code,
AlignLeft,
} from 'lucide-react';
interface DebugDialogProps { interface DebugDialogProps {
open: boolean; open: boolean;
@@ -71,7 +82,7 @@ export default function DebugDialog({
const isInitializingRef = useRef<boolean>(false); const isInitializingRef = useRef<boolean>(false);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
// 使用setTimeout确保在DOM更新后执行滚动 // Use setTimeout to ensure scroll happens after DOM update
setTimeout(() => { setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement; const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
if (scrollArea) { if (scrollArea) {
@@ -80,7 +91,7 @@ export default function DebugDialog({
behavior: 'smooth', behavior: 'smooth',
}); });
} }
// 同时确保messagesEndRef也滚动到视图 // Also ensure messagesEndRef scrolls into view
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0); }, 0);
}, []); }, []);
@@ -100,10 +111,10 @@ export default function DebugDialog({
[sessionType], [sessionType],
); );
// 初始化WebSocket连接 // Initialize WebSocket connection
const initWebSocket = useCallback( const initWebSocket = useCallback(
async (pipelineId: string) => { async (pipelineId: string) => {
// 防止重复初始化 // Prevent duplicate initialization
if (isInitializingRef.current) { if (isInitializingRef.current) {
return; return;
} }
@@ -111,13 +122,13 @@ export default function DebugDialog({
try { try {
isInitializingRef.current = true; isInitializingRef.current = true;
// 断开旧连接 // Disconnect old connection
if (wsClientRef.current) { if (wsClientRef.current) {
wsClientRef.current.disconnect(); wsClientRef.current.disconnect();
wsClientRef.current = null; wsClientRef.current = null;
} }
// 创建新连接 // Create new connection
const wsClient = new WebSocketClient(pipelineId, sessionType); const wsClient = new WebSocketClient(pipelineId, sessionType);
wsClient wsClient
@@ -126,31 +137,31 @@ export default function DebugDialog({
isInitializingRef.current = false; isInitializingRef.current = false;
}) })
.onMessage((wsMessage) => { .onMessage((wsMessage) => {
// WebSocketMessage 转换为 Message 类型 // Convert WebSocketMessage to Message type
const message: Message = { const message: Message = {
...wsMessage, ...wsMessage,
message_chain: wsMessage.message_chain as MessageChainComponent[], message_chain: wsMessage.message_chain as MessageChainComponent[],
}; };
setMessages((prevMessages) => { setMessages((prevMessages) => {
// 查找是否已存在相同ID的消息 // Check if message with same ID already exists
const existingIndex = prevMessages.findIndex( const existingIndex = prevMessages.findIndex(
(m) => m.id === message.id, (m) => m.id === message.id,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
// 更新已存在的消息(流式输出) // Update existing message (streaming output)
const newMessages = [...prevMessages]; const newMessages = [...prevMessages];
newMessages[existingIndex] = message; newMessages[existingIndex] = message;
return newMessages; return newMessages;
} else { } else {
// 添加新消息 // Add new message
return [...prevMessages, message]; return [...prevMessages, message];
} }
}); });
}) })
.onError((error) => { .onError((error) => {
console.error('WebSocket错误:', error); console.error('WebSocket error:', error);
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError')); toast.error(t('pipelines.debugDialog.connectionError'));
@@ -166,7 +177,7 @@ export default function DebugDialog({
await wsClient.connect(); await wsClient.connect();
wsClientRef.current = wsClient; wsClientRef.current = wsClient;
} catch (error) { } catch (error) {
console.error('WebSocket连接失败:', error); console.error('WebSocket connection failed:', error);
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed')); toast.error(t('pipelines.debugDialog.connectionFailed'));
@@ -175,17 +186,17 @@ export default function DebugDialog({
[sessionType, t], [sessionType, t],
); );
// 在useEffect中监听messages变化时滚动 // Scroll when messages change
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [messages, scrollToBottom]); }, [messages, scrollToBottom]);
// 监听 open pipelineId 变化,进入时连接,离开时断开 // Watch open and pipelineId changes: connect on open, disconnect on close
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelectedPipelineId(pipelineId); setSelectedPipelineId(pipelineId);
} else { } else {
// 关闭对话框时立即断开WebSocket // Disconnect WebSocket immediately when dialog closes
if (wsClientRef.current) { if (wsClientRef.current) {
wsClientRef.current.disconnect(); wsClientRef.current.disconnect();
wsClientRef.current = null; wsClientRef.current = null;
@@ -195,7 +206,7 @@ export default function DebugDialog({
} }
return () => { return () => {
// 组件卸载时断开WebSocket // Disconnect WebSocket on component unmount
if (wsClientRef.current) { if (wsClientRef.current) {
wsClientRef.current.disconnect(); wsClientRef.current.disconnect();
wsClientRef.current = null; wsClientRef.current = null;
@@ -204,17 +215,17 @@ export default function DebugDialog({
}; };
}, [open, pipelineId]); }, [open, pipelineId]);
// 监听 sessionType selectedPipelineId 变化,重新加载消息和连接 // Reload messages and reconnect when sessionType or selectedPipelineId changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
// 清空当前消息,避免显示旧的消息 // Clear current messages to avoid showing stale messages
setMessages([]); setMessages([]);
loadMessages(selectedPipelineId); loadMessages(selectedPipelineId);
initWebSocket(selectedPipelineId); initWebSocket(selectedPipelineId);
} }
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]); }, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
// 通知父组件连接状态变化 // Notify parent of connection status changes
useEffect(() => { useEffect(() => {
onConnectionStatusChange?.(isConnected); onConnectionStatusChange?.(isConnected);
}, [isConnected, onConnectionStatusChange]); }, [isConnected, onConnectionStatusChange]);
@@ -321,9 +332,9 @@ export default function DebugDialog({
const messageChain = []; const messageChain = [];
// 添加引用消息(如果有) // Add quoted message if present
if (quotedMessage) { if (quotedMessage) {
// 获取被引用消息的Source组件以获取message_id // Get message_id from the quoted message Source component
const sourceComponent = quotedMessage.message_chain.find( const sourceComponent = quotedMessage.message_chain.find(
(c) => c.type === 'Source', (c) => c.type === 'Source',
) as Source | undefined; ) as Source | undefined;
@@ -353,7 +364,7 @@ export default function DebugDialog({
}); });
} }
// 添加文本 // Add text content
if (text_content) { if (text_content) {
messageChain.push({ messageChain.push({
type: 'Plain', type: 'Plain',
@@ -361,7 +372,7 @@ export default function DebugDialog({
}); });
} }
// 上传图片并添加到消息链 // Upload images and add to message chain
for (const image of selectedImages) { for (const image of selectedImages) {
try { try {
const result = await httpClient.uploadWebSocketImage( const result = await httpClient.uploadWebSocketImage(
@@ -373,20 +384,20 @@ export default function DebugDialog({
path: result.file_key, path: result.file_key,
}); });
} catch (error) { } catch (error) {
console.error('图片上传失败:', error); console.error('Image upload failed:', error);
toast.error(t('pipelines.debugDialog.imageUploadFailed')); toast.error(t('pipelines.debugDialog.imageUploadFailed'));
} }
} }
// 清空输入框、图片和引用消息 // Clear input, images, and quoted message
setInputValue(''); setInputValue('');
setHasAt(false); setHasAt(false);
setQuotedMessage(null); setQuotedMessage(null);
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview)); selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
setSelectedImages([]); setSelectedImages([]);
// 通过WebSocket发送消息 // Send message via WebSocket
// 不在本地添加消息,等待后端广播回来(带有正确的ID // Do not add locally; wait for backend broadcast with correct ID
wsClientRef.current.sendMessage(messageChain, streamOutput); wsClientRef.current.sendMessage(messageChain, streamOutput);
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to send message:', error);
@@ -407,7 +418,7 @@ export default function DebugDialog({
case 'At': { case 'At': {
const atComponent = component as At; const atComponent = component as At;
// 优先使用 display,如果没有则使用 target // Prefer display name, fall back to target
const displayName = const displayName =
atComponent.display || atComponent.target?.toString() || ''; atComponent.display || atComponent.target?.toString() || '';
return ( return (
@@ -420,7 +431,10 @@ export default function DebugDialog({
case 'AtAll': case 'AtAll':
return ( return (
<span key={index} className="inline-flex align-middle mx-1"> <span key={index} className="inline-flex align-middle mx-1">
<AtBadge targetName="全体成员" readonly={true} /> <AtBadge
targetName={t('pipelines.debugDialog.allMembers')}
readonly={true}
/>
</span> </span>
); );
@@ -449,10 +463,10 @@ export default function DebugDialog({
const file = component as MessageChainComponent & { name?: string }; const file = component as MessageChainComponent & { name?: string };
return ( return (
<div key={index} className="my-2 flex items-center gap-2 text-sm"> <div key={index} className="my-2 flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <Paperclip className="size-4" />
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" /> <span>
</svg> [{t('pipelines.debugDialog.file')}] {file.name || 'Unknown'}
<span>[] {file.name || 'Unknown'}</span> </span>
</div> </div>
); );
} }
@@ -462,15 +476,13 @@ export default function DebugDialog({
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : ''); const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
if (!voiceUrl) { if (!voiceUrl) {
return <span key={index}>[]</span>; return <span key={index}>[{t('pipelines.debugDialog.voice')}]</span>;
} }
return ( return (
<div key={index} className="my-2 flex items-center gap-2"> <div key={index} className="my-2 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg"> <div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <Music className="size-5" />
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
<audio <audio
controls controls
src={voiceUrl} src={voiceUrl}
@@ -480,7 +492,7 @@ export default function DebugDialog({
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
{voice.length && voice.length > 0 && ( {voice.length && voice.length > 0 && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-muted-foreground">
{voice.length}s {voice.length}s
</span> </span>
)} )}
@@ -494,7 +506,7 @@ export default function DebugDialog({
return ( return (
<div <div
key={index} key={index}
className="mb-2 pl-3 border-l-2 border-gray-400 dark:border-gray-500" className="mb-2 pl-3 border-l-2 border-muted-foreground/50"
> >
<div className="text-sm opacity-75"> <div className="text-sm opacity-75">
{quote.origin?.map((comp, idx) => {quote.origin?.map((comp, idx) =>
@@ -506,7 +518,7 @@ export default function DebugDialog({
} }
case 'Source': case 'Source':
// Source 不显示 // Source is not rendered
return null; return null;
default: default:
@@ -515,7 +527,7 @@ export default function DebugDialog({
}; };
const getMessageTimestamp = (message: Message): number => { const getMessageTimestamp = (message: Message): number => {
// 首先尝试从message_chain中的Source组件获取时间戳 // Try to get timestamp from Source component in message_chain
const sourceComponent = message.message_chain.find( const sourceComponent = message.message_chain.find(
(c) => c.type === 'Source', (c) => c.type === 'Source',
) as Source | undefined; ) as Source | undefined;
@@ -524,8 +536,8 @@ export default function DebugDialog({
return sourceComponent.timestamp; return sourceComponent.timestamp;
} }
// 如果没有Source组件使用message.timestamp // Fall back to message.timestamp if no Source component
// 假设timestamp是ISO字符串转换为Unix时间戳 // Assume ISO string, convert to Unix timestamp (seconds)
if (message.timestamp) { if (message.timestamp) {
return Math.floor(new Date(message.timestamp).getTime() / 1000); return Math.floor(new Date(message.timestamp).getTime() / 1000);
} }
@@ -542,13 +554,13 @@ export default function DebugDialog({
const hours = date.getHours().toString().padStart(2, '0'); const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断是否是今天 // Check if today
const isToday = now.toDateString() === date.toDateString(); const isToday = now.toDateString() === date.toDateString();
if (isToday) { if (isToday) {
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
} }
// 判断是否是昨天 // Check if yesterday
const yesterday = new Date(now); const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = yesterday.toDateString() === date.toDateString(); const isYesterday = yesterday.toDateString() === date.toDateString();
@@ -556,7 +568,7 @@ export default function DebugDialog({
return `${t('bots.yesterday')} ${hours}:${minutes}`; return `${t('bots.yesterday')} ${hours}:${minutes}`;
} }
// 判断是否是今年 // Check if this year
const isThisYear = now.getFullYear() === date.getFullYear(); const isThisYear = now.getFullYear() === date.getFullYear();
if (isThisYear) { if (isThisYear) {
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
@@ -564,7 +576,7 @@ export default function DebugDialog({
return t('bots.dateFormat', { month, day }); return t('bots.dateFormat', { month, day });
} }
// 更早的日期 // Earlier dates
return t('bots.earlier'); return t('bots.earlier');
}; };
@@ -685,49 +697,37 @@ export default function DebugDialog({
const renderContent = () => ( const renderContent = () => (
<div className="flex flex-1 h-full min-h-0"> <div className="flex flex-1 h-full min-h-0">
<div className="w-14 bg-white dark:bg-black p-2 pl-0 flex-shrink-0 flex flex-col justify-start gap-2"> <div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`w-10 h-10 justify-center rounded-md transition-none ${ className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person' sessionType === 'person'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white' ? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700' : 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
} border-0 shadow-none`} )}
onClick={() => setSessionType('person')} onClick={() => setSessionType('person')}
> >
<svg <User className="size-5" />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
</svg>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`w-10 h-10 justify-center rounded-md transition-none ${ className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group' sessionType === 'group'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white' ? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700' : 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
} border-0 shadow-none`} )}
onClick={() => setSessionType('group')} onClick={() => setSessionType('group')}
> >
<svg <Users className="size-5" />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<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>
</Button> </Button>
</div> </div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0"> <div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area"> <ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
<div className="space-y-6"> <div className="space-y-6">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg"> <div className="text-center text-muted-foreground py-12 text-lg">
@@ -746,17 +746,15 @@ export default function DebugDialog({
className={cn( className={cn(
'max-w-3xl px-5 py-3 rounded-2xl', 'max-w-3xl px-5 py-3 rounded-2xl',
message.role === 'user' message.role === 'user'
? 'user-message-bubble bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none' ? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none', : 'bg-muted text-foreground rounded-bl-none',
)} )}
> >
{renderMessageContent(message)} {renderMessageContent(message)}
<div <div
className={cn( className={cn(
'text-xs mt-2 flex items-center justify-between gap-2', 'text-xs mt-2 flex items-center justify-between gap-2',
message.role === 'user' 'text-muted-foreground',
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -767,12 +765,11 @@ export default function DebugDialog({
</span> </span>
{hasPlainText(message) && ( {hasPlainText(message) && (
<button <button
type="button"
onClick={() => toggleRawMode(message)} onClick={() => toggleRawMode(message)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors', 'px-1.5 py-0.5 rounded text-[10px] transition-colors',
message.role === 'user' 'hover:bg-accent',
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)} )}
title={ title={
rawModeMessages.has(getMessageKey(message)) rawModeMessages.has(getMessageKey(message))
@@ -782,58 +779,27 @@ export default function DebugDialog({
> >
{rawModeMessages.has(getMessageKey(message)) ? ( {rawModeMessages.has(getMessageKey(message)) ? (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<svg <Code className="size-3" />
className="w-3 h-3"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z" />
</svg>
MD MD
</span> </span>
) : ( ) : (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<svg <AlignLeft className="size-3" />
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
{t('pipelines.debugDialog.showRaw')} {t('pipelines.debugDialog.showRaw')}
</span> </span>
)} )}
</button> </button>
)} )}
<button <button
type="button"
onClick={() => setQuotedMessage(message)} onClick={() => setQuotedMessage(message)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5', 'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
message.role === 'user' 'hover:bg-accent',
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)} )}
title={t('pipelines.debugDialog.reply')} title={t('pipelines.debugDialog.reply')}
> >
<svg <Reply className="size-3" />
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')} {t('pipelines.debugDialog.reply')}
</button> </button>
</div> </div>
@@ -849,18 +815,18 @@ export default function DebugDialog({
</div> </div>
</ScrollArea> </ScrollArea>
{/* 引用消息预览区域 */} {/* Quoted message preview */}
{quotedMessage && ( {quotedMessage && (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"> <div className="px-4 py-2 bg-muted/50 border-t">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex-1 pl-3 border-l-2 border-[#2288ee]"> <div className="flex-1 pl-3 border-l-2 border-primary">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1"> <div className="text-xs text-muted-foreground mb-1">
{t('pipelines.debugDialog.replyTo')}{' '} {t('pipelines.debugDialog.replyTo')}{' '}
{quotedMessage.role === 'user' {quotedMessage.role === 'user'
? t('pipelines.debugDialog.userMessage') ? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')} : t('pipelines.debugDialog.botMessage')}
</div> </div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2"> <div className="text-sm text-foreground/70 line-clamp-2">
{quotedMessage.message_chain {quotedMessage.message_chain
.filter((c) => c.type === 'Plain') .filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text) .map((c) => (c as Plain).text)
@@ -868,8 +834,9 @@ export default function DebugDialog({
</div> </div>
</div> </div>
<button <button
type="button"
onClick={() => setQuotedMessage(null)} onClick={() => setQuotedMessage(null)}
className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="w-5 h-5 text-muted-foreground hover:text-foreground"
> >
× ×
</button> </button>
@@ -877,20 +844,21 @@ export default function DebugDialog({
</div> </div>
)} )}
{/* 图片预览区域 */} {/* Image preview area */}
{selectedImages.length > 0 && ( {selectedImages.length > 0 && (
<div className="px-4 pb-2 bg-white dark:bg-black"> <div className="px-4 pb-2">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{selectedImages.map((image, index) => ( {selectedImages.map((image, index) => (
<div key={index} className="relative group"> <div key={index} className="relative group">
<img <img
src={image.preview} src={image.preview}
alt={`preview-${index}`} alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600" className="w-20 h-20 object-cover rounded-lg border"
/> />
<button <button
type="button"
onClick={() => handleRemoveImage(index)} onClick={() => handleRemoveImage(index)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute -top-2 -right-2 w-5 h-5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
× ×
</button> </button>
@@ -900,17 +868,16 @@ export default function DebugDialog({
</div> </div>
)} )}
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2"> <div className="p-4 pb-0 flex gap-2">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-muted-foreground">
{t('pipelines.debugDialog.streamOutput')} {t('pipelines.debugDialog.streamOutput')}
</span> </span>
<Switch <Switch
checked={streamOutput} checked={streamOutput}
onCheckedChange={setStreamOutput} onCheckedChange={setStreamOutput}
disabled={!isConnected} disabled={!isConnected}
className="data-[state=checked]:bg-[#2288ee]"
/> />
</div> </div>
<input <input
@@ -926,22 +893,10 @@ export default function DebugDialog({
size="icon" size="icon"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={!isConnected || isUploading} disabled={!isConnected || isUploading}
className="w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700" className="w-10 h-10 rounded-md hover:bg-accent"
title="上传图片" title={t('pipelines.debugDialog.uploadImage')}
> >
<svg <ImageIcon className="size-5" />
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</Button> </Button>
</div> </div>
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
@@ -961,25 +916,23 @@ export default function DebugDialog({
: t('pipelines.debugDialog.groupChat'), : t('pipelines.debugDialog.groupChat'),
})} })}
disabled={!isConnected || isUploading} disabled={!isConnected || isUploading}
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50" className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
/> />
{showAtPopover && ( {showAtPopover && (
<div <div
ref={popoverRef} ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white dark:bg-gray-800 dark:border-gray-600 shadow-lg" className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
> >
<div <div
className={cn( className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer', 'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering isHovering ? 'bg-accent' : '',
? 'bg-gray-100 dark:bg-gray-700'
: 'bg-white dark:bg-gray-800',
)} )}
onClick={handleAtSelect} onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)} onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
> >
<span className="text-gray-800 dark:text-gray-200"> <span>
@websocketbot - {t('pipelines.debugDialog.atTips')} @websocketbot - {t('pipelines.debugDialog.atTips')}
</span> </span>
</div> </div>
@@ -997,16 +950,23 @@ export default function DebugDialog({
!isConnected || !isConnected ||
isUploading isUploading
} }
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50" className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
> >
{isUploading ? '上传中...' : t('pipelines.debugDialog.send')} {isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
</>
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
// 如果是嵌入模式,直接返回内容 // Embedded mode: return content directly
if (isEmbedded) { if (isEmbedded) {
return ( return (
<> <>
@@ -1022,10 +982,10 @@ export default function DebugDialog({
); );
} }
// 原有的Dialog包装 // Dialog wrapper mode
return ( return (
<> <>
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black"> <DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl">
{renderContent()} {renderContent()}
</DialogContent> </DialogContent>
<ImagePreviewDialog <ImagePreviewDialog

View File

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

View File

@@ -5,7 +5,6 @@ import {
PipelineConfigTab, PipelineConfigTab,
PipelineConfigStage, PipelineConfigStage,
} from '@/app/infra/entities/pipeline'; } from '@/app/infra/entities/pipeline';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent'; import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -32,6 +31,25 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Info,
Brain,
Zap,
Shield,
FileOutput,
Puzzle,
Trash2,
Copy,
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({ export default function PipelineFormComponent({
onFinish, onFinish,
@@ -41,6 +59,7 @@ export default function PipelineFormComponent({
showButtons = true, showButtons = true,
onDeletePipeline, onDeletePipeline,
onCancel, onCancel,
onDirtyChange,
}: { }: {
pipelineId?: string; pipelineId?: string;
isEditMode: boolean; isEditMode: boolean;
@@ -49,7 +68,8 @@ export default function PipelineFormComponent({
onFinish: () => void; onFinish: () => void;
onNewPipelineCreated: (pipelineId: string) => void; onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void; onDeletePipeline: () => void;
onCancel: () => void; onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -60,9 +80,7 @@ export default function PipelineFormComponent({
? z.object({ ? z.object({
basic: z.object({ basic: z.object({
name: z.string().min(1, { message: t('pipelines.nameRequired') }), name: z.string().min(1, { message: t('pipelines.nameRequired') }),
description: z description: z.string().optional(),
.string()
.min(1, { message: t('pipelines.descriptionRequired') }),
emoji: z.string().optional(), emoji: z.string().optional(),
}), }),
ai: z.record(z.string(), z.any()), ai: z.record(z.string(), z.any()),
@@ -73,9 +91,7 @@ export default function PipelineFormComponent({
: z.object({ : z.object({
basic: z.object({ basic: z.object({
name: z.string().min(1, { message: t('pipelines.nameRequired') }), name: z.string().min(1, { message: t('pipelines.nameRequired') }),
description: z description: z.string().optional(),
.string()
.min(1, { message: t('pipelines.descriptionRequired') }),
emoji: z.string().optional(), emoji: z.string().optional(),
}), }),
ai: z.record(z.string(), z.any()).optional(), ai: z.record(z.string(), z.any()).optional(),
@@ -85,16 +101,58 @@ export default function PipelineFormComponent({
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
// 这里不好可以改成enum等 // Section navigation items with icons
const formLabelList: FormLabel[] = isEditMode const SECTION_ICONS: Record<string, React.ElementType> = {
basic: Info,
ai: Brain,
trigger: Zap,
safety: Shield,
output: FileOutput,
extensions: Puzzle,
};
const formLabelList: SectionItem[] = isEditMode
? [ ? [
{ label: t('pipelines.basicInfo'), name: 'basic' }, {
{ label: t('pipelines.aiCapabilities'), name: 'ai' }, label: t('pipelines.basicInfo'),
{ label: t('pipelines.triggerConditions'), name: 'trigger' }, name: 'basic',
{ label: t('pipelines.safetyControls'), name: 'safety' }, icon: SECTION_ICONS.basic,
{ label: t('pipelines.outputProcessing'), name: 'output' }, },
{
label: t('pipelines.aiCapabilities'),
name: 'ai',
icon: SECTION_ICONS.ai,
},
{
label: t('pipelines.triggerConditions'),
name: 'trigger',
icon: SECTION_ICONS.trigger,
},
{
label: t('pipelines.safetyControls'),
name: 'safety',
icon: SECTION_ICONS.safety,
},
{
label: t('pipelines.outputProcessing'),
name: 'output',
icon: SECTION_ICONS.output,
},
{
label: t('pipelines.extensions.title'),
name: 'extensions',
icon: SECTION_ICONS.extensions,
},
] ]
: [{ label: t('pipelines.basicInfo'), name: 'basic' }]; : [
{
label: t('pipelines.basicInfo'),
name: 'basic',
icon: SECTION_ICONS.basic,
},
];
const [activeSection, setActiveSection] = useState(formLabelList[0].name);
const [aiConfigTabSchema, setAIConfigTabSchema] = const [aiConfigTabSchema, setAIConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
@@ -128,6 +186,11 @@ export default function PipelineFormComponent({
return JSON.stringify(watchedValues) !== savedSnapshotRef.current; return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]); }, [isEditMode, watchedValues]);
// Notify parent when dirty state changes
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => { useEffect(() => {
// get config schema from metadata // get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => { httpClient.getGeneralPipelineMetadata().then((resp) => {
@@ -190,7 +253,7 @@ export default function PipelineFormComponent({
function handleCreate(values: FormValues) { function handleCreate(values: FormValues) {
const pipeline: Pipeline = { const pipeline: Pipeline = {
config: {}, config: {},
description: values.basic.description, description: values.basic.description ?? '',
name: values.basic.name, name: values.basic.name,
emoji: values.basic.emoji, emoji: values.basic.emoji,
}; };
@@ -217,7 +280,7 @@ export default function PipelineFormComponent({
const pipeline: Pipeline = { const pipeline: Pipeline = {
config: realConfig, config: realConfig,
// created_at: '', // created_at: '',
description: values.basic.description, description: values.basic.description ?? '',
// for_version: '', // for_version: '',
name: values.basic.name, name: values.basic.name,
emoji: values.basic.emoji, emoji: values.basic.emoji,
@@ -269,92 +332,98 @@ export default function PipelineFormComponent({
stage: PipelineConfigStage, stage: PipelineConfigStage,
formName: keyof FormValues, formName: keyof FormValues,
) { ) {
// 如果是 AI 配置,需要特殊处理 // Special handling for AI config section
if (formName === 'ai') { if (formName === 'ai') {
// 获取当前选择的 runner // Get the currently selected runner
const currentRunner = form.watch('ai.runner.runner'); const currentRunner = form.watch('ai.runner.runner');
// 如果是 runner 配置项,直接渲染 // If this is the runner selector stage, render it directly
if (stage.name === 'runner') { if (stage.name === 'runner') {
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-gray-500"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<DynamicFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <DynamicFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || // eslint-disable-next-line @typescript-eslint/no-explicit-any
{} (form.watch(formName) as Record<string, any>)?.[stage.name] ||
} {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
// 如果不是当前选择的 runner 对应的配置项,则不渲染 // Do not render if not the currently selected runner
if (stage.name !== currentRunner) { if (stage.name !== currentRunner) {
return null; return null;
} }
// 对于n8n-service-api配置,使用N8nAuthFormComponent处理表单联动 // For n8n-service-api config, use N8nAuthFormComponent for form linkage
if (stage.name === 'n8n-service-api') { if (stage.name === 'n8n-service-api') {
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-gray-500"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<N8nAuthFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <N8nAuthFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || // eslint-disable-next-line @typescript-eslint/no-explicit-any
{} (form.watch(formName) as Record<string, any>)?.[stage.name] ||
} {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
} }
return ( return (
<div key={stage.name} className="space-y-4 mb-6"> <Card key={stage.name}>
<div className="text-lg font-medium"> <CardHeader>
{extractI18nObject(stage.label)} <CardTitle>{extractI18nObject(stage.label)}</CardTitle>
</div> {stage.description && (
{stage.description && ( <CardDescription>
<div className="text-sm text-gray-500"> {extractI18nObject(stage.description)}
{extractI18nObject(stage.description)} </CardDescription>
</div> )}
)} </CardHeader>
<DynamicFormComponent <CardContent className="space-y-6">
itemConfigList={stage.config} <DynamicFormComponent
initialValues={ itemConfigList={stage.config}
// eslint-disable-next-line @typescript-eslint/no-explicit-any initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] || {} // eslint-disable-next-line @typescript-eslint/no-explicit-any
} (form.watch(formName) as Record<string, any>)?.[stage.name] || {}
onSubmit={(values) => { }
handleDynamicFormEmit(formName, stage.name, values); onSubmit={(values) => {
}} handleDynamicFormEmit(formName, stage.name, values);
/> }}
</div> />
</CardContent>
</Card>
); );
} }
@@ -389,7 +458,7 @@ export default function PipelineFormComponent({
onFinish(); onFinish();
toast.success(t('common.copySuccess')); toast.success(t('common.copySuccess'));
setShowCopyConfirm(false); setShowCopyConfirm(false);
onCancel(); onCancel?.();
}) })
.catch((err) => { .catch((err) => {
toast.error(t('pipelines.createError') + err.msg); toast.error(t('pipelines.createError') + err.msg);
@@ -399,82 +468,66 @@ export default function PipelineFormComponent({
return ( return (
<> <>
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black"> <div className="h-full p-0 flex flex-col">
<Form {...form}> <Form {...form}>
<form <form
id="pipeline-form" id="pipeline-form"
onSubmit={form.handleSubmit(handleFormSubmit)} onSubmit={form.handleSubmit(handleFormSubmit)}
className="h-full flex flex-col flex-1 min-h-0 mb-2" className="h-full flex flex-col flex-1 min-h-0 mb-2"
> >
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col md:flex-row min-h-0">
<Tabs {/* Vertical section navigation (only show when multiple sections) */}
defaultValue={formLabelList[0].name} {formLabelList.length > 1 && (
className="h-full flex flex-col flex-1 min-h-0" <nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
> <ul className="flex md:flex-col gap-1 md:space-y-1">
<TabsList> {formLabelList.map((section) => {
{formLabelList.map((formLabel) => ( const Icon = section.icon;
<TabsTrigger key={formLabel.name} value={formLabel.name}> return (
{formLabel.label} <li key={section.name}>
</TabsTrigger> <button
))} type="button"
</TabsList> onClick={() => setActiveSection(section.name)}
className={cn(
<div 'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
id="pipeline-form-content" activeSection === section.name
className="flex-1 overflow-y-auto min-h-0" ? 'bg-accent text-accent-foreground'
> : 'text-muted-foreground hover:bg-muted hover:text-foreground',
{formLabelList.map((formLabel) => ( )}
<TabsContent >
key={formLabel.name} <Icon className="size-4 shrink-0" />
value={formLabel.name} {section.label}
className="overflow-y-auto max-h-full" </button>
> </li>
{formLabel.name === 'basic' && ( );
<div className="space-y-6"> })}
{/* Name and Emoji in same row */} </ul>
<div className="flex gap-4 items-start"> </nav>
<FormField )}
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Content panel */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* Basic info section */}
{activeSection === 'basic' && (
<div className="space-y-6">
{/* Basic Information Card */}
<Card>
<CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
<CardDescription>
{t('pipelines.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField <FormField
control={form.control} control={form.control}
name="basic.description" name="basic.name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex-1">
<FormLabel> <FormLabel>
{t('common.description')} {t('common.name')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -483,56 +536,149 @@ export default function PipelineFormComponent({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
)}
{isEditMode && ( <FormField
<> control={form.control}
{formLabel.name === 'ai' && aiConfigTabSchema && ( name="basic.description"
<div className="space-y-6"> render={({ field }) => (
{aiConfigTabSchema.stages.map((stage) => <FormItem>
renderDynamicForms(stage, 'ai'), <FormLabel>{t('common.description')}</FormLabel>
)} <FormControl>
</div> <Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)} )}
/>
{formLabel.name === 'trigger' && {/* Copy pipeline (edit mode only) */}
triggerConfigTabSchema && ( {isEditMode && (
<div className="space-y-6"> <div className="flex items-center justify-between rounded-lg border p-4">
{triggerConfigTabSchema.stages.map((stage) => <div className="space-y-0.5">
renderDynamicForms(stage, 'trigger'), <p className="text-sm font-medium">
)} {t('pipelines.copyPipelineAction')}
</div> </p>
)} <p className="text-sm text-muted-foreground">
{t('pipelines.copyPipelineHint')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
>
<Copy className="size-4 mr-1.5" />
{t('common.copy')}
</Button>
</div>
)}
</CardContent>
</Card>
{formLabel.name === 'safety' && {/* Danger Zone (edit mode only) */}
safetyConfigTabSchema && ( {isEditMode && (
<div className="space-y-6"> <Card className="border-destructive/50">
{safetyConfigTabSchema.stages.map((stage) => <CardHeader>
renderDynamicForms(stage, 'safety'), <CardTitle className="text-destructive">
)} {t('pipelines.dangerZone')}
</div> </CardTitle>
)} <CardDescription>
{t('pipelines.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('pipelines.deletePipelineAction')}
</p>
<p className="text-sm text-muted-foreground">
{isDefaultPipeline
? t('pipelines.defaultPipelineCannotDelete')
: t('pipelines.deletePipelineHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultPipeline}
onClick={handleDelete}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)}
{formLabel.name === 'output' && {/* Dynamic config sections (edit mode only) */}
outputConfigTabSchema && ( {isEditMode && (
<div className="space-y-6"> <>
{outputConfigTabSchema.stages.map((stage) => {activeSection === 'ai' && aiConfigTabSchema && (
renderDynamicForms(stage, 'output'), <div className="space-y-6">
)} {aiConfigTabSchema.stages.map((stage) =>
</div> renderDynamicForms(stage, 'ai'),
)} )}
</> </div>
)} )}
</TabsContent>
))} {activeSection === 'trigger' && triggerConfigTabSchema && (
</div> <div className="space-y-6">
</Tabs> {triggerConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'trigger'),
)}
</div>
)}
{activeSection === 'safety' && safetyConfigTabSchema && (
<div className="space-y-6">
{safetyConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'safety'),
)}
</div>
)}
{activeSection === 'output' && outputConfigTabSchema && (
<div className="space-y-6">
{outputConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'output'),
)}
</div>
)}
{activeSection === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
</>
)}
</div>
</div> </div>
</form> </form>
{/* 按钮栏移到 Tabs 外部,始终固定底部 */} {/* Button bar pinned to bottom */}
{showButtons && ( {showButtons && (
<div className="flex justify-end items-center gap-2 pt-4 border-t mb-0 bg-white dark:bg-black sticky bottom-0 z-10"> <div className="flex justify-end items-center gap-2 pt-4 border-t mb-0 sticky bottom-0 z-10">
{isEditMode && hasUnsavedChanges && ( {isEditMode && hasUnsavedChanges && (
<div className="text-amber-600 dark:text-amber-400 text-sm flex items-center gap-1.5 mr-auto"> <div className="text-amber-600 dark:text-amber-400 text-sm flex items-center gap-1.5 mr-auto">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500" /> <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500" />
@@ -551,7 +697,7 @@ export default function PipelineFormComponent({
)} )}
{isEditMode && isDefaultPipeline && ( {isEditMode && isDefaultPipeline && (
<div className="text-gray-500 text-sm h-full flex items-center mr-2"> <div className="text-muted-foreground text-sm h-full flex items-center mr-2">
{t('pipelines.defaultPipelineCannotDelete')} {t('pipelines.defaultPipelineCannotDelete')}
</div> </div>
)} )}
@@ -570,15 +716,12 @@ export default function PipelineFormComponent({
<Button type="submit" form="pipeline-form"> <Button type="submit" form="pipeline-form">
{isEditMode ? t('common.save') : t('common.submit')} {isEditMode ? t('common.save') : t('common.submit')}
</Button> </Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t('common.cancel')}
</Button>
</div> </div>
)} )}
</Form> </Form>
</div> </div>
{/* 删除确认对话框 */} {/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -599,7 +742,7 @@ export default function PipelineFormComponent({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 复制确认对话框 */} {/* Copy confirmation dialog */}
<Dialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}> <Dialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -617,7 +760,8 @@ export default function PipelineFormComponent({
</> </>
); );
} }
interface FormLabel { interface SectionItem {
label: string; label: string;
name: string; name: string;
icon: React.ElementType;
} }

View File

@@ -1,12 +0,0 @@
.formItemSubtitle {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.changeFormButtonGroupContainer {
width: 320px;
display: flex;
flex-direction: row;
justify-content: space-between;
}

View File

@@ -1,195 +1,21 @@
'use client'; 'use client';
import { useState, useEffect } from 'react';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent'; import { useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard';
import styles from './pipelineConfig.module.css';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PipelineDialog from './PipelineDetailDialog'; import PipelineDetailContent from './PipelineDetailContent';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { systemInfo } from '@/app/infra/http';
export default function PluginConfigPage() { export default function PipelineConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const searchParams = useSearchParams();
const [isEditForm, setIsEditForm] = useState(false); const detailId = searchParams.get('id');
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState('');
const [sortByValue, setSortByValue] = useState<string>('created_at');
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
useEffect(() => { if (detailId) {
// Load sort preference from localStorage return <PipelineDetailContent id={detailId} />;
const savedSortBy = localStorage.getItem('pipeline_sort_by');
const savedSortOrder = localStorage.getItem('pipeline_sort_order');
if (savedSortBy && savedSortOrder) {
setSortByValue(savedSortBy);
setSortOrderValue(savedSortOrder);
getPipelines(savedSortBy, savedSortOrder);
} else {
getPipelines();
}
}, []);
function getPipelines(
sortBy: string = sortByValue,
sortOrder: string = sortOrderValue,
) {
httpClient
.getPipelines(sortBy, sortOrder)
.then((value) => {
const currentTime = new Date();
const pipelineList = value.pipelines.map((pipeline) => {
const lastUpdatedTimeAgo = Math.floor(
(currentTime.getTime() -
new Date(
pipeline.updated_at ?? currentTime.getTime(),
).getTime()) /
1000 /
60 /
60 /
24,
);
const lastUpdatedTimeAgoText =
lastUpdatedTimeAgo > 0
? ` ${lastUpdatedTimeAgo} ${t('pipelines.daysAgo')}`
: t('pipelines.today');
return new PipelineCardVO({
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
description: pipeline.description,
id: pipeline.uuid ?? '',
name: pipeline.name,
emoji: pipeline.emoji,
isDefault: pipeline.is_default ?? false,
});
});
setPipelineList(pipelineList);
})
.catch((error) => {
toast.error(t('pipelines.getPipelineListError') + error.message);
});
}
const handlePipelineClick = (pipelineId: string) => {
setSelectedPipelineId(pipelineId);
setIsEditForm(true);
setDialogOpen(true);
};
const handleCreateNew = () => {
const maxPipelines = systemInfo.limitation?.max_pipelines ?? -1;
if (maxPipelines >= 0 && pipelineList.length >= maxPipelines) {
toast.error(t('limitation.maxPipelinesReached', { max: maxPipelines }));
return;
}
setIsEditForm(false);
setSelectedPipelineId('');
setDialogOpen(true);
};
function handleSortChange(value: string) {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy);
setSortOrderValue(newSortOrder);
// Save sort preference to localStorage
localStorage.setItem('pipeline_sort_by', newSortBy);
localStorage.setItem('pipeline_sort_order', newSortOrder);
getPipelines(newSortBy, newSortOrder);
} }
return ( return (
<div className={styles.configPageContainer}> <div className="flex h-full items-center justify-center text-muted-foreground">
<PipelineDialog <p>{t('pipelines.selectFromSidebar')}</p>
open={dialogOpen}
onOpenChange={setDialogOpen}
pipelineId={selectedPipelineId || undefined}
isEditMode={isEditForm}
onFinish={() => {
getPipelines();
}}
onNewPipelineCreated={(pipelineId) => {
getPipelines();
setSelectedPipelineId(pipelineId);
setIsEditForm(true);
setDialogOpen(true);
}}
onDeletePipeline={() => {
getPipelines();
setDialogOpen(false);
}}
onCancel={() => {
setDialogOpen(false);
}}
/>
<div className="flex flex-row justify-between items-center mb-4 px-[0.8rem]">
<Select
value={`${sortByValue},${sortOrderValue}`}
onValueChange={handleSortChange}
>
<SelectTrigger className="w-[180px] cursor-pointer bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('pipelines.sortBy')} />
</SelectTrigger>
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectItem
value="created_at,DESC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.newestCreated')}
</SelectItem>
<SelectItem
value="created_at,ASC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.earliestCreated')}
</SelectItem>
<SelectItem
value="updated_at,DESC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.recentlyEdited')}
</SelectItem>
<SelectItem
value="updated_at,ASC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.earliestEdited')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className={styles.pipelineListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateNew}
/>
{pipelineList.map((pipeline) => {
return (
<div
key={pipeline.id}
onClick={() => handlePipelineClick(pipeline.id)}
>
<PipelineCard cardVO={pipeline} />
</div>
);
})}
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,96 @@
'use client';
import { useEffect } from 'react';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Bug } from 'lucide-react';
/**
* Plugin detail page content.
* The `id` prop is the composite key "author/name".
*/
export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
// Parse "author/name" composite key
const slashIndex = id.indexOf('/');
const pluginAuthor = slashIndex >= 0 ? id.substring(0, slashIndex) : '';
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
const plugin = plugins.find((p) => p.id === id);
// Set breadcrumb entity name
useEffect(() => {
setDetailEntityName(plugin?.name ?? `${pluginAuthor}/${pluginName}`);
return () => setDetailEntityName(null);
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
function handleFormSubmit(timeout?: number) {
if (timeout) {
setTimeout(() => {
refreshPlugins();
}, timeout);
} else {
refreshPlugins();
}
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{pluginAuthor}/{pluginName}
</h1>
{plugin?.debug ? (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
>
<Bug className="size-3.5" />
{t('plugins.debugging')}
</Badge>
) : plugin?.installSource === 'github' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
>
{t('plugins.fromGithub')}
</Badge>
) : plugin?.installSource === 'local' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
) : plugin?.installSource === 'marketplace' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
>
{t('plugins.fromMarketplace')}
</Badge>
) : null}
</div>
<div className="flex flex-1 flex-col md:flex-row overflow-hidden min-h-0 gap-6 max-w-full">
{/* Left side - Config */}
<div className="md:w-[380px] md:flex-shrink-0 overflow-y-auto overflow-x-hidden">
<PluginForm
pluginAuthor={pluginAuthor}
pluginName={pluginName}
onFormSubmit={handleFormSubmit}
/>
</div>
{/* Right side - Readme */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
'use client'; 'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useRouter } from 'next/navigation';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO'; import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent'; import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import styles from '@/app/home/plugins/plugins.module.css'; import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http'; import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -23,6 +22,7 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface PluginInstalledComponentRef { export interface PluginInstalledComponentRef {
refreshPluginList: () => void; refreshPluginList: () => void;
@@ -37,11 +37,9 @@ enum PluginOperationType {
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>( const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => { (props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]); const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [showOperationModal, setShowOperationModal] = useState(false); const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>( const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE, PluginOperationType.DELETE,
@@ -58,6 +56,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
toast.success(successMessage); toast.success(successMessage);
setShowOperationModal(false); setShowOperationModal(false);
getPluginList(); getPluginList();
refreshPlugins();
}, },
onError: () => { onError: () => {
// Error is already handled in the hook state // Error is already handled in the hook state
@@ -163,8 +162,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
})); }));
function handlePluginClick(plugin: PluginCardVO) { function handlePluginClick(plugin: PluginCardVO) {
setSelectedPlugin(plugin); const pluginId = `${plugin.author}/${plugin.name}`;
setDetailModalOpen(true); router.push(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
} }
function handlePluginDelete(plugin: PluginCardVO) { function handlePluginDelete(plugin: PluginCardVO) {
@@ -348,50 +347,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</div> </div>
) : ( ) : (
<div className={`${styles.pluginListContainer}`}> <div className={`${styles.pluginListContainer}`}>
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent className="sm:max-w-[1100px] max-w-[95vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2 border-b">
<DialogTitle>
{selectedPlugin &&
`${selectedPlugin.author}/${selectedPlugin.name}`}
</DialogTitle>
</DialogHeader>
<div className="flex-1 flex flex-row overflow-hidden min-h-0">
{/* Left side - Readme */}
<div className="flex-1 overflow-y-auto border-r min-w-0">
{selectedPlugin && (
<PluginReadme
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
/>
)}
</div>
{/* Right side - Config */}
<div className="w-[380px] flex-shrink-0 overflow-y-auto px-4">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setDetailModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setDetailModalOpen(false);
}}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
{pluginList.map((vo, index) => { {pluginList.map((vo, index) => {
return ( return (
<div key={index}> <div key={index}>

View File

@@ -31,7 +31,7 @@ export default function PluginCardComponent({
return ( return (
<> <>
<div <div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]" className="w-[100%] h-[10rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
onClick={() => onCardClick()} onClick={() => onCardClick()}
> >
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]"> <div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">

View File

@@ -13,12 +13,10 @@ export default function PluginForm({
pluginAuthor, pluginAuthor,
pluginName, pluginName,
onFormSubmit, onFormSubmit,
onFormCancel,
}: { }: {
pluginAuthor: string; pluginAuthor: string;
pluginName: string; pluginName: string;
onFormSubmit: (timeout?: number) => void; onFormSubmit: (timeout?: number) => void;
onFormCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [pluginInfo, setPluginInfo] = useState<Plugin>(); const [pluginInfo, setPluginInfo] = useState<Plugin>();
@@ -195,20 +193,19 @@ export default function PluginForm({
)} )}
</div> </div>
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4"> {pluginInfo.manifest.manifest.spec.config.length > 0 && (
<div className="flex justify-end gap-2"> <div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
<Button <div className="flex justify-end gap-2">
type="submit" <Button
onClick={() => handleSubmit()} type="submit"
disabled={isSaving} onClick={() => handleSubmit()}
> disabled={isSaving}
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')} >
</Button> {isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
<Button type="button" variant="outline" onClick={onFormCancel}> </Button>
{t('plugins.cancel')} </div>
</Button>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

@@ -152,7 +152,15 @@ function MarketPageContent({
); );
const data: ApiRespMarketplacePlugins = response; const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins.map(transformToVO); const newPlugins = data.plugins
.filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
})
.map(transformToVO);
const total = data.total; const total = data.total;
if (reset || page === 1) { if (reset || page === 1) {
@@ -434,68 +442,70 @@ function MarketPageContent({
{/* Component filter and sort */} {/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */} {/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2"> <div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap"> <span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}: {t('market.filterByComponent')}:
</span> </span>
<ToggleGroup <div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
type="single" <ToggleGroup
spacing={2} type="single"
size="sm" spacing={2}
value={componentFilter} size="sm"
onValueChange={(value) => { value={componentFilter}
if (value) handleComponentFilterChange(value); onValueChange={(value) => {
}} if (value) handleComponentFilterChange(value);
className="justify-start" }}
> className="justify-start flex-nowrap"
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm cursor-pointer"
> >
{t('market.allComponents')} <ToggleGroupItem
</ToggleGroupItem> value="all"
<ToggleGroupItem aria-label="All components"
value="Tool" className="text-xs sm:text-sm cursor-pointer"
aria-label="Tool" >
className="text-xs sm:text-sm cursor-pointer" {t('market.allComponents')}
> </ToggleGroupItem>
<Wrench className="h-4 w-4 mr-1" /> <ToggleGroupItem
{t('plugins.componentName.Tool')} value="Tool"
</ToggleGroupItem> aria-label="Tool"
<ToggleGroupItem className="text-xs sm:text-sm cursor-pointer"
value="Command" >
aria-label="Command" <Wrench className="h-4 w-4 mr-1" />
className="text-xs sm:text-sm cursor-pointer" {t('plugins.componentName.Tool')}
> </ToggleGroupItem>
<Hash className="h-4 w-4 mr-1" /> <ToggleGroupItem
{t('plugins.componentName.Command')} value="Command"
</ToggleGroupItem> aria-label="Command"
<ToggleGroupItem className="text-xs sm:text-sm cursor-pointer"
value="EventListener" >
aria-label="EventListener" <Hash className="h-4 w-4 mr-1" />
className="text-xs sm:text-sm cursor-pointer" {t('plugins.componentName.Command')}
> </ToggleGroupItem>
<AudioWaveform className="h-4 w-4 mr-1" /> <ToggleGroupItem
{t('plugins.componentName.EventListener')} value="EventListener"
</ToggleGroupItem> aria-label="EventListener"
<ToggleGroupItem className="text-xs sm:text-sm cursor-pointer"
value="KnowledgeEngine" >
aria-label="KnowledgeEngine" <AudioWaveform className="h-4 w-4 mr-1" />
className="text-xs sm:text-sm cursor-pointer" {t('plugins.componentName.EventListener')}
> </ToggleGroupItem>
<Book className="h-4 w-4 mr-1" /> <ToggleGroupItem
{t('plugins.componentName.KnowledgeEngine')} value="KnowledgeEngine"
</ToggleGroupItem> aria-label="KnowledgeEngine"
<ToggleGroupItem className="text-xs sm:text-sm cursor-pointer"
value="Parser" >
aria-label="Parser" <Book className="h-4 w-4 mr-1" />
className="text-xs sm:text-sm cursor-pointer" {t('plugins.componentName.KnowledgeEngine')}
> </ToggleGroupItem>
<FileText className="h-4 w-4 mr-1" /> <ToggleGroupItem
{t('plugins.componentName.Parser')} value="Parser"
</ToggleGroupItem> aria-label="Parser"
</ToggleGroup> className="text-xs sm:text-sm cursor-pointer"
>
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div> </div>
{/* Sort dropdown */} {/* Sort dropdown */}

View File

@@ -59,7 +59,11 @@ function RecommendationListRow({
const [perPage, setPerPage] = useState(4); const [perPage, setPerPage] = useState(4);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const plugins = list.plugins || []; const plugins = (list.plugins || []).filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
});
// Measure how many columns the CSS grid actually renders // Measure how many columns the CSS grid actually renders
const measureCols = useCallback(() => { const measureCols = useCallback(() => {

View File

@@ -1,12 +1,6 @@
import { PluginMarketCardVO } from './PluginMarketCardVO'; import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
Wrench, Wrench,
AudioWaveform, AudioWaveform,
@@ -15,7 +9,6 @@ import {
ExternalLink, ExternalLink,
Book, Book,
FileText, FileText,
Info,
} from 'lucide-react'; } from 'lucide-react';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -90,16 +83,9 @@ export default function PluginMarketCardComponent({
Parser: <FileText className="w-4 h-4" />, Parser: <FileText className="w-4 h-4" />,
}; };
// Plugins that only contain KnowledgeRetriever components are deprecated
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
return ( return (
<div <div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative" className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
@@ -121,30 +107,6 @@ export default function PluginMarketCardComponent({
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate"> <div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label} {cardVO.label}
</div> </div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e) => e.preventDefault()}
>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[240px] text-xs"
>
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
</div> </div>

View File

@@ -84,7 +84,7 @@ export default function MCPCardComponent({
return ( return (
<div <div
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none" className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
onClick={onCardClick} onClick={onCardClick}
> >
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]"> <div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">

View File

@@ -2,19 +2,9 @@
import PluginInstalledComponent, { import PluginInstalledComponent, {
PluginInstalledComponentRef, PluginInstalledComponentRef,
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent'; } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent'; import PluginDetailContent from './PluginDetailContent';
import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';
import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog';
import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog';
import styles from './plugins.module.css'; import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { import {
PlusIcon, PlusIcon,
ChevronDownIcon, ChevronDownIcon,
@@ -47,14 +37,21 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { systemInfo } from '@/app/infra/http/HttpClient'; import { systemInfo } from '@/app/infra/http/HttpClient';
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
enum PluginInstallStatus { enum PluginInstallStatus {
WAIT_INPUT = 'wait_input', WAIT_INPUT = 'wait_input',
@@ -83,12 +80,28 @@ interface GithubAsset {
} }
export default function PluginConfigPage() { export default function PluginConfigPage() {
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
// Show plugin detail view when ?id= query param is present
if (detailId) {
return <PluginDetailContent id={detailId} />;
}
return <PluginListView />;
}
function PluginListView() {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('installed'); const router = useRouter();
const {
refreshPlugins,
pendingPluginInstallAction,
setPendingPluginInstallAction,
} = useSidebarData();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local'); const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);
const [pluginInstallStatus, setPluginInstallStatus] = const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT); useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
@@ -108,12 +121,6 @@ export default function PluginConfigPage() {
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [editingServerName, setEditingServerName] = useState<string | null>(
null,
);
const [isEditMode, setIsEditMode] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [debugInfo, setDebugInfo] = useState<{ const [debugInfo, setDebugInfo] = useState<{
debug_url: string; debug_url: string;
plugin_debug_key: string; plugin_debug_key: string;
@@ -166,6 +173,7 @@ export default function PluginConfigPage() {
resetGithubState(); resetGithubState();
setModalOpen(false); setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList(); pluginInstalledRef.current?.refreshPluginList();
refreshPlugins();
} }
} }
}); });
@@ -318,17 +326,6 @@ export default function PluginConfigPage() {
setInstallError(err.msg); setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR); setPluginInstallStatus(PluginInstallStatus.ERROR);
}); });
} else if (installSource === 'marketplace') {
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
});
} }
} }
@@ -415,6 +412,28 @@ export default function PluginConfigPage() {
[uploadPluginFile, isPluginSystemReady, t], [uploadPluginFile, isPluginSystemReady, t],
); );
// Auto-trigger install action from sidebar via shared context
useEffect(() => {
if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady)
return;
// Consume the action immediately
const action = pendingPluginInstallAction;
setPendingPluginInstallAction(null);
if (action === 'local') {
// Small delay to ensure file input ref is ready
setTimeout(() => fileInputRef.current?.click(), 100);
} else if (action === 'github') {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
const handleShowDebugInfo = async () => { const handleShowDebugInfo = async () => {
try { try {
const info = await httpClient.getPluginDebugInfo(); const info = await httpClient.getPluginDebugInfo();
@@ -526,218 +545,144 @@ export default function PluginConfigPage() {
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<Tabs
value={activeTab} {/* Header bar with debug info and install button */}
onValueChange={setActiveTab} <div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
className="w-full h-full flex flex-col" <Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
> <PopoverTrigger asChild>
<div className="flex flex-row justify-between items-center px-[0.8rem] flex-shrink-0"> <Button
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]"> variant="outline"
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer"> className="px-4 py-5 cursor-pointer"
{t('plugins.installed')} onClick={handleShowDebugInfo}
</TabsTrigger>
{systemInfo.enable_marketplace && (
<TabsTrigger value="market" className="px-6 py-4 cursor-pointer">
{t('plugins.marketplace')}
</TabsTrigger>
)}
<TabsTrigger
value="mcp-servers"
className="px-6 py-4 cursor-pointer"
> >
{t('mcp.title')} <Code className="w-4 h-4 mr-2" />
</TabsTrigger> {t('plugins.debugInfo')}
</TabsList> </Button>
</PopoverTrigger>
<PopoverContent className="w-[380px]" align="end">
<div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b">
<Bug className="w-4 h-4" />
<h4 className="font-semibold text-sm">
{t('plugins.debugInfoTitle')}
</h4>
</div>
<div className="flex flex-row justify-end items-center gap-2"> {/* Debug URL row */}
{activeTab === 'installed' && ( <div className="flex items-center gap-2">
<Popover <label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
open={debugPopoverOpen} {t('plugins.debugUrl')}:
onOpenChange={setDebugPopoverOpen} </label>
> <Input
<PopoverTrigger asChild> value={debugInfo?.debug_url || ''}
<Button readOnly
variant="outline" className="w-[220px] font-mono text-xs h-8"
className="px-4 py-5 cursor-pointer" />
onClick={handleShowDebugInfo} <Button
> variant="ghost"
<Code className="w-4 h-4 mr-2" /> size="icon"
{t('plugins.debugInfo')} className="h-8 w-8 shrink-0"
</Button> onClick={() =>
</PopoverTrigger> handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
<PopoverContent className="w-[380px]" align="end"> }
<div className="space-y-3"> >
{/* Header with icon and title */} {copiedDebugUrl ? (
<div className="flex items-center gap-2 pb-2 border-b"> <Check className="w-3.5 h-3.5 text-green-600" />
<Bug className="w-4 h-4" /> ) : (
<h4 className="font-semibold text-sm"> <Copy className="w-3.5 h-3.5" />
{t('plugins.debugInfoTitle')} )}
</h4>
</div>
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}:
</label>
<Input
value={debugInfo?.debug_url || ''}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
}
>
{copiedDebugUrl ? (
<Check className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
{/* Debug Key row */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugKey')}:
</label>
<Input
value={
debugInfo?.plugin_debug_key ||
t('plugins.noDebugKey')
}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(
debugInfo?.plugin_debug_key || '',
'key',
)
}
disabled={!debugInfo?.plugin_debug_key}
>
{copiedDebugKey ? (
<Check className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
{!debugInfo?.plugin_debug_key && (
<p className="text-xs text-muted-foreground ml-[58px]">
{t('plugins.debugKeyDisabled')}
</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">
<PlusIcon className="w-4 h-4" />
{activeTab === 'mcp-servers'
? t('mcp.add')
: t('plugins.install')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </div>
<DropdownMenuContent align="end">
{activeTab === 'mcp-servers' ? (
<>
<DropdownMenuItem
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setActiveTab('mcp-servers');
setIsEditMode(false);
setEditingServerName(null);
setMcpSSEModalOpen(true);
}}
>
<PlusIcon className="w-4 h-4" />
{t('mcp.createServer')}
</DropdownMenuItem>
</>
) : (
<>
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={() => {
setActiveTab('market');
}}
>
<StoreIcon className="w-4 h-4" />
{t('plugins.marketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}}
>
<Github className="w-4 h-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<TabsContent value="installed" className="flex-1 overflow-y-auto mt-0">
<PluginInstalledComponent ref={pluginInstalledRef} />
</TabsContent>
<TabsContent value="market" className="flex-1 overflow-y-auto mt-0">
<MarketPage
installPlugin={async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallSource('marketplace');
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setModalOpen(true);
}}
/>
</TabsContent>
<TabsContent
value="mcp-servers"
className="flex-1 overflow-y-auto mt-0"
>
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {
setEditingServerName(serverName);
setIsEditMode(true);
setMcpSSEModalOpen(true);
}}
/>
</TabsContent>
</Tabs>
{/* Debug Key row */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugKey')}:
</label>
<Input
value={
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(
debugInfo?.plugin_debug_key || '',
'key',
)
}
disabled={!debugInfo?.plugin_debug_key}
>
{copiedDebugKey ? (
<Check className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
{!debugInfo?.plugin_debug_key && (
<p className="text-xs text-muted-foreground ml-[58px]">
{t('plugins.debugKeyDisabled')}
</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">
<PlusIcon className="w-4 h-4" />
{t('plugins.install')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={() => {
router.push('/home/market');
}}
>
<StoreIcon className="w-4 h-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}}
>
<Github className="w-4 h-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Installed plugins grid */}
<div className="flex-1 overflow-y-auto">
<PluginInstalledComponent ref={pluginInstalledRef} />
</div>
{/* Install plugin dialog (GitHub flow) */}
<Dialog <Dialog
open={modalOpen} open={modalOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -886,19 +831,6 @@ export default function PluginConfigPage() {
</div> </div>
)} )}
{/* Marketplace Install Confirm */}
{installSource === 'marketplace' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{/* GitHub Install Confirm */} {/* GitHub Install Confirm */}
{installSource === 'github' && {installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
@@ -1009,33 +941,6 @@ export default function PluginConfigPage() {
</div> </div>
</div> </div>
)} )}
<MCPFormDialog
open={mcpSSEModalOpen}
onOpenChange={setMcpSSEModalOpen}
serverName={editingServerName}
isEditMode={isEditMode}
onSuccess={() => {
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
onDelete={() => {
setShowDeleteConfirmModal(true);
}}
/>
<MCPDeleteConfirmDialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
serverName={editingServerName}
onSuccess={() => {
setMcpSSEModalOpen(false);
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
/>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
.cardContainer { .cardContainer {
background-color: #fff; background-color: #fff;
border-radius: 9px; border-radius: 9px;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); border: 1px solid #e4e4e7;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -12,15 +12,15 @@
:global(.dark) .cardContainer { :global(.dark) .cardContainer {
background-color: #1f1f22; background-color: #1f1f22;
box-shadow: 0; border-color: #27272a;
} }
.cardContainer:hover { .cardContainer:hover {
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.05); border-color: #a1a1aa;
} }
:global(.dark) .cardContainer:hover { :global(.dark) .cardContainer:hover {
box-shadow: 0; border-color: #3f3f46;
} }
.createCardContainer { .createCardContainer {

View File

@@ -97,6 +97,10 @@ export class CloudServiceClient extends BaseHttpClient {
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases'); return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
} }
public getGitHubRepoInfo(): Promise<GitHubRepoInfo> {
return this.get<GitHubRepoInfo>('/api/v1/dist/info/repo');
}
public getAllTags(): Promise<{ tags: PluginTag[] }> { public getAllTags(): Promise<{ tags: PluginTag[] }> {
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags'); return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
} }
@@ -134,3 +138,13 @@ export interface GitHubRelease {
prerelease: boolean; prerelease: boolean;
draft: boolean; draft: boolean;
} }
export interface GitHubRepoInfo {
repo: {
stargazers_count: number;
forks_count: number;
open_issues_count: number;
[key: string]: unknown;
};
contributors: unknown[];
}

1086
web/src/app/wizard/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6',
className, className,
)} )}
{...props} {...props}

View File

@@ -25,8 +25,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state'; const SIDEBAR_STORAGE_KEY = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem'; const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_WIDTH_ICON = '3rem';
@@ -71,7 +70,13 @@ function SidebarProvider({
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen); const [_open, _setOpen] = React.useState(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (stored !== null) return stored === 'true';
}
return defaultOpen;
});
const open = openProp ?? _open; const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
@@ -82,8 +87,10 @@ function SidebarProvider({
_setOpen(openState); _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // Persist sidebar state to localStorage
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; if (typeof window !== 'undefined') {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState));
}
}, },
[setOpenProp, open], [setOpenProp, open],
); );
@@ -139,7 +146,7 @@ function SidebarProvider({
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh w-full overflow-hidden',
className, className,
)} )}
{...props} {...props}
@@ -311,6 +318,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
className={cn( className={cn(
'bg-background relative flex w-full flex-1 flex-col', 'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
className, className,
)} )}
{...props} {...props}
@@ -643,7 +651,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
className, className,
)} )}

View File

@@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "cursor-pointer data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,4 +1,12 @@
const enUS = { const enUS = {
sidebar: {
home: 'Home',
extensions: 'Extensions',
installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace',
mcpServers: 'MCP Servers',
quickStart: 'Quick Start',
},
common: { common: {
login: 'Login', login: 'Login',
logout: 'Logout', logout: 'Logout',
@@ -22,6 +30,7 @@ const enUS = {
language: 'Language', language: 'Language',
helpDocs: 'Get Help', helpDocs: 'Get Help',
featureRequest: 'Feature Request', featureRequest: 'Feature Request',
starOnGitHub: 'Star on GitHub',
create: 'Create', create: 'Create',
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
@@ -136,6 +145,9 @@ const enUS = {
apiKeyCreatedMessage: apiKeyCreatedMessage:
'Please copy this API key, if the button is invalid, please copy manually.', 'Please copy this API key, if the button is invalid, please copy manually.',
none: 'None', none: 'None',
more: 'More ({{count}})',
less: 'Less',
noItems: 'No items',
}, },
notFound: { notFound: {
title: 'Page not found', title: 'Page not found',
@@ -251,6 +263,7 @@ const enUS = {
description: description:
'Create and manage bots, which are the entry points for LangBot to connect with various platforms', 'Create and manage bots, which are the entry points for LangBot to connect with various platforms',
createBot: 'Create Bot', createBot: 'Create Bot',
selectFromSidebar: 'Select a bot from the sidebar',
editBot: 'Edit Bot', editBot: 'Edit Bot',
getBotListError: 'Failed to get bot list: ', getBotListError: 'Failed to get bot list: ',
botName: 'Bot Name', botName: 'Bot Name',
@@ -284,6 +297,17 @@ const enUS = {
log: 'Log', log: 'Log',
configuration: 'Configuration', configuration: 'Configuration',
logs: 'Logs', logs: 'Logs',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the bot name and description',
routingConnection: 'Routing & Connection',
routingConnectionDescription:
'Bind the pipeline that processes messages for this bot',
adapterConfigDescription: 'Configure the selected platform adapter',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deleteBotAction: 'Delete this bot',
deleteBotHint:
'Once deleted, all associated configuration will be permanently removed.',
webhookUrl: 'Webhook Callback URL', webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied', webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint: webhookUrlHint:
@@ -298,6 +322,7 @@ const enUS = {
viewDetails: 'Details', viewDetails: 'Details',
collapse: 'Collapse', collapse: 'Collapse',
imagesAttached: 'image(s) attached', imagesAttached: 'image(s) attached',
noLogs: 'No logs yet',
sessionMonitor: { sessionMonitor: {
title: 'Sessions', title: 'Sessions',
sessions: 'Sessions', sessions: 'Sessions',
@@ -453,6 +478,7 @@ const enUS = {
assetSize: 'Size: {{size}}', assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install', confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release', installFromGithubDesc: 'Install plugin from GitHub Release',
goToMarketplace: 'Go to Marketplace',
}, },
market: { market: {
searchPlaceholder: 'Search plugins...', searchPlaceholder: 'Search plugins...',
@@ -590,12 +616,20 @@ const enUS = {
sseTimeoutNonNegative: 'SSE timeout cannot be negative', sseTimeoutNonNegative: 'SSE timeout cannot be negative',
updateSuccess: 'Updated successfully', updateSuccess: 'Updated successfully',
updateFailed: 'Update failed: ', updateFailed: 'Update failed: ',
selectFromSidebar: 'Select an MCP server from the sidebar',
dangerZone: 'Danger Zone',
dangerZoneDescription:
'Irreversible and destructive actions for this MCP server.',
deleteMCPAction: 'Delete this MCP server',
deleteMCPHint:
'Once deleted, this MCP server configuration cannot be recovered.',
}, },
pipelines: { pipelines: {
title: 'Pipelines', title: 'Pipelines',
description: description:
'Pipelines define the processing flow for message events, used to bind to bots', 'Pipelines define the processing flow for message events, used to bind to bots',
createPipeline: 'Create Pipeline', createPipeline: 'Create Pipeline',
selectFromSidebar: 'Select a pipeline from the sidebar',
editPipeline: 'Edit Pipeline', editPipeline: 'Edit Pipeline',
chat: 'Chat', chat: 'Chat',
configuration: 'Configuration', configuration: 'Configuration',
@@ -610,7 +644,8 @@ const enUS = {
earliestCreated: 'Earliest Created', earliestCreated: 'Earliest Created',
recentlyEdited: 'Recently Edited', recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited', earliestEdited: 'Earliest Edited',
basicInfo: 'Basic', basicInfo: 'Basic Information',
basicInfoDescription: 'Set the pipeline name, icon and description',
aiCapabilities: 'AI', aiCapabilities: 'AI',
triggerConditions: 'Trigger', triggerConditions: 'Trigger',
safetyControls: 'Safety', safetyControls: 'Safety',
@@ -631,6 +666,14 @@ const enUS = {
copyConfirmation: copyConfirmation:
'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.', 'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.',
unsavedChanges: 'You have unsaved changes', unsavedChanges: 'You have unsaved changes',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deletePipelineAction: 'Delete this pipeline',
deletePipelineHint:
'Once deleted, bots bound to this pipeline will stop working.',
copyPipelineAction: 'Copy this pipeline',
copyPipelineHint:
'Create a new pipeline with all configurations duplicated.',
extensions: { extensions: {
title: 'Extensions', title: 'Extensions',
loadError: 'Failed to load plugins', loadError: 'Failed to load plugins',
@@ -685,9 +728,14 @@ const enUS = {
replyTo: 'Reply to', replyTo: 'Reply to',
showMarkdown: 'Show Markdown', showMarkdown: 'Show Markdown',
showRaw: 'Show Raw', showRaw: 'Show Raw',
allMembers: 'All Members',
file: 'File',
voice: 'Voice',
uploadImage: 'Upload Image',
uploading: 'Uploading...',
}, },
monitoring: { monitoring: {
title: 'Monitoring', title: 'Dashboard',
description: description:
'View execution logs and errors for this pipeline (last 24 hours)', 'View execution logs and errors for this pipeline (last 24 hours)',
detailedLogs: 'Detailed Logs', detailedLogs: 'Detailed Logs',
@@ -696,6 +744,7 @@ const enUS = {
knowledge: { knowledge: {
title: 'Knowledge', title: 'Knowledge',
createKnowledgeBase: 'Create Knowledge Base', createKnowledgeBase: 'Create Knowledge Base',
selectFromSidebar: 'Select a knowledge base from the sidebar',
editKnowledgeBase: 'Edit Knowledge Base', editKnowledgeBase: 'Edit Knowledge Base',
selectKnowledgeBase: 'Select Knowledge Base', selectKnowledgeBase: 'Select Knowledge Base',
selectKnowledgeBases: 'Select Knowledge Bases', selectKnowledgeBases: 'Select Knowledge Bases',
@@ -773,9 +822,20 @@ const enUS = {
builtInEngine: 'Built-in Engine', builtInEngine: 'Built-in Engine',
cannotChangeKnowledgeEngine: cannotChangeKnowledgeEngine:
'Knowledge engine cannot be changed after creation', 'Knowledge engine cannot be changed after creation',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the knowledge base name, icon and description',
engineSettings: 'Engine Settings', engineSettings: 'Engine Settings',
engineSettingsDescription:
'Configuration for the selected knowledge engine',
engineSettingsReadonly: 'read-only in edit mode', engineSettingsReadonly: 'read-only in edit mode',
retrievalSettings: 'Retrieval Settings', retrievalSettings: 'Retrieval Settings',
retrievalSettingsDescription:
'Configure how documents are retrieved from this knowledge base',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deleteKbAction: 'Delete this knowledge base',
deleteKbHint:
'Once deleted, all documents and data in this knowledge base will be permanently removed.',
noEnginesAvailable: 'No knowledge base engines available', noEnginesAvailable: 'No knowledge base engines available',
installEngineHint: 'Please install a "Knowledge Engine" plugin first', installEngineHint: 'Please install a "Knowledge Engine" plugin first',
createKnowledgeBaseFailed: 'Failed to create knowledge base: ', createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
@@ -901,7 +961,7 @@ const enUS = {
'Space login email does not match the local account email', 'Space login email does not match the local account email',
}, },
monitoring: { monitoring: {
title: 'Monitoring', title: 'Dashboard',
description: 'Monitor bot activities, LLM calls, and system performance', description: 'Monitor bot activities, LLM calls, and system performance',
overview: 'Overview', overview: 'Overview',
totalMessages: 'Total Messages', totalMessages: 'Total Messages',
@@ -1051,6 +1111,68 @@ const enUS = {
maxExtensionsReached: maxExtensionsReached:
'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.', 'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.',
}, },
wizard: {
sidebarDescription: 'Create a bot with guided steps',
loading: 'Loading wizard...',
loadError: 'Failed to load wizard data',
skip: 'Skip',
skipConfirmMessage:
'You can re-enter the Quick Start wizard from the sidebar later, or create a bot manually.',
skipConfirmOk: 'OK',
prev: 'Previous',
next: 'Next',
finish: 'Create & Deploy',
confirmCreateBot: 'Confirm, Create Bot',
createSuccess: 'Pipeline created and linked to bot successfully!',
botCreateSuccess: 'Bot created successfully!',
botSaveSuccess: 'Bot configuration saved and enabled!',
createError: 'Failed to create resources',
spaceAuthError: 'Failed to initiate Space authorization',
step: {
platform: 'Platform',
botConfig: 'Bot Setup',
aiEngine: 'AI Engine',
done: 'Done',
},
platform: {
title: 'Select a Platform',
description: 'Choose the messaging platform your bot will connect to.',
},
botConfig: {
title: 'Configure Your Bot',
description: 'Set up your bot and verify it works before continuing.',
saveBot: 'Save & Enable Bot',
resaveBot: 'Re-save Configuration',
botSaved:
'Bot configuration saved and enabled. Check the logs to verify the connection.',
logsTitle: 'Bot Logs',
logsDescription:
'Monitor bot activity to verify the platform connection is working.',
},
aiEngine: {
title: 'Select an AI Engine',
description:
"Choose the AI engine that will power your bot's intelligence.",
},
spaceBanner: {
message:
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
action: 'Authorize with Space',
},
config: {
botInfo: 'Bot Information',
botNamePlaceholder: 'Enter bot name',
botDescPlaceholder: 'Enter bot description (optional)',
platformConfig: '{{platform}} Configuration',
aiConfig: '{{engine}} Configuration',
},
done: {
title: 'All Set!',
description:
'Your bot has been created and connected to the AI pipeline. You can now manage it from the workbench.',
backToWorkbench: 'Back to Workbench',
},
},
}; };
export default enUS; export default enUS;

View File

@@ -1,4 +1,12 @@
const jaJP = { const jaJP = {
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
installedPlugins: 'インストール済みプラグイン',
pluginMarket: 'プラグインマーケット',
mcpServers: 'MCPサーバー',
quickStart: 'クイックスタート',
},
common: { common: {
login: 'ログイン', login: 'ログイン',
logout: 'ログアウト', logout: 'ログアウト',
@@ -23,6 +31,7 @@
language: '言語', language: '言語',
helpDocs: 'ヘルプドキュメント', helpDocs: 'ヘルプドキュメント',
featureRequest: '機能リクエスト', featureRequest: '機能リクエスト',
starOnGitHub: 'GitHubでStarする',
create: '作成', create: '作成',
edit: '編集', edit: '編集',
delete: '削除', delete: '削除',
@@ -138,6 +147,9 @@
apiKeyCreatedMessage: apiKeyCreatedMessage:
'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。', 'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。',
none: 'なし', none: 'なし',
more: 'もっと見る ({{count}})',
less: '折りたたむ',
noItems: '項目がありません',
}, },
notFound: { notFound: {
title: 'ページが見つかりません', title: 'ページが見つかりません',
@@ -256,6 +268,7 @@
description: description:
'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです', 'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです',
createBot: 'ボットを作成', createBot: 'ボットを作成',
selectFromSidebar: 'サイドバーからボットを選択してください',
editBot: 'ボットを編集', editBot: 'ボットを編集',
getBotListError: 'ボットリストの取得に失敗しました:', getBotListError: 'ボットリストの取得に失敗しました:',
botName: 'ボット名', botName: 'ボット名',
@@ -289,6 +302,17 @@
log: 'ログ', log: 'ログ',
configuration: '設定', configuration: '設定',
logs: 'ログ', logs: 'ログ',
basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続',
routingConnectionDescription:
'このボットのメッセージを処理するパイプラインを紐付け',
adapterConfigDescription: '選択したプラットフォームアダプターを設定',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deleteBotAction: 'このボットを削除',
deleteBotHint:
'削除すると、関連する全ての設定が完全に削除され、復元できません。',
webhookUrl: 'Webhook コールバック URL', webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint: webhookUrlHint:
@@ -299,6 +323,7 @@
allLevels: 'すべてのレベル', allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択', selectLevel: 'レベルを選択',
levelsSelected: 'レベル選択済み', levelsSelected: 'レベル選択済み',
noLogs: 'ログはまだありません',
sessionMonitor: { sessionMonitor: {
title: 'セッション監視', title: 'セッション監視',
sessions: 'セッション一覧', sessions: 'セッション一覧',
@@ -453,6 +478,7 @@
assetSize: 'サイズ: {{size}}', assetSize: 'サイズ: {{size}}',
confirmInstall: 'インストールを確認', confirmInstall: 'インストールを確認',
installFromGithubDesc: 'GitHubリリースからプラグインをインストール', installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
goToMarketplace: 'マーケットプレイスへ',
}, },
market: { market: {
searchPlaceholder: 'プラグインを検索...', searchPlaceholder: 'プラグインを検索...',
@@ -591,12 +617,18 @@
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません', sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
updateSuccess: '更新に成功しました', updateSuccess: '更新に成功しました',
updateFailed: '更新に失敗しました:', updateFailed: '更新に失敗しました:',
selectFromSidebar: 'サイドバーからMCPサーバーを選択してください',
dangerZone: '危険ゾーン',
dangerZoneDescription: 'このMCPサーバーに対する不可逆的な操作です。',
deleteMCPAction: 'このMCPサーバーを削除',
deleteMCPHint: '削除すると、このMCPサーバーの設定は復元できません。',
}, },
pipelines: { pipelines: {
title: 'パイプライン', title: 'パイプライン',
description: description:
'メッセージイベントの処理フローを定義し、ボットに紐付けて使用するパイプラインです', 'メッセージイベントの処理フローを定義し、ボットに紐付けて使用するパイプラインです',
createPipeline: 'パイプラインを作成', createPipeline: 'パイプラインを作成',
selectFromSidebar: 'サイドバーからパイプラインを選択してください',
editPipeline: 'パイプラインを編集', editPipeline: 'パイプラインを編集',
chat: 'チャット', chat: 'チャット',
configuration: '設定', configuration: '設定',
@@ -612,6 +644,7 @@
recentlyEdited: '最近編集', recentlyEdited: '最近編集',
earliestEdited: '最古編集', earliestEdited: '最古編集',
basicInfo: '基本情報', basicInfo: '基本情報',
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
aiCapabilities: 'AI機能', aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件', triggerConditions: 'トリガー条件',
safetyControls: '安全制御', safetyControls: '安全制御',
@@ -633,6 +666,13 @@
copyConfirmation: copyConfirmation:
'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。', 'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。',
unsavedChanges: '未保存の変更があります', unsavedChanges: '未保存の変更があります',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deletePipelineAction: 'このパイプラインを削除',
deletePipelineHint:
'削除すると、このパイプラインに紐付けられたボットは動作しなくなります。',
copyPipelineAction: 'このパイプラインをコピー',
copyPipelineHint: 'すべての設定を複製した新しいパイプラインを作成します。',
extensions: { extensions: {
title: 'プラグイン統合', title: 'プラグイン統合',
loadError: 'プラグインリストの読み込みに失敗しました', loadError: 'プラグインリストの読み込みに失敗しました',
@@ -688,9 +728,14 @@
replyTo: '返信先', replyTo: '返信先',
showMarkdown: 'Markdownで表示', showMarkdown: 'Markdownで表示',
showRaw: '原文で表示', showRaw: '原文で表示',
allMembers: '全メンバー',
file: 'ファイル',
voice: '音声',
uploadImage: '画像をアップロード',
uploading: 'アップロード中...',
}, },
monitoring: { monitoring: {
title: 'モニタリング', title: 'ダッシュボード',
description: 'このパイプラインの実行ログとエラー情報を表示過去24時間', description: 'このパイプラインの実行ログとエラー情報を表示過去24時間',
detailedLogs: '詳細ログ', detailedLogs: '詳細ログ',
}, },
@@ -698,6 +743,7 @@
knowledge: { knowledge: {
title: '知識ベース', title: '知識ベース',
createKnowledgeBase: '知識ベースを作成', createKnowledgeBase: '知識ベースを作成',
selectFromSidebar: 'サイドバーから知識ベースを選択してください',
editKnowledgeBase: '知識ベースを編集', editKnowledgeBase: '知識ベースを編集',
selectKnowledgeBase: '知識ベースを選択', selectKnowledgeBase: '知識ベースを選択',
selectKnowledgeBases: '知識ベースを選択', selectKnowledgeBases: '知識ベースを選択',
@@ -769,6 +815,18 @@
fileName: 'ファイル名', fileName: 'ファイル名',
noResults: '検索結果がありません', noResults: '検索結果がありません',
retrieveError: '検索に失敗しました:', retrieveError: '検索に失敗しました:',
basicInfo: '基本情報',
basicInfoDescription: 'ナレッジベースの名前、アイコン、説明を設定',
engineSettings: 'エンジン設定',
engineSettingsDescription: '選択したナレッジエンジンの設定',
engineSettingsReadonly: '編集モードでは変更できません',
retrievalSettings: '検索設定',
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deleteKbAction: 'このナレッジベースを削除',
deleteKbHint:
'削除すると、このナレッジベース内のすべての文書とデータが完全に削除されます。',
noEnginesAvailable: '利用可能なナレッジエンジンがありません', noEnginesAvailable: '利用可能なナレッジエンジンがありません',
installEngineHint: installEngineHint:
'先に「ナレッジエンジン」プラグインをインストールしてください', '先に「ナレッジエンジン」プラグインをインストールしてください',
@@ -894,7 +952,7 @@
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません', 'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
}, },
monitoring: { monitoring: {
title: 'モニタリング', title: 'ダッシュボード',
description: description:
'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視', 'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視',
overview: '概要', overview: '概要',
@@ -1027,6 +1085,70 @@
maxExtensionsReached: maxExtensionsReached:
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。', '拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
}, },
wizard: {
sidebarDescription: 'ガイド付きステップでボットを作成',
loading: 'ウィザードを読み込み中...',
loadError: 'ウィザードデータの読み込みに失敗しました',
skip: 'スキップ',
skipConfirmMessage:
'サイドバーからクイックスタートウィザードに再度アクセスするか、手動でボットを作成できます。',
skipConfirmOk: 'OK',
prev: '前へ',
next: '次へ',
finish: '作成&デプロイ',
confirmCreateBot: '確定、ボットを作成',
createSuccess: 'パイプラインが作成され、ボットにリンクされました!',
botCreateSuccess: 'ボットが正常に作成されました!',
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
createError: 'リソースの作成に失敗しました',
spaceAuthError: 'Space 認証の開始に失敗しました',
step: {
platform: 'プラットフォーム',
botConfig: 'ボット設定',
aiEngine: 'AIエンジン',
done: '完了',
},
platform: {
title: 'プラットフォームを選択',
description:
'ボットが接続するメッセージングプラットフォームを選択してください。',
},
botConfig: {
title: 'ボットを設定',
description:
'ボットをセットアップし、正常に動作することを確認してから続行してください。',
saveBot: '保存して有効化',
resaveBot: '設定を再保存',
botSaved:
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
logsTitle: 'ボットログ',
logsDescription:
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
},
aiEngine: {
title: 'AIエンジンを選択',
description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
},
spaceBanner: {
message:
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手',
action: 'Spaceで認証',
},
config: {
botInfo: 'ボット情報',
botNamePlaceholder: 'ボット名を入力',
botDescPlaceholder: 'ボットの説明を入力(任意)',
platformConfig: '{{platform}} 設定',
aiConfig: '{{engine}} 設定',
},
done: {
title: '完了しました!',
description:
'ボットが作成され、AIパイプラインに接続されました。ワークベンチから管理できます。',
backToWorkbench: 'ワークベンチに戻る',
},
},
}; };
export default jaJP; export default jaJP;

View File

@@ -1,4 +1,12 @@
const zhHans = { const zhHans = {
sidebar: {
home: '首页',
extensions: '扩展',
installedPlugins: '已安装插件',
pluginMarket: '插件市场',
mcpServers: 'MCP 服务器',
quickStart: '快速开始',
},
common: { common: {
login: '登录', login: '登录',
logout: '退出登录', logout: '退出登录',
@@ -21,6 +29,7 @@ const zhHans = {
language: '语言', language: '语言',
helpDocs: '帮助文档', helpDocs: '帮助文档',
featureRequest: '需求建议', featureRequest: '需求建议',
starOnGitHub: '在 GitHub 上 Star',
create: '创建', create: '创建',
edit: '编辑', edit: '编辑',
delete: '删除', delete: '删除',
@@ -127,6 +136,9 @@ const zhHans = {
actions: '操作', actions: '操作',
apiKeyCreatedMessage: '请复制此 API 密钥,若按钮无效,请手动复制。', apiKeyCreatedMessage: '请复制此 API 密钥,若按钮无效,请手动复制。',
none: '无', none: '无',
more: '更多 ({{count}})',
less: '收起',
noItems: '暂无内容',
}, },
notFound: { notFound: {
title: '页面不存在', title: '页面不存在',
@@ -240,6 +252,7 @@ const zhHans = {
title: '机器人', title: '机器人',
description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口', description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口',
createBot: '创建机器人', createBot: '创建机器人',
selectFromSidebar: '从侧边栏选择一个机器人',
editBot: '编辑机器人', editBot: '编辑机器人',
getBotListError: '获取机器人列表失败:', getBotListError: '获取机器人列表失败:',
botName: '机器人名称', botName: '机器人名称',
@@ -272,6 +285,15 @@ const zhHans = {
log: '日志', log: '日志',
configuration: '配置', configuration: '配置',
logs: '日志', logs: '日志',
basicInfo: '基础信息',
basicInfoDescription: '设置机器人名称和描述',
routingConnection: '路由与连接',
routingConnectionDescription: '绑定处理此机器人消息的流水线',
adapterConfigDescription: '配置所选平台适配器',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deleteBotAction: '删除此机器人',
deleteBotHint: '删除后,所有关联配置将被永久移除,且无法恢复。',
webhookUrl: 'Webhook 回调地址', webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制', webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint: webhookUrlHint:
@@ -285,6 +307,7 @@ const zhHans = {
viewDetails: '详情', viewDetails: '详情',
collapse: '收起', collapse: '收起',
imagesAttached: '张图片', imagesAttached: '张图片',
noLogs: '暂无日志',
sessionMonitor: { sessionMonitor: {
title: '会话监控', title: '会话监控',
sessions: '会话列表', sessions: '会话列表',
@@ -431,6 +454,7 @@ const zhHans = {
assetSize: '大小: {{size}}', assetSize: '大小: {{size}}',
confirmInstall: '确认安装', confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件', installFromGithubDesc: '从 GitHub Release 安装插件',
goToMarketplace: '前往插件市场',
}, },
market: { market: {
searchPlaceholder: '搜索插件...', searchPlaceholder: '搜索插件...',
@@ -564,11 +588,17 @@ const zhHans = {
sseTimeoutNonNegative: 'SSE超时时间不能为负数', sseTimeoutNonNegative: 'SSE超时时间不能为负数',
updateSuccess: '更新成功', updateSuccess: '更新成功',
updateFailed: '更新失败:', updateFailed: '更新失败:',
selectFromSidebar: '从侧边栏选择一个 MCP 服务器',
dangerZone: '危险区域',
dangerZoneDescription: '此 MCP 服务器的不可逆操作。',
deleteMCPAction: '删除此 MCP 服务器',
deleteMCPHint: '删除后,此 MCP 服务器配置将无法恢复。',
}, },
pipelines: { pipelines: {
title: '流水线', title: '流水线',
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人', description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',
createPipeline: '创建流水线', createPipeline: '创建流水线',
selectFromSidebar: '从侧边栏选择一个流水线',
editPipeline: '编辑流水线', editPipeline: '编辑流水线',
chat: '对话', chat: '对话',
configuration: '配置', configuration: '配置',
@@ -584,6 +614,7 @@ const zhHans = {
recentlyEdited: '最近编辑', recentlyEdited: '最近编辑',
earliestEdited: '最早编辑', earliestEdited: '最早编辑',
basicInfo: '基础信息', basicInfo: '基础信息',
basicInfoDescription: '设置流水线名称、图标和描述',
aiCapabilities: 'AI 能力', aiCapabilities: 'AI 能力',
triggerConditions: '触发条件', triggerConditions: '触发条件',
safetyControls: '安全控制', safetyControls: '安全控制',
@@ -604,6 +635,12 @@ const zhHans = {
copyConfirmation: copyConfirmation:
'确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。', '确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。',
unsavedChanges: '有未保存的更改', unsavedChanges: '有未保存的更改',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '删除此流水线',
deletePipelineHint: '删除后,绑定此流水线的机器人将无法正常工作。',
copyPipelineAction: '复制此流水线',
copyPipelineHint: '创建一条新的流水线,并复制所有配置。',
extensions: { extensions: {
title: '扩展集成', title: '扩展集成',
loadError: '加载插件列表失败', loadError: '加载插件列表失败',
@@ -658,6 +695,11 @@ const zhHans = {
replyTo: '回复给', replyTo: '回复给',
showMarkdown: '渲染', showMarkdown: '渲染',
showRaw: '原文', showRaw: '原文',
allMembers: '全体成员',
file: '文件',
voice: '语音',
uploadImage: '上传图片',
uploading: '上传中...',
}, },
monitoring: { monitoring: {
title: '监控日志', title: '监控日志',
@@ -668,6 +710,7 @@ const zhHans = {
knowledge: { knowledge: {
title: '知识库', title: '知识库',
createKnowledgeBase: '创建知识库', createKnowledgeBase: '创建知识库',
selectFromSidebar: '从侧边栏选择一个知识库',
editKnowledgeBase: '编辑知识库', editKnowledgeBase: '编辑知识库',
selectKnowledgeBase: '选择知识库', selectKnowledgeBase: '选择知识库',
selectKnowledgeBases: '选择知识库', selectKnowledgeBases: '选择知识库',
@@ -739,9 +782,17 @@ const zhHans = {
selectKnowledgeEngine: '选择知识引擎', selectKnowledgeEngine: '选择知识引擎',
builtInEngine: '内置引擎', builtInEngine: '内置引擎',
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎', cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
basicInfo: '基础信息',
basicInfoDescription: '设置知识库名称、图标和描述',
engineSettings: '引擎设置', engineSettings: '引擎设置',
engineSettingsDescription: '所选知识引擎的配置',
engineSettingsReadonly: '编辑模式下不可修改', engineSettingsReadonly: '编辑模式下不可修改',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '删除此知识库',
deleteKbHint: '删除后,此知识库中的所有文档和数据将被永久移除。',
noEnginesAvailable: '没有可用的知识库引擎', noEnginesAvailable: '没有可用的知识库引擎',
installEngineHint: '请先安装「知识引擎」插件', installEngineHint: '请先安装「知识引擎」插件',
createKnowledgeBaseFailed: '知识库创建失败:', createKnowledgeBaseFailed: '知识库创建失败:',
@@ -857,7 +908,7 @@ const zhHans = {
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配', spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
}, },
monitoring: { monitoring: {
title: '日志监控', title: '仪表盘',
description: '查看机器人活动、LLM调用和系统性能', description: '查看机器人活动、LLM调用和系统性能',
overview: '概览', overview: '概览',
totalMessages: '总消息数', totalMessages: '总消息数',
@@ -1007,6 +1058,64 @@ const zhHans = {
maxExtensionsReached: maxExtensionsReached:
'已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。', '已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。',
}, },
wizard: {
sidebarDescription: '通过引导步骤创建机器人',
loading: '正在加载向导...',
loadError: '加载向导数据失败',
skip: '跳过',
skipConfirmMessage:
'您之后可以在侧边栏重新进入快速开始向导,或手动创建机器人。',
skipConfirmOk: '确定',
prev: '上一步',
next: '下一步',
finish: '创建并部署',
confirmCreateBot: '确定,创建机器人',
createSuccess: '流水线已创建并关联到机器人!',
botCreateSuccess: '机器人创建成功!',
botSaveSuccess: '机器人配置已保存并启用!',
createError: '创建资源失败',
spaceAuthError: '无法发起 Space 授权',
step: {
platform: '平台接入',
botConfig: '机器人配置',
aiEngine: 'AI 引擎',
done: '完成',
},
platform: {
title: '选择平台',
description: '选择机器人要接入的消息平台。',
},
botConfig: {
title: '配置机器人',
description: '配置好机器人并确认其正常运行后再继续。',
saveBot: '保存并启用',
resaveBot: '重新保存配置',
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
logsTitle: '机器人日志',
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
},
aiEngine: {
title: '选择 AI 引擎',
description: '选择驱动机器人智能的 AI 引擎。',
},
spaceBanner: {
message: '接入 LangBot Space获取免费试用模型额度零配置极速开箱',
action: '前往授权登录',
},
config: {
botInfo: '机器人信息',
botNamePlaceholder: '请输入机器人名称',
botDescPlaceholder: '请输入机器人描述(可选)',
platformConfig: '{{platform}} 配置',
aiConfig: '{{engine}} 配置',
},
done: {
title: '一切就绪!',
description:
'机器人已创建并连接到 AI 流水线。你现在可以在工作台中管理它。',
backToWorkbench: '返回工作台',
},
},
}; };
export default zhHans; export default zhHans;

View File

@@ -1,4 +1,12 @@
const zhHant = { const zhHant = {
sidebar: {
home: '首頁',
extensions: '擴展',
installedPlugins: '已安裝外掛',
pluginMarket: '外掛市場',
mcpServers: 'MCP 伺服器',
quickStart: '快速開始',
},
common: { common: {
login: '登入', login: '登入',
logout: '登出', logout: '登出',
@@ -21,6 +29,7 @@ const zhHant = {
language: '語言', language: '語言',
helpDocs: '輔助說明', helpDocs: '輔助說明',
featureRequest: '需求建議', featureRequest: '需求建議',
starOnGitHub: '在 GitHub 上 Star',
create: '建立', create: '建立',
edit: '編輯', edit: '編輯',
delete: '刪除', delete: '刪除',
@@ -127,6 +136,9 @@ const zhHant = {
actions: '操作', actions: '操作',
apiKeyCreatedMessage: '請複製此 API 金鑰,若按鈕無效,請手動複製。', apiKeyCreatedMessage: '請複製此 API 金鑰,若按鈕無效,請手動複製。',
none: '無', none: '無',
more: '更多 ({{count}})',
less: '收起',
noItems: '暫無內容',
}, },
notFound: { notFound: {
title: '頁面不存在', title: '頁面不存在',
@@ -239,6 +251,7 @@ const zhHant = {
title: '機器人', title: '機器人',
description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口', description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口',
createBot: '建立機器人', createBot: '建立機器人',
selectFromSidebar: '從側邊欄選擇一個機器人',
editBot: '編輯機器人', editBot: '編輯機器人',
getBotListError: '取得機器人清單失敗:', getBotListError: '取得機器人清單失敗:',
botName: '機器人名稱', botName: '機器人名稱',
@@ -271,6 +284,15 @@ const zhHant = {
log: '日誌', log: '日誌',
configuration: '設定', configuration: '設定',
logs: '日誌', logs: '日誌',
basicInfo: '基礎資訊',
basicInfoDescription: '設定機器人名稱和描述',
routingConnection: '路由與連接',
routingConnectionDescription: '綁定處理此機器人訊息的流程線',
adapterConfigDescription: '設定所選平台適配器',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteBotAction: '刪除此機器人',
deleteBotHint: '刪除後,所有關聯設定將被永久移除,且無法復原。',
webhookUrl: 'Webhook 回調位址', webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製', webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint: webhookUrlHint:
@@ -280,6 +302,7 @@ const zhHant = {
allLevels: '全部級別', allLevels: '全部級別',
selectLevel: '選擇級別', selectLevel: '選擇級別',
levelsSelected: '個級別已選', levelsSelected: '個級別已選',
noLogs: '暫無日誌',
sessionMonitor: { sessionMonitor: {
title: '會話監控', title: '會話監控',
sessions: '會話列表', sessions: '會話列表',
@@ -424,6 +447,7 @@ const zhHant = {
assetSize: '大小: {{size}}', assetSize: '大小: {{size}}',
confirmInstall: '確認安裝', confirmInstall: '確認安裝',
installFromGithubDesc: '從 GitHub Release 安裝插件', installFromGithubDesc: '從 GitHub Release 安裝插件',
goToMarketplace: '前往外掛市場',
}, },
market: { market: {
searchPlaceholder: '搜尋插件...', searchPlaceholder: '搜尋插件...',
@@ -557,11 +581,17 @@ const zhHant = {
sseTimeoutNonNegative: 'SSE逾時時間不能為負數', sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
updateSuccess: '更新成功', updateSuccess: '更新成功',
updateFailed: '更新失敗:', updateFailed: '更新失敗:',
selectFromSidebar: '從側邊欄選擇一個 MCP 伺服器',
dangerZone: '危險區域',
dangerZoneDescription: '此 MCP 伺服器的不可逆操作。',
deleteMCPAction: '刪除此 MCP 伺服器',
deleteMCPHint: '刪除後,此 MCP 伺服器設定將無法恢復。',
}, },
pipelines: { pipelines: {
title: '流程線', title: '流程線',
description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人', description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',
createPipeline: '建立流程線', createPipeline: '建立流程線',
selectFromSidebar: '從側邊欄選擇一個流程線',
editPipeline: '編輯流程線', editPipeline: '編輯流程線',
chat: '對話', chat: '對話',
configuration: '設定', configuration: '設定',
@@ -577,6 +607,7 @@ const zhHant = {
recentlyEdited: '最近編輯', recentlyEdited: '最近編輯',
earliestEdited: '最早編輯', earliestEdited: '最早編輯',
basicInfo: '基本資訊', basicInfo: '基本資訊',
basicInfoDescription: '設定流程線名稱、圖示和描述',
aiCapabilities: 'AI 能力', aiCapabilities: 'AI 能力',
triggerConditions: '觸發條件', triggerConditions: '觸發條件',
safetyControls: '安全控制', safetyControls: '安全控制',
@@ -597,6 +628,12 @@ const zhHant = {
copyConfirmation: copyConfirmation:
'確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。', '確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。',
unsavedChanges: '有未儲存的變更', unsavedChanges: '有未儲存的變更',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deletePipelineAction: '刪除此流程線',
deletePipelineHint: '刪除後,綁定此流程線的機器人將無法正常運作。',
copyPipelineAction: '複製此流程線',
copyPipelineHint: '建立一條新的流程線,並複製所有設定。',
extensions: { extensions: {
title: '擴展集成', title: '擴展集成',
loadError: '載入插件清單失敗', loadError: '載入插件清單失敗',
@@ -651,6 +688,11 @@ const zhHant = {
replyTo: '回覆給', replyTo: '回覆給',
showMarkdown: '渲染', showMarkdown: '渲染',
showRaw: '原文', showRaw: '原文',
allMembers: '全體成員',
file: '檔案',
voice: '語音',
uploadImage: '上傳圖片',
uploading: '上傳中...',
}, },
monitoring: { monitoring: {
title: '監控日誌', title: '監控日誌',
@@ -661,6 +703,7 @@ const zhHant = {
knowledge: { knowledge: {
title: '知識庫', title: '知識庫',
createKnowledgeBase: '建立知識庫', createKnowledgeBase: '建立知識庫',
selectFromSidebar: '從側邊欄選擇一個知識庫',
editKnowledgeBase: '編輯知識庫', editKnowledgeBase: '編輯知識庫',
selectKnowledgeBase: '選擇知識庫', selectKnowledgeBase: '選擇知識庫',
selectKnowledgeBases: '選擇知識庫', selectKnowledgeBases: '選擇知識庫',
@@ -726,6 +769,17 @@ const zhHant = {
fileName: '文檔名稱', fileName: '文檔名稱',
noResults: '暫無結果', noResults: '暫無結果',
retrieveError: '檢索失敗:', retrieveError: '檢索失敗:',
basicInfo: '基本資訊',
basicInfoDescription: '設定知識庫名稱、圖示和描述',
engineSettings: '引擎設定',
engineSettingsDescription: '所選知識引擎的設定',
engineSettingsReadonly: '編輯模式下不可修改',
retrievalSettings: '檢索設定',
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteKbAction: '刪除此知識庫',
deleteKbHint: '刪除後,此知識庫中的所有文件和資料將被永久移除。',
noEnginesAvailable: '沒有可用的知識庫引擎', noEnginesAvailable: '沒有可用的知識庫引擎',
installEngineHint: '請先安裝「知識引擎」插件', installEngineHint: '請先安裝「知識引擎」插件',
unknownEngine: '未知引擎', unknownEngine: '未知引擎',
@@ -839,7 +893,7 @@ const zhHant = {
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
}, },
monitoring: { monitoring: {
title: '日誌監控', title: '儀表盤',
description: '監控機器人活動、LLM調用和系統效能', description: '監控機器人活動、LLM調用和系統效能',
overview: '概覽', overview: '概覽',
totalMessages: '總訊息數', totalMessages: '總訊息數',
@@ -971,6 +1025,64 @@ const zhHant = {
maxExtensionsReached: maxExtensionsReached:
'已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。', '已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。',
}, },
wizard: {
sidebarDescription: '透過引導步驟建立機器人',
loading: '正在載入嚮導...',
loadError: '載入嚮導資料失敗',
skip: '跳過',
skipConfirmMessage:
'您之後可以在側邊欄重新進入快速開始嚮導,或手動建立機器人。',
skipConfirmOk: '確定',
prev: '上一步',
next: '下一步',
finish: '建立並部署',
confirmCreateBot: '確定,建立機器人',
createSuccess: '流水線已建立並關聯到機器人!',
botCreateSuccess: '機器人建立成功!',
botSaveSuccess: '機器人配置已儲存並啟用!',
createError: '建立資源失敗',
spaceAuthError: '無法發起 Space 授權',
step: {
platform: '平台接入',
botConfig: '機器人配置',
aiEngine: 'AI 引擎',
done: '完成',
},
platform: {
title: '選擇平台',
description: '選擇機器人要接入的訊息平台。',
},
botConfig: {
title: '配置機器人',
description: '配置好機器人並確認其正常運作後再繼續。',
saveBot: '儲存並啟用',
resaveBot: '重新儲存配置',
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
logsTitle: '機器人日誌',
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
},
aiEngine: {
title: '選擇 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。',
},
spaceBanner: {
message: '接入 LangBot Space取得免費試用模型額度零配置極速開箱',
action: '前往授權登入',
},
config: {
botInfo: '機器人資訊',
botNamePlaceholder: '請輸入機器人名稱',
botDescPlaceholder: '請輸入機器人描述(可選)',
platformConfig: '{{platform}} 配置',
aiConfig: '{{engine}} 配置',
},
done: {
title: '一切就緒!',
description:
'機器人已建立並連接到 AI 流水線。你現在可以在工作台中管理它。',
backToWorkbench: '返回工作台',
},
},
}; };
export default zhHant; export default zhHant;

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,9 +19,7 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
} }
}, },
"include": [ "include": [
@@ -35,7 +29,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules"]
"node_modules"
]
} }