Compare commits

..

25 Commits

Author SHA1 Message Date
Junyan Qin
59d55b382d chore: bump version to 4.8.3 in pyproject.toml and uv.lock 2026-02-02 01:07:46 +08:00
Copilot
8c17e55913 feat: Add Telegram voice message receiving support (#1948)
* Initial plan

* feat: add Telegram voice message receiving support

- Add filters.VOICE to Telegram message handler to capture voice messages
- Implement voice message processing in target2yiri converter
- Download voice files from Telegram API and convert to base64
- Create platform_message.Voice component with proper mime type and duration
- Maintain compatibility with existing text, photo, and command messages

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

* chore: format code

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-02-02 00:51:49 +08:00
RockChinQ
af509fe61f chore: sync deps 2026-02-01 23:02:09 +08:00
Copilot
87e2a2099a fix: display loading animation in content area only (#1955)
* Initial plan

* fix: change loading animation to display only in content area instead of full screen

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-02-01 22:51:10 +08:00
Copilot
3f22f62332 feat: add monitoring tab to pipeline dialog for in-context error debugging (#1953)
* Initial plan

* Add monitoring tab to pipeline dialog with i18n support

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

* Fix prettier formatting for monitoring tab component

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

* Fix code review issues: use functional state updates and add comment for delay

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

* Update dependencies and enhance monitoring tab functionality

- Updated various package versions in pnpm-lock.yaml for improved compatibility and performance.
- Refactored PipelineDetailDialog to streamline WebSocket connection status display.
- Enhanced PipelineMonitoringTab to support navigation to detailed logs and improved UI elements.
- Added i18n support for 'Detailed Logs' in English, Japanese, Simplified Chinese, and Traditional Chinese locales.

* Fix lint errors: remove unused Button import and format en-US.ts

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-01-31 22:00:37 +08:00
fdc310
d1ee5f931a chore(deps): update dashscope version to 1.25.10 in pyproject.toml (#1951)
feat: enable thinking feature in DashScopeAPIRunner for improved conversation handling
2026-01-31 20:31:37 +08:00
fdc310
35506dd2bb feat: add card auto layout configuration for DingTalk adapter (#1952)
* feat: add card auto layout configuration for DingTalk adapter

* fix: correct card auto layout configuration key and improve related logic

* fix: simplify card auto layout configuration logic in create_and_card method

* fix: correct card auto layout key in DingTalk migration configuration

* fix: correct migration class name for DingTalk card auto layout

* fix: update migration version for DingTalk card auto layout

* fix: correct key name for card auto layout in DingTalk configuration

* fix: improve formatting and consistency in DingTalk card auto layout methods
2026-01-31 20:31:01 +08:00
fdc310
2f06321ebf fix: Fix the file URL processing logic to support complete URLs (#1950) 2026-01-31 20:30:46 +08:00
Junyan Qin
023281ae56 fix: ensure content extraction from messages includes only valid text entries 2026-01-31 13:51:17 +08:00
Junyan Qin
50dff55217 feat: enhance LLM model creation with optional default pipeline setting
- Updated create_llm_model method to include auto_set_to_default_pipeline parameter.
- Adjusted ModelManager to set auto_set_to_default_pipeline to False when creating models.
- Improved logic for setting the default pipeline model based on the new parameter.
2026-01-31 13:24:33 +08:00
Junyan Qin
3204292360 chore: bump version to 4.8.2 and update langbot-plugin and pyseekdb versions in uv.lock 2026-01-31 12:54:05 +08:00
Junyan Qin
e0d72969e3 chore(deps): update langbot-plugin version to 0.2.5 in pyproject.toml 2026-01-30 17:31:21 +08:00
Junyan Qin
a65b7ad413 chore(deps): update pyseekdb version to 1.0.0b7 in pyproject.toml 2026-01-30 13:39:36 +08:00
Junyan Qin
45df44e01b chore: update uv.lock 2026-01-30 12:42:21 +08:00
Junyan Qin
d8addb105a chore: update .gitignore and add uv.lock for dependency management 2026-01-30 12:32:39 +08:00
Junyan Qin
f17ccad665 chore: update TypeScript configuration for improved compatibility and structure 2026-01-30 12:15:19 +08:00
Junyan Qin
120ceb0b55 chore: update linting configuration to use eslint directly 2026-01-30 12:03:43 +08:00
dependabot[bot]
8a6f80a181 chore(deps): bump lodash from 4.17.21 to 4.17.23 in /web (#1944)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 11:25:16 +08:00
dependabot[bot]
b19e468668 chore(deps): bump next from 15.5.9 to 16.1.5 in /web (#1943)
Bumps [next](https://github.com/vercel/next.js) from 15.5.9 to 16.1.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.9...v16.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 11:20:08 +08:00
Junyan Qin
aeac79e1b3 feat: add tag filtering functionality to Plugin Market
- Introduced TagsFilter component for selecting and filtering plugins by tags.
- Updated PluginMarketComponent to handle tag selection and display.
- Enhanced PluginMarketCardComponent to show selected tags.
- Modified CloudServiceClient to fetch available tags from the API.
- Updated localization files to support new tag-related strings.
2026-01-29 16:08:05 +08:00
Junyan Qin
b89a240250 feat: implement LoadingSpinner component and replace existing loaders across the application 2026-01-29 15:24:23 +08:00
Junyan Qin
13f42857f5 perf: detailed control of models service displaying 2026-01-27 22:44:58 +08:00
Junyan Qin
61f3f31edc chore: bump version to 4.8.1 2026-01-27 20:33:55 +08:00
Junyan Qin
3663d9dc10 style: adjust margin in PipelineDetailDialog for improved button alignment 2026-01-27 20:33:17 +08:00
Guanchao Wang
89ec86c530 fix: issue 1936 (#1937) 2026-01-27 20:28:19 +08:00
43 changed files with 19041 additions and 888 deletions

1
.gitignore vendored
View File

@@ -42,7 +42,6 @@ botpy.log*
test.py
/web_ui
.venv/
uv.lock
/test
plugins.bak
coverage.xml

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.8.0"
version = "4.8.3"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -17,7 +17,7 @@ dependencies = [
"certifi>=2025.4.26",
"colorlog~=6.6.0",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"dashscope>=1.25.10",
"dingtalk-stream>=0.24.0",
"discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
@@ -63,8 +63,8 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb>=0.1.0",
"langbot-plugin==0.2.4",
"pyseekdb==1.0.0b7",
"langbot-plugin==0.2.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

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

View File

@@ -347,10 +347,15 @@ class DingTalkClient:
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
async def create_and_card(
self, temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False
self,
temp_card_id: str,
incoming_message: dingtalk_stream.ChatbotMessage,
quote_origin: bool = False,
card_auto_layout: bool = False,
):
content_key = 'content'
card_data = {content_key: ''}
card_data = {}
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)

View File

@@ -64,7 +64,9 @@ class LLMModelsService:
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
async def create_llm_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
async def create_llm_model(
self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True
) -> str:
"""Create a new LLM model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
@@ -95,18 +97,19 @@ class LLMModelsService:
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
if auto_set_to_default_pipeline:
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
)
)
)
pipeline = result.first()
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
pipeline = result.first()
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
return model_data['uuid']

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dingtalk_card_auto_layout', 41)
class DingTalkCardAutoLayoutMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False
await self.ap.platform_cfg.dump_config()

View File

@@ -231,7 +231,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_template_id = self.config['card_template_id']
incoming_message = event.source_platform_object.incoming_message
# message_id = incoming_message.message_id
card_instance, card_instance_id = await self.bot.create_and_card(card_template_id, incoming_message)
card_auto_layout = self.config.get('card_ auto_layout', False)
card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id, incoming_message, card_auto_layout=card_auto_layout
)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True

View File

@@ -56,6 +56,13 @@ spec:
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
type: boolean
required: false
default: false
- name: card_template_id
label:
en_US: card template id

View File

@@ -85,6 +85,26 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
)
)
if message.voice:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.voice.get_file()
file_bytes = None
file_format = message.voice.mime_type or 'audio/ogg'
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.Voice(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
length=message.voice.duration,
)
)
return platform_message.MessageChain(message_components)
@@ -159,7 +179,9 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
application = ApplicationBuilder().token(config['token']).build()
bot = application.bot
application.add_handler(MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO, telegram_callback))
application.add_handler(
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)
)
super().__init__(
config=config,
logger=logger,

View File

@@ -149,6 +149,7 @@ class ModelManager:
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
auto_set_to_default_pipeline=False,
)
elif space_model.category == 'embedding':

View File

@@ -25,7 +25,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -43,7 +43,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
# deepseek 不支持多模态把content都转换成纯文字
for m in messages:
if 'content' in m and isinstance(m['content'], list):
m['content'] = ' '.join([c['text'] for c in m['content']])
m['content'] = ' '.join([c['text'] for c in m['content'] if 'text' in c])
args['messages'] = messages
@@ -57,4 +57,11 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
# 处理请求结果
message = await self._make_msg(resp, remove_think)
return message
# Extract token usage from response
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return message, usage_info

View File

@@ -130,7 +130,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -162,7 +162,10 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
# 处理请求结果
message = await self._make_msg(resp)
return message
# ModelScope uses streaming, usage info not available
usage_info = {}
return message, usage_info
async def _req_stream(
self,

View File

@@ -26,7 +26,7 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -57,4 +57,11 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
# 处理请求结果
message = await self._make_msg(resp, remove_think)
return message
# Extract token usage from response
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return message, usage_info

View File

@@ -118,6 +118,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
stream=True, # 流式输出
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
session_id=query.session.using_conversation.uuid, # 会话ID用于多轮对话
enable_thinking=has_thoughts,
has_thoughts=has_thoughts,
# rag_options={ # 主要用于文件交互,暂不支持
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
@@ -141,14 +142,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 获取流式传输的output
stream_output = chunk.get('output', {})
stream_think = stream_output.get('thoughts', [])
if stream_think[0].get('thought'):
if stream_think and stream_think[0].get('thought'):
if not think_start:
think_start = True
pending_content += f'<think>\n{stream_think[0].get("thought")}'
else:
# 继续输出 reasoning_content
pending_content += stream_think[0].get('thought')
elif stream_think[0].get('thought') == '' and not think_end:
elif (not stream_think or stream_think[0].get('thought') == '') and not think_end:
think_end = True
pending_content += '\n</think>\n'
if stream_output.get('text') is not None:

View File

@@ -289,12 +289,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
yield msg
if chunk['event'] == 'message_file':
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
# 检查URL是否已经是完整的连接
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
image_url = chunk['url']
else:
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
image_url = base_url + chunk['url']
yield provider_message.Message(
role='assistant',
@@ -559,12 +563,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if chunk['event'] == 'message_file':
message_idx += 1
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
# 检查URL是否已经是完整的连接
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
image_url = chunk['url']
else:
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
image_url = base_url + chunk['url']
yield provider_message.MessageChunk(
role='assistant',

5801
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
"*.{js,jsx,ts,tsx}": ["next lint --fix --file", "next lint --file"],
"*.{js,jsx,ts,tsx}": ["eslint --fix"],
"**/*": ["bash -c 'cd \"$(pwd)\" && next build"]
}

11210
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
@@ -50,9 +51,9 @@
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"lucide-react": "^0.507.0",
"next": "~15.5.9",
"next": "~16.1.5",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",

1359
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import {
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
@@ -174,9 +175,7 @@ function SpaceOAuthCallbackContent() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{status === 'loading' && (
<Loader2 className="h-12 w-12 animate-spin text-primary" />
)}
{status === 'loading' && <LoadingSpinner size="lg" text="" />}
{status === 'confirm' && (
<>
<AlertTriangle className="h-12 w-12 text-yellow-500" />
@@ -232,7 +231,7 @@ function LoadingFallback() {
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardContent className="flex flex-col items-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<LoadingSpinner size="lg" text="" />
</CardContent>
</Card>
</div>

View File

@@ -17,7 +17,7 @@ import { Switch } from '@/components/ui/switch';
import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
import {
LLMModel,
Bot,
@@ -99,8 +99,11 @@ export default function DynamicFormItemComponent({
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
// Filter out space-chat-completions models when models service is disabled
if (systemInfo.disable_models_service) {
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);

View File

@@ -1,7 +1,7 @@
'use client';
import styles from './HomeSidebar.module.css';
import { useEffect, useState, Suspense } from 'react';
import { useEffect, useState } from 'react';
import {
SidebarChild,
SidebarChildVO,
@@ -20,7 +20,6 @@ import {
Lightbulb,
LogOut,
KeyRound,
Loader2,
} from 'lucide-react';
import { useTheme } from 'next-themes';
@@ -59,7 +58,7 @@ function compareVersions(v1: string, v2: string): boolean {
}
// TODO 侧边导航栏要加动画
function HomeSidebarContent({
export default function HomeSidebar({
onSelectedChangeAction,
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
@@ -484,25 +483,3 @@ function HomeSidebarContent({
</div>
);
}
function SidebarLoadingFallback() {
return (
<div className={`${styles.sidebarContainer}`}>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</div>
);
}
export default function HomeSidebar({
onSelectedChangeAction,
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
return (
<Suspense fallback={<SidebarLoadingFallback />}>
<HomeSidebarContent onSelectedChangeAction={onSelectedChangeAction} />
</Suspense>
);
}

View File

@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';
import { ExtraArg, ModelType, TestResult } from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
@@ -113,10 +114,15 @@ export default function ModelItem({
}
};
// Check if popover should be disabled (space models when not logged in)
const isPopoverDisabled =
isLangBotModels && userInfo?.account_type !== 'space';
return (
<Popover
open={isEditOpen}
open={isEditOpen && !isPopoverDisabled}
onOpenChange={(open) => {
if (isPopoverDisabled) return;
if (open) {
onOpenEditModel(model.uuid);
} else {
@@ -125,7 +131,13 @@ export default function ModelItem({
}}
>
<PopoverTrigger asChild>
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-background hover:bg-accent cursor-pointer">
<div
className={`flex items-center justify-between py-2 px-3 rounded-md border bg-background ${
isPopoverDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-accent cursor-pointer'
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">

View File

@@ -14,7 +14,7 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
import {
Select,
SelectContent,
@@ -101,8 +101,11 @@ export default function KBForm({
const getEmbeddingModelNameList = async () => {
const resp = await httpClient.getProviderEmbeddingModels();
let models = resp.models;
// Filter out space-chat-completions models when models service is disabled
if (systemInfo.disable_models_service) {
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);

View File

@@ -3,9 +3,16 @@
import styles from './layout.module.css';
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
import React, { useState, useCallback, useMemo } from 'react';
import React, {
useState,
useCallback,
useMemo,
useEffect,
Suspense,
} from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { I18nObject } from '@/app/infra/entities/common';
import { userInfo, initializeUserInfo } from '@/app/infra/http';
export default function HomeLayout({
children,
@@ -19,6 +26,13 @@ export default function HomeLayout({
zh_Hans: '',
});
// Initialize user info if not already initialized
useEffect(() => {
if (!userInfo) {
initializeUserInfo();
}
}, []);
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
setTitle(child.name);
setSubtitle(child.description);
@@ -31,7 +45,9 @@ export default function HomeLayout({
return (
<div className={styles.homeLayoutContainer}>
<aside className={styles.sidebar}>
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
<Suspense fallback={<div />}>
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
</Suspense>
</aside>
<div className={styles.main}>

View File

@@ -0,0 +1,9 @@
import { LoadingSpinner } from '@/components/ui/loading-spinner';
export default function Loading() {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { MessageDetailsCard } from './components/MessageDetailsCard';
import { MessageContentRenderer } from './components/MessageContentRenderer';
import { MessageDetails } from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
interface RawMessageData {
id: string;
@@ -262,11 +263,10 @@ function MonitoringPageContent() {
<TabsContent value="messages" className="p-6 m-0">
<div>
{loading && (
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
<div className="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 dark:border-blue-400 mb-4"></div>
<p className="text-sm font-medium">
{t('monitoring.messageList.loading')}
</p>
<div className="py-12 flex justify-center">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
@@ -363,8 +363,8 @@ function MonitoringPageContent() {
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="text-center text-gray-500 dark:text-gray-400 py-4">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div>
<div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" />
</div>
)}
{!loadingDetails[msg.id] &&
@@ -410,9 +410,8 @@ function MonitoringPageContent() {
<TabsContent value="modelCalls" className="p-6 m-0">
<div>
{loading && (
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
<div className="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 dark:border-blue-400 mb-4"></div>
<p className="text-sm font-medium">{t('common.loading')}</p>
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
@@ -629,9 +628,8 @@ function MonitoringPageContent() {
<TabsContent value="errors" className="p-6 m-0">
<div>
{loading && (
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
<div className="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 dark:border-blue-400 mb-4"></div>
<p className="text-sm font-medium">{t('common.loading')}</p>
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
@@ -810,7 +808,7 @@ function MonitoringPageContent() {
export default function MonitoringPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<LoadingPage />}>
<MonitoringPageContent />
</Suspense>
);

View File

@@ -7,7 +7,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Sidebar,
SidebarContent,
@@ -21,6 +20,7 @@ import {
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;
@@ -34,7 +34,7 @@ interface PipelineDialogProps {
onCancel: () => void;
}
type DialogMode = 'config' | 'debug' | 'extensions';
type DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';
export default function PipelineDialog({
open,
@@ -111,6 +111,19 @@ export default function PipelineDialog({
</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 = () => {
@@ -122,6 +135,9 @@ export default function PipelineDialog({
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
if (currentMode === 'monitoring') {
return t('pipelines.monitoring.title');
}
return t('pipelines.debugDialog.title');
};
@@ -193,48 +209,23 @@ export default function PipelineDialog({
>
<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
<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>
</div>
<div className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => {
router.push(
`/home/monitoring?pipelineId=${pipelineId}`,
);
onOpenChange(false);
}}
className="bg-white dark:bg-[#2a2a2e]"
>
<svg
className="w-4 h-4 mr-2"
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>
{t('monitoring.viewMonitoring')}
</Button>
</div>
</>
: 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
@@ -268,6 +259,16 @@ export default function PipelineDialog({
onConnectionStatusChange={setIsWebSocketConnected}
/>
)}
{currentMode === 'monitoring' && pipelineId && (
<PipelineMonitoringTab
pipelineId={pipelineId}
onNavigateToMonitoring={() => {
router.push(`/home/monitoring?pipelineId=${pipelineId}`);
onOpenChange(false);
}}
/>
)}
</div>
</main>
</SidebarProvider>

View File

@@ -0,0 +1,665 @@
'use client';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
interface PipelineMonitoringTabProps {
pipelineId: string;
onNavigateToMonitoring?: () => void;
}
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
export default function PipelineMonitoringTab({
pipelineId,
onNavigateToMonitoring,
}: PipelineMonitoringTabProps) {
const { t } = useTranslation();
// Filter state - only show data for this pipeline, last 24 hours
const filterState = useMemo(
() => ({
selectedBots: [],
selectedPipelines: [pipelineId],
timeRange: 'last24Hours' as const,
customDateRange: null,
}),
[pipelineId],
);
const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
);
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('messages');
const toggleMessageExpand = async (messageId: string) => {
if (expandedMessageId === messageId) {
setExpandedMessageId(null);
} else {
setExpandedMessageId(messageId);
if (!messageDetails[messageId]) {
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
try {
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: new Date(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
pipelineName: result.message.pipeline_name,
messageContent: result.message.message_content,
sessionId: result.message.session_id,
status: result.message.status,
level: result.message.level,
platform: result.message.platform,
userId: result.message.user_id,
runnerName: result.message.runner_name,
variables: result.message.variables,
}
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
errorMessage: call.error_message,
tokens: {
input: call.input_tokens || 0,
output: call.output_tokens || 0,
total: call.total_tokens || 0,
},
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,
})),
llmStats: {
totalCalls: result.llm_stats.total_calls,
totalInputTokens: result.llm_stats.total_input_tokens,
totalOutputTokens: result.llm_stats.total_output_tokens,
totalTokens: result.llm_stats.total_tokens,
totalDurationMs: result.llm_stats.total_duration_ms,
averageDurationMs: result.llm_stats.average_duration_ms,
},
} as MessageDetails,
}));
}
} catch (error) {
console.error('Failed to fetch message details:', error);
} finally {
setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));
}
}
}
};
const toggleErrorExpand = (errorId: string) => {
if (expandedErrorId === errorId) {
setExpandedErrorId(null);
} else {
setExpandedErrorId(errorId);
}
};
const jumpToMessage = async (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab transition completes before expanding
setTimeout(() => {
toggleMessageExpand(messageId);
}, 100);
};
return (
<div className="w-full h-full flex flex-col">
{/* Header with refresh button */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('pipelines.monitoring.description')}
</p>
<div className="flex items-center gap-2">
{onNavigateToMonitoring && (
<Button
variant="outline"
size="sm"
onClick={onNavigateToMonitoring}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19V6.413L11.2071 14.2071L9.79289 12.7929L17.585 5H13V3H21Z"></path>
</svg>
{t('pipelines.monitoring.detailedLogs')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={refetch}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
{t('monitoring.refreshData')}
</Button>
</div>
</div>
{/* Overview Stats */}
{data && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.totalMessages')}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{data.overview.totalMessages}
</div>
</div>
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.successRate')}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{data.overview.successRate.toFixed(1)}%
</div>
</div>
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.tabs.errors')}
</div>
<div className="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">
{data.errors.length}
</div>
</div>
</div>
)}
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="bg-gray-100 dark:bg-[#1a1a1e] h-10 p-1 mb-4">
<TabsTrigger
value="messages"
className="px-4 py-1.5 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')}
</TabsTrigger>
<TabsTrigger
value="errors"
className="px-4 py-1.5 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')}
</TabsTrigger>
<TabsTrigger
value="llmCalls"
className="px-4 py-1.5 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')}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto min-h-0">
{/* Messages Tab */}
<TabsContent value="messages" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('monitoring.messageList.loading')} />
</div>
)}
{!loading && data && data.messages && data.messages.length > 0 && (
<div className="space-y-3">
{data.messages
.filter((msg) => {
const content = msg.messageContent?.trim();
return content && content !== '[]' && content !== '""';
})
.map((msg) => (
<div
key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs px-2 py-0.5 rounded ${
msg.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: msg.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{msg.status}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{msg.botName}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
<MessageContentRenderer
content={msg.messageContent}
/>
</div>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{msg.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="flex justify-center py-8">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<div className="space-y-4">
{messageDetails[msg.id].errors.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
{messageDetails[msg.id].errors.map(
(error) => (
<div
key={error.id}
className="text-sm space-y-2"
>
<div className="text-red-600 dark:text-red-400">
{error.errorType}:{' '}
{error.errorMessage}
</div>
{error.stackTrace && (
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
)}
</div>
),
)}
</div>
)}
{messageDetails[msg.id].llmCalls.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
{t('monitoring.tabs.modelCalls')} (
{messageDetails[msg.id].llmCalls.length})
</h4>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{
messageDetails[msg.id].llmStats
.totalTokens
}
</div>
<div>
{t('monitoring.llmCalls.duration')}:{' '}
{messageDetails[
msg.id
].llmStats.totalDurationMs.toFixed(0)}
ms
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
</TabsContent>
{/* Errors Tab */}
<TabsContent value="errors" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.errors && data.errors.length > 0 && (
<div className="space-y-3">
{data.errors.map((error) => (
<div
key={error.id}
className="border border-red-200 dark:border-red-900 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedErrorId === error.id ? (
<ChevronDown className="w-4 h-4 text-red-500" />
) : (
<ChevronRight className="w-4 h-4 text-red-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{error.messageId && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
jumpToMessage(error.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t('monitoring.messageList.viewConversation')}
</Button>
)}
</div>
<div className="font-medium text-sm text-red-700 dark:text-red-300 mb-1">
{error.errorType}
</div>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{error.errorMessage}
</p>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{error.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedErrorId === error.id && (
<div className="border-t border-red-200 dark:border-red-900 p-4 bg-white dark:bg-gray-900">
<div className="space-y-3">
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
{error.errorMessage}
</div>
</div>
{error.stackTrace && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('monitoring.errors.stackTrace')}
</h4>
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-base font-medium text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
</div>
)}
</TabsContent>
{/* LLM Calls Tab */}
<TabsContent value="llmCalls" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.llmCalls && data.llmCalls.length > 0 && (
<div className="space-y-3">
{data.llmCalls.map((call) => (
<div
key={call.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-all duration-200"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs px-2 py-0.5 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
{call.modelName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex flex-wrap gap-4">
<span>
{t('monitoring.llmCalls.inputTokens')}:{' '}
{call.tokens.input}
</span>
<span>
{t('monitoring.llmCalls.outputTokens')}:{' '}
{call.tokens.output}
</span>
<span>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{call.tokens.total}
</span>
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
))}
</div>
)}
{!loading &&
(!data || !data.llmCalls || data.llmCalls.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.llmCalls.noData')}
</p>
</div>
)}
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -11,14 +10,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
Loader2,
Wrench,
AudioWaveform,
Hash,
Book,
} from 'lucide-react';
import { Search, Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -27,6 +19,9 @@ import { PluginV4 } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface SortOption {
value: string;
@@ -42,10 +37,12 @@ function MarketPageContent({
installPlugin: (plugin: PluginV4) => void;
}) {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>('all');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -111,6 +108,7 @@ function MarketPageContent({
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}, []);
@@ -128,7 +126,7 @@ function MarketPageContent({
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
// Always use searchMarketplacePlugins to support component filtering
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
@@ -137,6 +135,7 @@ function MarketPageContent({
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
);
const data: ApiRespMarketplacePlugins = response;
@@ -165,6 +164,7 @@ function MarketPageContent({
[
searchQuery,
componentFilter,
selectedTags,
pageSize,
transformToVO,
plugins.length,
@@ -175,8 +175,34 @@ function MarketPageContent({
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
fetchAvailableTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取可用标签
const fetchAvailableTags = async () => {
try {
const response = await getCloudServiceClientSync().getAllTags();
const tags = response.tags || [];
setAvailableTags(tags);
// Build tag names map for all components to use
const nameMap: Record<string, string> = {};
tags.forEach((tag: PluginTag) => {
const displayName = {
en_US: tag.display_name.en_US || tag.tag,
zh_Hans: tag.display_name.zh_Hans || tag.tag,
zh_Hant: tag.display_name.zh_Hant,
ja_JP: tag.display_name.ja_JP,
};
nameMap[tag.tag] = extractI18nObject(displayName);
});
setTagNames(nameMap);
} catch (error) {
console.error('Failed to fetch tags:', error);
}
};
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
@@ -227,16 +253,19 @@ function MarketPageContent({
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
// 处理URL参数重定向到 LangBot Space
// Tags 筛选变化时重新搜索
useEffect(() => {
const author = searchParams.get('author');
const pluginName = searchParams.get('plugin');
if (author && pluginName) {
const detailUrl = `https://space.langbot.app/market/${author}/${pluginName}`;
window.open(detailUrl, '_blank');
if (!isLoading) {
setCurrentPage(1);
fetchPlugins(1, searchQuery.trim() !== '', true);
}
}, [searchParams]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTags]);
// 处理 tags 变化
const handleTagsChange = useCallback((tags: string[]) => {
setSelectedTags(tags);
}, []);
// 处理安装插件
const handleInstallPlugin = useCallback(
@@ -342,8 +371,8 @@ function MarketPageContent({
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box */}
<div className="flex items-center justify-center">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
@@ -362,6 +391,13 @@ function MarketPageContent({
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
@@ -460,8 +496,7 @@ function MarketPageContent({
>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
<LoadingSpinner text={t('market.loading')} />
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
@@ -477,6 +512,7 @@ function MarketPageContent({
key={plugin.pluginId}
cardVO={plugin}
onInstall={handleInstallPlugin}
tagNames={tagNames}
/>
))}
</div>
@@ -484,8 +520,7 @@ function MarketPageContent({
{/* Loading more indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
<LoadingSpinner size="sm" text={t('market.loadingMore')} />
</div>
)}
@@ -522,8 +557,7 @@ export default function MarketPage({
fallback={
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">...</span>
<LoadingSpinner text="加载中..." />
</div>
</div>
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectGroup,
SelectTrigger,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Tag as TagIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface TagsFilterProps {
availableTags: PluginTag[];
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
}
export function TagsFilter({
availableTags,
selectedTags,
onTagsChange,
}: TagsFilterProps) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const handleTagToggle = (tag: string) => {
const newTags = selectedTags.includes(tag)
? selectedTags.filter((t) => t !== tag)
: [...selectedTags, tag];
onTagsChange(newTags);
};
const handleClearAll = () => {
onTagsChange([]);
};
const extractI18nObject = (obj: { zh_Hans?: string; en_US?: string }) => {
const lang = i18n.language || 'en_US';
return obj[lang as keyof typeof obj] || obj.zh_Hans || obj.en_US || '';
};
return (
<Select open={open} onOpenChange={setOpen}>
<SelectTrigger className="w-[140px]">
<div className="flex items-center gap-2 w-full">
<TagIcon className="h-4 w-4 flex-shrink-0" />
{selectedTags.length === 0 ? (
<span className="text-muted-foreground truncate text-sm">
{t('market.tags.filterByTags')}
</span>
) : (
<span className="text-sm truncate">
{selectedTags.length} {t('market.tags.selected')}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="w-[240px]">
<SelectGroup>
<div className="px-2 py-1.5 flex items-center justify-between border-b">
<span className="text-sm font-medium">
{t('market.tags.selectTags')}
</span>
{selectedTags.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-auto p-0 text-xs hover:bg-transparent hover:text-destructive"
>
{t('market.tags.clearAll')}
</Button>
)}
</div>
{availableTags.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
{t('market.tags.noTags')}
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{availableTags.map((tag) => (
<div
key={tag.tag}
className="flex items-center space-x-2 px-2 py-2 hover:bg-accent cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleTagToggle(tag.tag);
}}
>
<Checkbox
id={`tag-${tag.tag}`}
checked={selectedTags.includes(tag.tag)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => handleTagToggle(tag.tag)}
/>
<Label
htmlFor={`tag-${tag.tag}`}
className="text-sm font-normal cursor-pointer flex-1"
onClick={(e) => e.preventDefault()}
>
{extractI18nObject(tag.display_name)}
</Label>
</div>
))}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@@ -15,9 +15,11 @@ import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
cardVO,
onInstall,
tagNames = {},
}: {
cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void;
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -42,13 +44,6 @@ export default function PluginMarketCardComponent({
KnowledgeRetriever: <Book className="w-4 h-4" />,
};
const componentKindNameMap: Record<string, string> = {
Tool: t('plugins.componentName.Tool'),
EventListener: t('plugins.componentName.EventListener'),
Command: t('plugins.componentName.Command'),
KnowledgeRetriever: t('plugins.componentName.KnowledgeRetriever'),
};
return (
<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"
@@ -97,24 +92,63 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
{/* 下部分:下载量、标签和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
</div>
{/* Tags */}
{cardVO.tags && cardVO.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{cardVO.tags.slice(0, 2).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
>
<svg
className="w-2.5 h-2.5 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<span className="truncate">{tagNames[tag] || tag}</span>
</Badge>
))}
{cardVO.tags.length > 2 && (
<Badge
variant="outline"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
>
+{cardVO.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
{/* 组件列表 */}
@@ -127,10 +161,6 @@ export default function PluginMarketCardComponent({
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
<span className="hidden md:inline">
{componentKindNameMap[kind]}
</span>
<span className="ml-1">{count}</span>
</Badge>
))}

View File

@@ -9,6 +9,7 @@ export interface IPluginMarketCardVO {
githubURL: string;
version: string;
components?: Record<string, number>;
tags?: string[];
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
installCount: number;
version: string;
components?: Record<string, number>;
tags?: string[];
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -34,5 +36,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
}
}

View File

@@ -35,6 +35,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by?: string,
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
): Promise<ApiRespMarketplacePlugins> {
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
@@ -45,6 +46,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
tags_filter,
},
);
}
@@ -92,6 +94,20 @@ export class CloudServiceClient extends BaseHttpClient {
public getLangBotReleases(): Promise<GitHubRelease[]> {
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
}
public getAllTags(): Promise<{ tags: PluginTag[] }> {
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
}
}
export interface PluginTag {
tag: string;
display_name: {
zh_Hans?: string;
en_US?: string;
zh_Hant?: string;
ja_JP?: string;
};
}
export interface GitHubRelease {

View File

@@ -12,6 +12,13 @@ export let systemInfo: ApiRespSystemInfo = {
disable_models_service: false,
};
// 用户信息
export let userInfo: {
user: string;
account_type: 'local' | 'space';
has_password: boolean;
} | null = null;
/**
* 获取基础 URL
*/
@@ -24,6 +31,8 @@ const getBaseURL = (): string => {
// 创建后端客户端实例
export const backendClient = new BackendClient(getBaseURL());
// 为了兼容性,也导出为 httpClient
export const httpClient = backendClient;
// 创建云服务客户端实例(初始化时使用默认 URL
export const cloudServiceClient = new CloudServiceClient(
@@ -82,6 +91,27 @@ export const initializeSystemInfo = async (): Promise<void> => {
}
};
/**
* 初始化用户信息
* 应该在用户登录后调用此方法
*/
export const initializeUserInfo = async (): Promise<void> => {
try {
userInfo = await backendClient.getUserInfo();
} catch (error) {
console.error('Failed to initialize user info:', error);
userInfo = null;
}
};
/**
* 清除用户信息
* 应该在用户登出时调用此方法
*/
export const clearUserInfo = (): void => {
userInfo = null;
};
// 导出类型,以便其他地方使用
export type { ResponseData, RequestConfig } from './BaseHttpClient';
export { BaseHttpClient } from './BaseHttpClient';

View File

@@ -21,7 +21,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { httpClient, initializeUserInfo } from '@/app/infra/http';
import { useRouter } from 'next/navigation';
import { Mail, Lock, Loader2 } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -29,6 +29,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
const formSchema = (t: (key: string) => string) =>
z.object({
@@ -95,9 +96,10 @@ export default function Login() {
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
.then((res) => {
.then(async (res) => {
localStorage.setItem('token', res.token);
localStorage.setItem('userEmail', username);
await initializeUserInfo();
router.push('/home');
toast.success(t('common.loginSuccess'));
})
@@ -122,7 +124,7 @@ export default function Login() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<LoadingSpinner />
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
/**
* Size variant of the spinner
* @default 'default'
*/
size?: 'sm' | 'default' | 'lg';
/**
* Additional CSS classes
*/
className?: string;
/**
* Loading text to display below the spinner
*/
text?: string;
/**
* Whether to display as full page overlay
* @default false
*/
fullPage?: boolean;
}
const sizeMap = {
sm: 'h-4 w-4',
default: 'h-8 w-8',
lg: 'h-12 w-12',
};
const textSizeMap = {
sm: 'text-xs',
default: 'text-sm',
lg: 'text-base',
};
export function LoadingSpinner({
size = 'default',
className,
text = '加载中...',
fullPage = false,
}: LoadingSpinnerProps) {
const spinner = (
<div className="flex flex-col items-center gap-4">
<Loader2
className={cn('animate-spin text-primary', sizeMap[size], className)}
/>
{text && (
<p className={cn('text-muted-foreground', textSizeMap[size])}>{text}</p>
)}
</div>
);
if (fullPage) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
{spinner}
</div>
);
}
return spinner;
}
/**
* Full page loading component for use in page.tsx or layout.tsx
*/
export function LoadingPage({ text }: { text?: string }) {
return <LoadingSpinner fullPage text={text} />;
}
/**
* Inline loading component for use within components
*/
export function LoadingInline({
size,
text,
}: {
size?: 'sm' | 'default' | 'lg';
text?: string;
}) {
return <LoadingSpinner size={size} text={text} />;
}

View File

@@ -446,7 +446,7 @@ const enUS = {
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
description: 'Description',
tags: 'Tags',
tagLabel: 'Tags',
submissionTitle: 'You have a plugin submission under review: {{name}}',
submissionPending: 'Your plugin submission is under review: {{name}}',
submissionApproved: 'Your plugin submission has been approved: {{name}}',
@@ -462,6 +462,13 @@ const enUS = {
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
selectTags: 'Select Tags',
clearAll: 'Clear All',
noTags: 'No tags available',
},
},
mcp: {
title: 'MCP',
@@ -637,6 +644,12 @@ const enUS = {
showMarkdown: 'Show Markdown',
showRaw: 'Show Raw',
},
monitoring: {
title: 'Monitoring',
description:
'View execution logs and errors for this pipeline (last 24 hours)',
detailedLogs: 'Detailed Logs',
},
},
knowledge: {
title: 'Knowledge',

View File

@@ -447,7 +447,7 @@ const jaJP = {
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
description: '説明',
tags: 'タグ',
tagLabel: 'タグ',
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
submissionPending: 'プラグインの提出が審査中です: {{name}}',
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
@@ -462,6 +462,13 @@ const jaJP = {
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
selected: '選択済み',
selectTags: 'タグを選択',
clearAll: 'クリア',
noTags: 'タグがありません',
},
viewDetails: '詳細を表示',
},
mcp: {
@@ -640,6 +647,11 @@ const jaJP = {
showMarkdown: 'Markdownで表示',
showRaw: '原文で表示',
},
monitoring: {
title: 'モニタリング',
description: 'このパイプラインの実行ログとエラー情報を表示過去24時間',
detailedLogs: '詳細ログ',
},
},
knowledge: {
title: '知識ベース',

View File

@@ -425,7 +425,7 @@ const zhHans = {
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
description: '描述',
tags: '标签',
tagLabel: '标签',
submissionTitle: '您有插件提交正在审核中: {{name}}',
submissionApproved: '您的插件提交已通过审核: {{name}}',
submissionRejected: '您的插件提交已被拒绝: {{name}}',
@@ -439,6 +439,13 @@ const zhHans = {
filterByComponent: '组件',
allComponents: '全部组件',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
selected: '已选',
selectTags: '选择标签',
clearAll: '清空',
noTags: '暂无标签',
},
viewDetails: '查看详情',
},
mcp: {
@@ -613,6 +620,11 @@ const zhHans = {
showMarkdown: '渲染',
showRaw: '原文',
},
monitoring: {
title: '监控日志',
description: '查看此流水线的运行记录和错误信息最近24小时',
detailedLogs: '详细日志',
},
},
knowledge: {
title: '知识库',

View File

@@ -418,7 +418,7 @@ const zhHant = {
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
description: '描述',
tags: '標籤',
tagLabel: '標籤',
submissionTitle: '您有插件提交正在審核中: {{name}}',
submissionApproved: '您的插件提交已通過審核: {{name}}',
submissionRejected: '您的插件提交已被拒絕: {{name}}',
@@ -432,6 +432,13 @@ const zhHant = {
filterByComponent: '組件',
allComponents: '全部組件',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
selected: '已選',
selectTags: '選擇標籤',
clearAll: '清空',
noTags: '暫無標籤',
},
viewDetails: '查看詳情',
},
mcp: {
@@ -606,6 +613,11 @@ const zhHant = {
showMarkdown: '渲染',
showRaw: '原文',
},
monitoring: {
title: '監控日誌',
description: '檢視此流程線的執行記錄和錯誤資訊最近24小時',
detailedLogs: '詳細日誌',
},
},
knowledge: {
title: '知識庫',

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}