mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d55b382d | ||
|
|
8c17e55913 | ||
|
|
af509fe61f | ||
|
|
87e2a2099a | ||
|
|
3f22f62332 | ||
|
|
d1ee5f931a | ||
|
|
35506dd2bb | ||
|
|
2f06321ebf | ||
|
|
023281ae56 | ||
|
|
50dff55217 | ||
|
|
3204292360 | ||
|
|
e0d72969e3 | ||
|
|
a65b7ad413 | ||
|
|
45df44e01b | ||
|
|
d8addb105a | ||
|
|
f17ccad665 | ||
|
|
120ceb0b55 | ||
|
|
8a6f80a181 | ||
|
|
b19e468668 | ||
|
|
aeac79e1b3 | ||
|
|
b89a240250 | ||
|
|
13f42857f5 | ||
|
|
61f3f31edc | ||
|
|
3663d9dc10 | ||
|
|
89ec86c530 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,7 +42,6 @@ botpy.log*
|
||||
test.py
|
||||
/web_ui
|
||||
.venv/
|
||||
uv.lock
|
||||
/test
|
||||
plugins.bak
|
||||
coverage.xml
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.8.0'
|
||||
__version__ = '4.8.3'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
11210
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
1359
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
9
web/src/app/home/loading.tsx
Normal file
9
web/src/app/home/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
117
web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
Normal file
117
web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
web/src/components/ui/loading-spinner.tsx
Normal file
83
web/src/components/ui/loading-spinner.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '知識ベース',
|
||||
|
||||
@@ -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: '知识库',
|
||||
|
||||
@@ -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: '知識庫',
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user