mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 22:36:02 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2a89d9db | ||
|
|
c91e2f0efe | ||
|
|
411d082d2a | ||
|
|
d4e08a1765 | ||
|
|
b529d07479 | ||
|
|
d44df75e5c | ||
|
|
b74e07b608 | ||
|
|
4a868afecd | ||
|
|
1cb9560663 | ||
|
|
8f878673ae | ||
|
|
74a5e37892 | ||
|
|
76a69ecc7e | ||
|
|
f06e3d3efa | ||
|
|
973e7bae42 | ||
|
|
94aa175c1a |
@@ -463,7 +463,17 @@ class WecomBotClient:
|
|||||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||||
message_data['picurl'] = base64 # 只保留第一个 image
|
message_data['picurl'] = base64 # 只保留第一个 image
|
||||||
|
|
||||||
message_data['userid'] = msg_json.get('from', {}).get('userid', '')
|
# Extract user information
|
||||||
|
from_info = msg_json.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
# Extract chat/group information
|
||||||
|
if msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['chatid'] = msg_json.get('chatid', '')
|
||||||
|
# Try to get group name if available
|
||||||
|
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||||
|
|
||||||
message_data['msgid'] = msg_json.get('msgid', '')
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
|
|||||||
@@ -22,7 +22,21 @@ class WecomBotEvent(dict):
|
|||||||
"""
|
"""
|
||||||
用户id
|
用户id
|
||||||
"""
|
"""
|
||||||
return self.get('from', {}).get('userid', '')
|
return self.get('from', {}).get('userid', '') or self.get('userid', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def username(self) -> str:
|
||||||
|
"""
|
||||||
|
用户名称
|
||||||
|
"""
|
||||||
|
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chatname(self) -> str:
|
||||||
|
"""
|
||||||
|
群组名称
|
||||||
|
"""
|
||||||
|
return self.get('chatname', '') or str(self.chatid)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def content(self) -> str:
|
def content(self) -> str:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import base64
|
|||||||
import quart
|
import quart
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
|
||||||
from .....core import taskmgr
|
from .....core import taskmgr
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -269,3 +271,39 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return self.success(data={'task_id': wrapper.id})
|
return self.success(data={'task_id': wrapper.id})
|
||||||
|
|
||||||
|
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Upload a file for plugin configuration"""
|
||||||
|
file = (await quart.request.files).get('file')
|
||||||
|
if file is None:
|
||||||
|
return self.http_status(400, -1, 'file is required')
|
||||||
|
|
||||||
|
# Check file size (10MB limit)
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
file_bytes = file.read()
|
||||||
|
if len(file_bytes) > MAX_FILE_SIZE:
|
||||||
|
return self.http_status(400, -1, 'file size exceeds 10MB limit')
|
||||||
|
|
||||||
|
# Generate unique file key with original extension
|
||||||
|
original_filename = file.filename
|
||||||
|
_, ext = os.path.splitext(original_filename)
|
||||||
|
file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
|
||||||
|
|
||||||
|
# Save file using storage manager
|
||||||
|
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||||
|
|
||||||
|
return self.success(data={'file_key': file_key})
|
||||||
|
|
||||||
|
@self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(file_key: str) -> str:
|
||||||
|
"""Delete a plugin configuration file"""
|
||||||
|
# Only allow deletion of files with plugin_config_ prefix for security
|
||||||
|
if not file_key.startswith('plugin_config_'):
|
||||||
|
return self.http_status(400, -1, 'invalid file key')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.storage_mgr.storage_provider.delete(file_key)
|
||||||
|
return self.success(data={'deleted': True})
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'failed to delete file: {str(e)}')
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....provider.modelmgr import requester as model_requester
|
from ....provider.modelmgr import requester as model_requester
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
|
||||||
|
|
||||||
|
|
||||||
class LLMModelsService:
|
class LLMModelsService:
|
||||||
@@ -104,12 +105,17 @@ class LLMModelsService:
|
|||||||
else:
|
else:
|
||||||
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
|
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
|
||||||
|
|
||||||
|
# 有些模型厂商默认开启了思考功能,测试容易延迟
|
||||||
|
extra_args = model_data.get('extra_args', {})
|
||||||
|
if not extra_args or 'thinking' not in extra_args:
|
||||||
|
extra_args['thinking'] = {'type': 'disabled'}
|
||||||
|
|
||||||
await runtime_llm_model.requester.invoke_llm(
|
await runtime_llm_model.requester.invoke_llm(
|
||||||
query=None,
|
query=None,
|
||||||
model=runtime_llm_model,
|
model=runtime_llm_model,
|
||||||
messages=[provider_message.Message(role='user', content='Hello, world!')],
|
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
|
||||||
funcs=[],
|
funcs=[],
|
||||||
extra_args=model_data.get('extra_args', {}),
|
extra_args=extra_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,93 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app
|
||||||
from ..bootutils import config
|
from ..bootutils import config
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||||
|
"""Apply environment variable overrides to data/config.yaml
|
||||||
|
|
||||||
|
Environment variables should be uppercase and use __ (double underscore)
|
||||||
|
to represent nested keys. For example:
|
||||||
|
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
|
||||||
|
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
|
||||||
|
|
||||||
|
Arrays and dict types are ignored.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Configuration dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated configuration dictionary
|
||||||
|
"""
|
||||||
|
|
||||||
|
def convert_value(value: str, original_value: Any) -> Any:
|
||||||
|
"""Convert string value to appropriate type based on original value
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: String value from environment variable
|
||||||
|
original_value: Original value to infer type from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Converted value (falls back to string if conversion fails)
|
||||||
|
"""
|
||||||
|
if isinstance(original_value, bool):
|
||||||
|
return value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
elif isinstance(original_value, int):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, keep as string (user error, but non-breaking)
|
||||||
|
return value
|
||||||
|
elif isinstance(original_value, float):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, keep as string (user error, but non-breaking)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Process environment variables
|
||||||
|
for env_key, env_value in os.environ.items():
|
||||||
|
# Check if the environment variable is uppercase and contains __
|
||||||
|
if not env_key.isupper():
|
||||||
|
continue
|
||||||
|
if '__' not in env_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}')
|
||||||
|
|
||||||
|
# Convert environment variable name to config path
|
||||||
|
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
|
||||||
|
keys = [key.lower() for key in env_key.split('__')]
|
||||||
|
|
||||||
|
# Navigate to the target value and validate the path
|
||||||
|
current = cfg
|
||||||
|
|
||||||
|
for i, key in enumerate(keys):
|
||||||
|
if not isinstance(current, dict) or key not in current:
|
||||||
|
break
|
||||||
|
|
||||||
|
if i == len(keys) - 1:
|
||||||
|
# At the final key - check if it's a scalar value
|
||||||
|
if isinstance(current[key], (dict, list)):
|
||||||
|
# Skip dict and list types
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Valid scalar value - convert and set it
|
||||||
|
converted_value = convert_value(env_value, current[key])
|
||||||
|
current[key] = converted_value
|
||||||
|
else:
|
||||||
|
# Navigate deeper
|
||||||
|
current = current[key]
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@stage.stage_class('LoadConfigStage')
|
@stage.stage_class('LoadConfigStage')
|
||||||
class LoadConfigStage(stage.BootingStage):
|
class LoadConfigStage(stage.BootingStage):
|
||||||
"""Load config file stage"""
|
"""Load config file stage"""
|
||||||
@@ -54,6 +136,10 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
ap.instance_config = await config.load_yaml_config(
|
ap.instance_config = await config.load_yaml_config(
|
||||||
'data/config.yaml', 'templates/config.yaml', completion=False
|
'data/config.yaml', 'templates/config.yaml', completion=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply environment variable overrides to data/config.yaml
|
||||||
|
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
|
||||||
|
|
||||||
await ap.instance_config.dump_config()
|
await ap.instance_config.dump_config()
|
||||||
|
|
||||||
ap.sensitive_meta = await config.load_json_config(
|
ap.sensitive_meta = await config.load_json_config(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.userid,
|
id=event.userid,
|
||||||
nickname='',
|
nickname=event.username,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
@@ -61,10 +61,10 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
sender = platform_entities.GroupMember(
|
sender = platform_entities.GroupMember(
|
||||||
id=event.userid,
|
id=event.userid,
|
||||||
permission='MEMBER',
|
permission='MEMBER',
|
||||||
member_name=event.userid,
|
member_name=event.username,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=str(event.chatid),
|
id=str(event.chatid),
|
||||||
name='',
|
name=event.chatname,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
special_title='',
|
special_title='',
|
||||||
|
|||||||
@@ -436,6 +436,25 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.action(RuntimeToLangBotAction.GET_CONFIG_FILE)
|
||||||
|
async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Get a config file by file key"""
|
||||||
|
file_key = data['file_key']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load file from storage
|
||||||
|
file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key)
|
||||||
|
|
||||||
|
return handler.ActionResponse.success(
|
||||||
|
data={
|
||||||
|
'file_base64': base64.b64encode(file_bytes).decode('utf-8'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Failed to load config file {file_key}: {e}',
|
||||||
|
)
|
||||||
|
|
||||||
async def ping(self) -> dict[str, Any]:
|
async def ping(self) -> dict[str, Any]:
|
||||||
"""Ping the runtime"""
|
"""Ping the runtime"""
|
||||||
return await self.call_action(
|
return await self.call_action(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
semantic_version = 'v4.4.0'
|
semantic_version = 'v4.4.1'
|
||||||
|
|
||||||
required_database_version = 8
|
required_database_version = 8
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.4.0"
|
version = "4.4.1"
|
||||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10.1,<4.0"
|
requires-python = ">=3.10.1,<4.0"
|
||||||
@@ -63,7 +63,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=0.4.24",
|
"chromadb>=0.4.24",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.1.6",
|
"langbot-plugin==0.1.8",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
1
tests/unit_tests/config/__init__.py
Normal file
1
tests/unit_tests/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Config unit tests
|
||||||
332
tests/unit_tests/config/test_env_override.py
Normal file
332
tests/unit_tests/config/test_env_override.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
Tests for environment variable override functionality in YAML config
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||||
|
"""Apply environment variable overrides to data/config.yaml
|
||||||
|
|
||||||
|
Environment variables should be uppercase and use __ (double underscore)
|
||||||
|
to represent nested keys. For example:
|
||||||
|
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
|
||||||
|
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
|
||||||
|
|
||||||
|
Arrays and dict types are ignored.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Configuration dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated configuration dictionary
|
||||||
|
"""
|
||||||
|
def convert_value(value: str, original_value: Any) -> Any:
|
||||||
|
"""Convert string value to appropriate type based on original value
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: String value from environment variable
|
||||||
|
original_value: Original value to infer type from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Converted value (falls back to string if conversion fails)
|
||||||
|
"""
|
||||||
|
if isinstance(original_value, bool):
|
||||||
|
return value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
elif isinstance(original_value, int):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, keep as string (user error, but non-breaking)
|
||||||
|
return value
|
||||||
|
elif isinstance(original_value, float):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
# If conversion fails, keep as string (user error, but non-breaking)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Process environment variables
|
||||||
|
for env_key, env_value in os.environ.items():
|
||||||
|
# Check if the environment variable is uppercase and contains __
|
||||||
|
if not env_key.isupper():
|
||||||
|
continue
|
||||||
|
if '__' not in env_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert environment variable name to config path
|
||||||
|
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
|
||||||
|
keys = [key.lower() for key in env_key.split('__')]
|
||||||
|
|
||||||
|
# Navigate to the target value and validate the path
|
||||||
|
current = cfg
|
||||||
|
|
||||||
|
for i, key in enumerate(keys):
|
||||||
|
if not isinstance(current, dict) or key not in current:
|
||||||
|
break
|
||||||
|
|
||||||
|
if i == len(keys) - 1:
|
||||||
|
# At the final key - check if it's a scalar value
|
||||||
|
if isinstance(current[key], (dict, list)):
|
||||||
|
# Skip dict and list types
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Valid scalar value - convert and set it
|
||||||
|
converted_value = convert_value(env_value, current[key])
|
||||||
|
current[key] = converted_value
|
||||||
|
else:
|
||||||
|
# Navigate deeper
|
||||||
|
current = current[key]
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvOverrides:
|
||||||
|
"""Test environment variable override functionality"""
|
||||||
|
|
||||||
|
def test_simple_string_override(self):
|
||||||
|
"""Test overriding a simple string value"""
|
||||||
|
cfg = {
|
||||||
|
'api': {
|
||||||
|
'port': 5300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set environment variable
|
||||||
|
os.environ['API__PORT'] = '8080'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['api']['port'] == 8080
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del os.environ['API__PORT']
|
||||||
|
|
||||||
|
def test_nested_key_override(self):
|
||||||
|
"""Test overriding nested keys with __ delimiter"""
|
||||||
|
cfg = {
|
||||||
|
'concurrency': {
|
||||||
|
'pipeline': 20,
|
||||||
|
'session': 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['CONCURRENCY__PIPELINE'] = '50'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['concurrency']['pipeline'] == 50
|
||||||
|
assert result['concurrency']['session'] == 1 # Unchanged
|
||||||
|
|
||||||
|
del os.environ['CONCURRENCY__PIPELINE']
|
||||||
|
|
||||||
|
def test_deep_nested_override(self):
|
||||||
|
"""Test overriding deeply nested keys"""
|
||||||
|
cfg = {
|
||||||
|
'system': {
|
||||||
|
'jwt': {
|
||||||
|
'expire': 604800,
|
||||||
|
'secret': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['SYSTEM__JWT__EXPIRE'] = '86400'
|
||||||
|
os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['system']['jwt']['expire'] == 86400
|
||||||
|
assert result['system']['jwt']['secret'] == 'my_secret_key'
|
||||||
|
|
||||||
|
del os.environ['SYSTEM__JWT__EXPIRE']
|
||||||
|
del os.environ['SYSTEM__JWT__SECRET']
|
||||||
|
|
||||||
|
def test_underscore_in_key(self):
|
||||||
|
"""Test keys with underscores like runtime_ws_url"""
|
||||||
|
cfg = {
|
||||||
|
'plugin': {
|
||||||
|
'enable': True,
|
||||||
|
'runtime_ws_url': 'ws://localhost:5400/control/ws'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws'
|
||||||
|
|
||||||
|
del os.environ['PLUGIN__RUNTIME_WS_URL']
|
||||||
|
|
||||||
|
def test_boolean_conversion(self):
|
||||||
|
"""Test boolean value conversion"""
|
||||||
|
cfg = {
|
||||||
|
'plugin': {
|
||||||
|
'enable': True,
|
||||||
|
'enable_marketplace': False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['PLUGIN__ENABLE'] = 'false'
|
||||||
|
os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['plugin']['enable'] is False
|
||||||
|
assert result['plugin']['enable_marketplace'] is True
|
||||||
|
|
||||||
|
del os.environ['PLUGIN__ENABLE']
|
||||||
|
del os.environ['PLUGIN__ENABLE_MARKETPLACE']
|
||||||
|
|
||||||
|
def test_ignore_dict_type(self):
|
||||||
|
"""Test that dict types are ignored"""
|
||||||
|
cfg = {
|
||||||
|
'database': {
|
||||||
|
'use': 'sqlite',
|
||||||
|
'sqlite': {
|
||||||
|
'path': 'data/langbot.db'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to override a dict value - should be ignored
|
||||||
|
os.environ['DATABASE__SQLITE'] = 'new_value'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
# Should remain a dict, not overridden
|
||||||
|
assert isinstance(result['database']['sqlite'], dict)
|
||||||
|
assert result['database']['sqlite']['path'] == 'data/langbot.db'
|
||||||
|
|
||||||
|
del os.environ['DATABASE__SQLITE']
|
||||||
|
|
||||||
|
def test_ignore_list_type(self):
|
||||||
|
"""Test that list/array types are ignored"""
|
||||||
|
cfg = {
|
||||||
|
'admins': ['admin1', 'admin2'],
|
||||||
|
'command': {
|
||||||
|
'enable': True,
|
||||||
|
'prefix': ['!', '!']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to override list values - should be ignored
|
||||||
|
os.environ['ADMINS'] = 'admin3'
|
||||||
|
os.environ['COMMAND__PREFIX'] = '?'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
# Should remain lists, not overridden
|
||||||
|
assert isinstance(result['admins'], list)
|
||||||
|
assert result['admins'] == ['admin1', 'admin2']
|
||||||
|
assert isinstance(result['command']['prefix'], list)
|
||||||
|
assert result['command']['prefix'] == ['!', '!']
|
||||||
|
|
||||||
|
del os.environ['ADMINS']
|
||||||
|
del os.environ['COMMAND__PREFIX']
|
||||||
|
|
||||||
|
def test_lowercase_env_var_ignored(self):
|
||||||
|
"""Test that lowercase environment variables are ignored"""
|
||||||
|
cfg = {
|
||||||
|
'api': {
|
||||||
|
'port': 5300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['api__port'] = '8080'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
# Should not be overridden
|
||||||
|
assert result['api']['port'] == 5300
|
||||||
|
|
||||||
|
del os.environ['api__port']
|
||||||
|
|
||||||
|
def test_no_double_underscore_ignored(self):
|
||||||
|
"""Test that env vars without __ are ignored"""
|
||||||
|
cfg = {
|
||||||
|
'api': {
|
||||||
|
'port': 5300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['APIPORT'] = '8080'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
# Should not be overridden
|
||||||
|
assert result['api']['port'] == 5300
|
||||||
|
|
||||||
|
del os.environ['APIPORT']
|
||||||
|
|
||||||
|
def test_nonexistent_key_ignored(self):
|
||||||
|
"""Test that env vars for non-existent keys are ignored"""
|
||||||
|
cfg = {
|
||||||
|
'api': {
|
||||||
|
'port': 5300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['API__NONEXISTENT'] = 'value'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
# Should not create new key
|
||||||
|
assert 'nonexistent' not in result['api']
|
||||||
|
|
||||||
|
del os.environ['API__NONEXISTENT']
|
||||||
|
|
||||||
|
def test_integer_conversion(self):
|
||||||
|
"""Test integer value conversion"""
|
||||||
|
cfg = {
|
||||||
|
'concurrency': {
|
||||||
|
'pipeline': 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['CONCURRENCY__PIPELINE'] = '100'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['concurrency']['pipeline'] == 100
|
||||||
|
assert isinstance(result['concurrency']['pipeline'], int)
|
||||||
|
|
||||||
|
del os.environ['CONCURRENCY__PIPELINE']
|
||||||
|
|
||||||
|
def test_multiple_overrides(self):
|
||||||
|
"""Test multiple environment variable overrides at once"""
|
||||||
|
cfg = {
|
||||||
|
'api': {
|
||||||
|
'port': 5300
|
||||||
|
},
|
||||||
|
'concurrency': {
|
||||||
|
'pipeline': 20,
|
||||||
|
'session': 1
|
||||||
|
},
|
||||||
|
'plugin': {
|
||||||
|
'enable': False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.environ['API__PORT'] = '8080'
|
||||||
|
os.environ['CONCURRENCY__PIPELINE'] = '50'
|
||||||
|
os.environ['PLUGIN__ENABLE'] = 'true'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['api']['port'] == 8080
|
||||||
|
assert result['concurrency']['pipeline'] == 50
|
||||||
|
assert result['plugin']['enable'] is True
|
||||||
|
|
||||||
|
del os.environ['API__PORT']
|
||||||
|
del os.environ['CONCURRENCY__PIPELINE']
|
||||||
|
del os.environ['PLUGIN__ENABLE']
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v'])
|
||||||
@@ -11,18 +11,23 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
|
||||||
export default function DynamicFormComponent({
|
export default function DynamicFormComponent({
|
||||||
itemConfigList,
|
itemConfigList,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
onFileUploaded,
|
||||||
}: {
|
}: {
|
||||||
itemConfigList: IDynamicFormItemSchema[];
|
itemConfigList: IDynamicFormItemSchema[];
|
||||||
onSubmit?: (val: object) => unknown;
|
onSubmit?: (val: object) => unknown;
|
||||||
initialValues?: Record<string, object>;
|
initialValues?: Record<string, object>;
|
||||||
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
const previousInitialValues = useRef(initialValues);
|
||||||
|
|
||||||
// 根据 itemConfigList 动态生成 zod schema
|
// 根据 itemConfigList 动态生成 zod schema
|
||||||
const formSchema = z.object(
|
const formSchema = z.object(
|
||||||
itemConfigList.reduce(
|
itemConfigList.reduce(
|
||||||
@@ -97,9 +102,24 @@ export default function DynamicFormComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 当 initialValues 变化时更新表单值
|
// 当 initialValues 变化时更新表单值
|
||||||
|
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('initialValues', initialValues);
|
console.log('initialValues', initialValues);
|
||||||
if (initialValues) {
|
|
||||||
|
// 首次挂载时,使用 initialValues 初始化表单
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
previousInitialValues.current = initialValues;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 initialValues 是否真的发生了实质性变化
|
||||||
|
// 使用 JSON.stringify 进行深度比较
|
||||||
|
const hasRealChange =
|
||||||
|
JSON.stringify(previousInitialValues.current) !==
|
||||||
|
JSON.stringify(initialValues);
|
||||||
|
|
||||||
|
if (initialValues && hasRealChange) {
|
||||||
// 合并默认值和初始值
|
// 合并默认值和初始值
|
||||||
const mergedValues = itemConfigList.reduce(
|
const mergedValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@@ -112,6 +132,8 @@ export default function DynamicFormComponent({
|
|||||||
Object.entries(mergedValues).forEach(([key, value]) => {
|
Object.entries(mergedValues).forEach(([key, value]) => {
|
||||||
form.setValue(key as keyof FormValues, value);
|
form.setValue(key as keyof FormValues, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
previousInitialValues.current = initialValues;
|
||||||
}
|
}
|
||||||
}, [initialValues, form, itemConfigList]);
|
}, [initialValues, form, itemConfigList]);
|
||||||
|
|
||||||
@@ -149,7 +171,11 @@ export default function DynamicFormComponent({
|
|||||||
{config.required && <span className="text-red-500">*</span>}
|
{config.required && <span className="text-red-500">*</span>}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DynamicFormItemComponent config={config} field={field} />
|
<DynamicFormItemComponent
|
||||||
|
config={config}
|
||||||
|
field={field}
|
||||||
|
onFileUploaded={onFileUploaded}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DynamicFormItemType,
|
DynamicFormItemType,
|
||||||
IDynamicFormItemSchema,
|
IDynamicFormItemSchema,
|
||||||
|
IFileConfig,
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -27,19 +28,53 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
export default function DynamicFormItemComponent({
|
export default function DynamicFormItemComponent({
|
||||||
config,
|
config,
|
||||||
field,
|
field,
|
||||||
|
onFileUploaded,
|
||||||
}: {
|
}: {
|
||||||
config: IDynamicFormItemSchema;
|
config: IDynamicFormItemSchema;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
field: ControllerRenderProps<any, any>;
|
field: ControllerRenderProps<any, any>;
|
||||||
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
toast.error(t('plugins.fileUpload.tooLarge'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
const response = await httpClient.uploadPluginConfigFile(file);
|
||||||
|
toast.success(t('plugins.fileUpload.success'));
|
||||||
|
|
||||||
|
// 通知父组件文件已上传
|
||||||
|
onFileUploaded?.(response.file_key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_key: response.file_key,
|
||||||
|
mimetype: file.type,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t('plugins.fileUpload.failed') + ': ' + (error as Error).message,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||||
httpClient
|
httpClient
|
||||||
@@ -80,6 +115,9 @@ export default function DynamicFormItemComponent({
|
|||||||
case DynamicFormItemType.STRING:
|
case DynamicFormItemType.STRING:
|
||||||
return <Input {...field} />;
|
return <Input {...field} />;
|
||||||
|
|
||||||
|
case DynamicFormItemType.TEXT:
|
||||||
|
return <Textarea {...field} className="min-h-[120px]" />;
|
||||||
|
|
||||||
case DynamicFormItemType.BOOLEAN:
|
case DynamicFormItemType.BOOLEAN:
|
||||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||||
|
|
||||||
@@ -366,6 +404,185 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DynamicFormItemType.FILE:
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.value && (field.value as IFileConfig).file_key ? (
|
||||||
|
<Card className="py-3 max-w-full overflow-hidden bg-gray-900">
|
||||||
|
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium truncate"
|
||||||
|
title={(field.value as IFileConfig).file_key}
|
||||||
|
>
|
||||||
|
{(field.value as IFileConfig).file_key}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{(field.value as IFileConfig).mimetype}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0 h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
field.onChange(null);
|
||||||
|
}}
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-4 h-4 text-destructive"
|
||||||
|
>
|
||||||
|
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={config.accept}
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const fileConfig = await handleFileUpload(file);
|
||||||
|
if (fileConfig) {
|
||||||
|
field.onChange(fileConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
id={`file-input-${config.name}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById(`file-input-${config.name}`)?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||||
|
</svg>
|
||||||
|
{uploading
|
||||||
|
? t('plugins.fileUpload.uploading')
|
||||||
|
: t('plugins.fileUpload.chooseFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case DynamicFormItemType.FILE_ARRAY:
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(field.value as IFileConfig[])?.map(
|
||||||
|
(fileConfig: IFileConfig, index: number) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="py-3 max-w-full overflow-hidden bg-gray-900"
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium truncate"
|
||||||
|
title={fileConfig.file_key}
|
||||||
|
>
|
||||||
|
{fileConfig.file_key}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{fileConfig.mimetype}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0 h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const newValue = (field.value as IFileConfig[]).filter(
|
||||||
|
(_: IFileConfig, i: number) => i !== index,
|
||||||
|
);
|
||||||
|
field.onChange(newValue);
|
||||||
|
}}
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-4 h-4 text-destructive"
|
||||||
|
>
|
||||||
|
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={config.accept}
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const fileConfig = await handleFileUpload(file);
|
||||||
|
if (fileConfig) {
|
||||||
|
field.onChange([...(field.value || []), fileConfig]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
id={`file-array-input-${config.name}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById(`file-array-input-${config.name}`)
|
||||||
|
?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||||
|
</svg>
|
||||||
|
{uploading
|
||||||
|
? t('plugins.fileUpload.uploading')
|
||||||
|
: t('plugins.fileUpload.addFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Input {...field} />;
|
return <Input {...field} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import styles from './layout.module.css';
|
import styles from './layout.module.css';
|
||||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||||
import { I18nObject } from '@/app/infra/entities/common';
|
import { I18nObject } from '@/app/infra/entities/common';
|
||||||
|
|
||||||
@@ -18,11 +18,15 @@ export default function HomeLayout({
|
|||||||
en_US: '',
|
en_US: '',
|
||||||
zh_Hans: '',
|
zh_Hans: '',
|
||||||
});
|
});
|
||||||
const onSelectedChangeAction = (child: SidebarChildVO) => {
|
|
||||||
|
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||||
setTitle(child.name);
|
setTitle(child.name);
|
||||||
setSubtitle(child.description);
|
setSubtitle(child.description);
|
||||||
setHelpLink(child.helpLink);
|
setHelpLink(child.helpLink);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoize the main content area to prevent re-renders when sidebar state changes
|
||||||
|
const mainContent = useMemo(() => children, [children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeLayoutContainer}>
|
<div className={styles.homeLayoutContainer}>
|
||||||
@@ -33,7 +37,7 @@ export default function HomeLayout({
|
|||||||
<div className={styles.main}>
|
<div className={styles.main}>
|
||||||
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
||||||
|
|
||||||
<main className={styles.mainContent}>{children}</main>
|
<main className={styles.mainContent}>{mainContent}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -24,6 +24,9 @@ export default function PluginForm({
|
|||||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||||
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
||||||
const [isSaving, setIsLoading] = useState(false);
|
const [isSaving, setIsLoading] = useState(false);
|
||||||
|
const currentFormValues = useRef<object>({});
|
||||||
|
const uploadedFileKeys = useRef<Set<string>>(new Set());
|
||||||
|
const initialFileKeys = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 获取插件信息
|
// 获取插件信息
|
||||||
@@ -33,28 +36,103 @@ export default function PluginForm({
|
|||||||
// 获取插件配置
|
// 获取插件配置
|
||||||
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
||||||
setPluginConfig(res);
|
setPluginConfig(res);
|
||||||
|
|
||||||
|
// 提取初始配置中的所有文件 key
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const extractFileKeys = (obj: any): string[] => {
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
||||||
|
keys.push(obj.file_key);
|
||||||
|
}
|
||||||
|
for (const value of Object.values(obj)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
keys.push(...extractFileKeys(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileKeys = extractFileKeys(res.config);
|
||||||
|
initialFileKeys.current = new Set(fileKeys);
|
||||||
});
|
});
|
||||||
}, [pluginAuthor, pluginName]);
|
}, [pluginAuthor, pluginName]);
|
||||||
|
|
||||||
const handleSubmit = async (values: object) => {
|
const handleSubmit = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const isDebugPlugin = pluginInfo?.debug;
|
const isDebugPlugin = pluginInfo?.debug;
|
||||||
httpClient
|
|
||||||
.updatePluginConfig(pluginAuthor, pluginName, values)
|
try {
|
||||||
.then(() => {
|
// 保存配置
|
||||||
toast.success(
|
await httpClient.updatePluginConfig(
|
||||||
isDebugPlugin
|
pluginAuthor,
|
||||||
? t('plugins.saveConfigSuccessDebugPlugin')
|
pluginName,
|
||||||
: t('plugins.saveConfigSuccessNormal'),
|
currentFormValues.current,
|
||||||
);
|
);
|
||||||
onFormSubmit(1000);
|
|
||||||
})
|
// 提取最终保存的配置中的所有文件 key
|
||||||
.catch((error) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
toast.error(t('plugins.saveConfigError') + error.message);
|
const extractFileKeys = (obj: any): string[] => {
|
||||||
})
|
const keys: string[] = [];
|
||||||
.finally(() => {
|
if (obj && typeof obj === 'object') {
|
||||||
setIsLoading(false);
|
if ('file_key' in obj && typeof obj.file_key === 'string') {
|
||||||
|
keys.push(obj.file_key);
|
||||||
|
}
|
||||||
|
for (const value of Object.values(obj)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => keys.push(...extractFileKeys(item)));
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
keys.push(...extractFileKeys(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalFileKeys = new Set(extractFileKeys(currentFormValues.current));
|
||||||
|
|
||||||
|
// 计算需要删除的文件:
|
||||||
|
// 1. 在编辑期间上传的,但最终未保存的文件
|
||||||
|
// 2. 初始配置中有的,但最终配置中没有的文件(被删除的文件)
|
||||||
|
const filesToDelete: string[] = [];
|
||||||
|
|
||||||
|
// 上传了但未使用的文件
|
||||||
|
uploadedFileKeys.current.forEach((key) => {
|
||||||
|
if (!finalFileKeys.has(key)) {
|
||||||
|
filesToDelete.push(key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 初始有但最终没有的文件(被删除的)
|
||||||
|
initialFileKeys.current.forEach((key) => {
|
||||||
|
if (!finalFileKeys.has(key)) {
|
||||||
|
filesToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除不需要的文件
|
||||||
|
const deletePromises = filesToDelete.map((fileKey) =>
|
||||||
|
httpClient.deletePluginConfigFile(fileKey).catch((err) => {
|
||||||
|
console.warn(`Failed to delete file ${fileKey}:`, err);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
isDebugPlugin
|
||||||
|
? t('plugins.saveConfigSuccessDebugPlugin')
|
||||||
|
: t('plugins.saveConfigSuccessNormal'),
|
||||||
|
);
|
||||||
|
onFormSubmit(1000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('plugins.saveConfigError') + (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pluginInfo || !pluginConfig) {
|
if (!pluginInfo || !pluginConfig) {
|
||||||
@@ -95,14 +173,12 @@ export default function PluginForm({
|
|||||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||||
initialValues={pluginConfig.config as Record<string, object>}
|
initialValues={pluginConfig.config as Record<string, object>}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
let config = pluginConfig.config;
|
// 只保存表单值的引用,不触发状态更新
|
||||||
config = {
|
currentFormValues.current = values;
|
||||||
...config,
|
}}
|
||||||
...values,
|
onFileUploaded={(fileKey) => {
|
||||||
};
|
// 追踪上传的文件
|
||||||
setPluginConfig({
|
uploadedFileKeys.current.add(fileKey);
|
||||||
config: config,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -117,7 +193,7 @@ export default function PluginForm({
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() => handleSubmit(pluginConfig.config)}
|
onClick={() => handleSubmit()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ function MarketPageContent({
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
|
||||||
{/* 搜索框 */}
|
{/* 搜索框 */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="relative w-full max-w-2xl">
|
<div className="relative w-full max-w-2xl">
|
||||||
@@ -301,19 +301,19 @@ function MarketPageContent({
|
|||||||
handleSearch(searchQuery);
|
handleSearch(searchQuery);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-10 pr-4"
|
className="pl-10 pr-4 text-sm sm:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 排序下拉框 */}
|
{/* 排序下拉框 */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="w-full max-w-2xl flex items-center gap-3">
|
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{t('market.sortBy')}:
|
{t('market.sortBy')}:
|
||||||
</span>
|
</span>
|
||||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||||
<SelectTrigger className="w-48">
|
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -329,7 +329,7 @@ function MarketPageContent({
|
|||||||
|
|
||||||
{/* 搜索结果统计 */}
|
{/* 搜索结果统计 */}
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground text-sm">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? t('market.searchResults', { count: total })
|
? t('market.searchResults', { count: total })
|
||||||
: t('market.totalPlugins', { count: total })}
|
: t('market.totalPlugins', { count: total })}
|
||||||
|
|||||||
@@ -228,6 +228,30 @@ export default function PluginDetailDialog({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
h3: ({ ...props }) => (
|
||||||
|
<h3
|
||||||
|
className="text-xl font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h4: ({ ...props }) => (
|
||||||
|
<h4
|
||||||
|
className="text-lg font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h5: ({ ...props }) => (
|
||||||
|
<h5
|
||||||
|
className="text-base font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h6: ({ ...props }) => (
|
||||||
|
<h6
|
||||||
|
className="text-sm font-semibold mb-2 mt-4 dark:text-gray-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
p: ({ ...props }) => (
|
p: ({ ...props }) => (
|
||||||
<p className="leading-relaxed dark:text-gray-400" {...props} />
|
<p className="leading-relaxed dark:text-gray-400" {...props} />
|
||||||
),
|
),
|
||||||
@@ -274,6 +298,57 @@ export default function PluginDetailDialog({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
// 图片组件 - 转换本地路径为API路径
|
||||||
|
img: ({ src, alt, ...props }) => {
|
||||||
|
// 处理图片路径
|
||||||
|
let imageSrc = src || '';
|
||||||
|
|
||||||
|
// 确保 src 是字符串类型
|
||||||
|
if (typeof imageSrc !== 'string') {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ''}
|
||||||
|
className="max-w-full h-auto rounded-lg my-4"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是相对路径,转换为API路径
|
||||||
|
if (
|
||||||
|
imageSrc &&
|
||||||
|
!imageSrc.startsWith('http://') &&
|
||||||
|
!imageSrc.startsWith('https://') &&
|
||||||
|
!imageSrc.startsWith('data:')
|
||||||
|
) {
|
||||||
|
// 移除开头的 ./ 或 / (支持多个前缀)
|
||||||
|
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||||
|
|
||||||
|
// 如果路径以 assets/ 开头,直接使用
|
||||||
|
// 否则假设它在 assets/ 目录下
|
||||||
|
if (!imageSrc.startsWith('assets/')) {
|
||||||
|
imageSrc = `assets/${imageSrc}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 assets/ 前缀以构建API URL
|
||||||
|
const assetPath = imageSrc.replace(/^assets\//, '');
|
||||||
|
imageSrc = getCloudServiceClientSync().getPluginAssetURL(
|
||||||
|
author!,
|
||||||
|
pluginName!,
|
||||||
|
assetPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt || ''}
|
||||||
|
className="max-w-lg h-auto my-4"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{readme}
|
{readme}
|
||||||
|
|||||||
@@ -15,35 +15,37 @@ export default function PluginMarketCardComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-[100%] h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
|
className="w-[100%] h-auto min-h-[8rem] sm:h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full flex flex-col justify-between">
|
<div className="w-full h-full flex flex-col justify-between gap-2">
|
||||||
{/* 上部分:插件信息 */}
|
{/* 上部分:插件信息 */}
|
||||||
<div className="flex flex-row items-start justify-start gap-[1.2rem]">
|
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
|
||||||
<img src={cardVO.iconURL} alt="plugin icon" className="w-16 h-16" />
|
<img
|
||||||
|
src={cardVO.iconURL}
|
||||||
|
alt="plugin icon"
|
||||||
|
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-start justify-start gap-[0.6rem]">
|
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-col items-start justify-start">
|
<div className="flex flex-col items-start justify-start w-full min-w-0">
|
||||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
|
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||||
{cardVO.pluginId}
|
{cardVO.pluginId}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full">
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
|
{cardVO.label}
|
||||||
{cardVO.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2">
|
<div className="text-[0.7rem] sm:text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2 overflow-hidden">
|
||||||
{cardVO.description}
|
{cardVO.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
|
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
|
||||||
{cardVO.githubURL && (
|
{cardVO.githubURL && (
|
||||||
<svg
|
<svg
|
||||||
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0]"
|
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -59,9 +61,9 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下部分:下载量 */}
|
{/* 下部分:下载量 */}
|
||||||
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
|
<div className="w-full flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
|
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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -72,7 +74,7 @@ export default function PluginMarketCardComponent({
|
|||||||
<polyline points="7,10 12,15 17,10" />
|
<polyline points="7,10 12,15 17,10" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-sm text-[#2563eb] font-medium">
|
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
|
||||||
{cardVO.installCount.toLocaleString()}
|
{cardVO.installCount.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ export default function MCPComponent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Check if any server is connecting and start/stop polling accordingly
|
// Check if any enabled server is connecting and start/stop polling accordingly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasConnecting = installedServers.some(
|
const hasConnecting = installedServers.some(
|
||||||
(server) => server.status === MCPSessionStatus.CONNECTING,
|
(server) =>
|
||||||
|
server.enable && server.status === MCPSessionStatus.CONNECTING,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasConnecting && !pollingIntervalRef.current) {
|
if (hasConnecting && !pollingIntervalRef.current) {
|
||||||
@@ -42,7 +43,7 @@ export default function MCPComponent({
|
|||||||
fetchInstalledServers();
|
fetchInstalledServers();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else if (!hasConnecting && pollingIntervalRef.current) {
|
} else if (!hasConnecting && pollingIntervalRef.current) {
|
||||||
// Stop polling when no server is connecting
|
// Stop polling when no enabled server is connecting
|
||||||
clearInterval(pollingIntervalRef.current);
|
clearInterval(pollingIntervalRef.current);
|
||||||
pollingIntervalRef.current = null;
|
pollingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ export default function PluginConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPluginSystemStatus();
|
fetchPluginSystemStatus();
|
||||||
}, [t]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface IDynamicFormItemSchema {
|
|||||||
type: DynamicFormItemType;
|
type: DynamicFormItemType;
|
||||||
description?: I18nObject;
|
description?: I18nObject;
|
||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
|
accept?: string; // For file type: accepted MIME types
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DynamicFormItemType {
|
export enum DynamicFormItemType {
|
||||||
@@ -16,7 +17,10 @@ export enum DynamicFormItemType {
|
|||||||
FLOAT = 'float',
|
FLOAT = 'float',
|
||||||
BOOLEAN = 'boolean',
|
BOOLEAN = 'boolean',
|
||||||
STRING = 'string',
|
STRING = 'string',
|
||||||
|
TEXT = 'text',
|
||||||
STRING_ARRAY = 'array[string]',
|
STRING_ARRAY = 'array[string]',
|
||||||
|
FILE = 'file',
|
||||||
|
FILE_ARRAY = 'array[file]',
|
||||||
SELECT = 'select',
|
SELECT = 'select',
|
||||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||||
PROMPT_EDITOR = 'prompt-editor',
|
PROMPT_EDITOR = 'prompt-editor',
|
||||||
@@ -24,6 +28,11 @@ export enum DynamicFormItemType {
|
|||||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IFileConfig {
|
||||||
|
file_key: string;
|
||||||
|
mimetype: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IDynamicFormItemOption {
|
export interface IDynamicFormItemOption {
|
||||||
name: string;
|
name: string;
|
||||||
label: I18nObject;
|
label: I18nObject;
|
||||||
|
|||||||
@@ -442,6 +442,26 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.put(`/api/v1/plugins/${author}/${name}/config`, config);
|
return this.put(`/api/v1/plugins/${author}/${name}/config`, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public uploadPluginConfigFile(file: File): Promise<{ file_key: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return this.request<{ file_key: string }>({
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/v1/plugins/config-files',
|
||||||
|
data: formData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public deletePluginConfigFile(
|
||||||
|
fileKey: string,
|
||||||
|
): Promise<{ deleted: boolean }> {
|
||||||
|
return this.delete(`/api/v1/plugins/config-files/${fileKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
public getPluginIconURL(author: string, name: string): string {
|
public getPluginIconURL(author: string, name: string): string {
|
||||||
if (this.instance.defaults.baseURL === '/') {
|
if (this.instance.defaults.baseURL === '/') {
|
||||||
const url = window.location.href;
|
const url = window.location.href;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export abstract class BaseHttpClient {
|
|||||||
|
|
||||||
this.instance = axios.create({
|
this.instance = axios.create({
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
timeout: 15000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;
|
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${name}/resources/icon`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPluginAssetURL(
|
||||||
|
author: string,
|
||||||
|
pluginName: string,
|
||||||
|
filepath: string,
|
||||||
|
): string {
|
||||||
|
return `${this.baseURL}/api/v1/marketplace/plugins/${author}/${pluginName}/resources/assets/${filepath}`;
|
||||||
|
}
|
||||||
|
|
||||||
public getPluginMarketplaceURL(author: string, name: string): string {
|
public getPluginMarketplaceURL(author: string, name: string): string {
|
||||||
return `${this.baseURL}/market?author=${author}&plugin=${name}`;
|
return `${this.baseURL}/market?author=${author}&plugin=${name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ const enUS = {
|
|||||||
saveConfigSuccessDebugPlugin:
|
saveConfigSuccessDebugPlugin:
|
||||||
'Configuration saved successfully, please manually restart the plugin',
|
'Configuration saved successfully, please manually restart the plugin',
|
||||||
saveConfigError: 'Configuration save failed: ',
|
saveConfigError: 'Configuration save failed: ',
|
||||||
|
fileUpload: {
|
||||||
|
tooLarge: 'File size exceeds 10MB limit',
|
||||||
|
success: 'File uploaded successfully',
|
||||||
|
failed: 'File upload failed',
|
||||||
|
uploading: 'Uploading...',
|
||||||
|
chooseFile: 'Choose File',
|
||||||
|
addFile: 'Add File',
|
||||||
|
},
|
||||||
installFromGithub: 'From GitHub',
|
installFromGithub: 'From GitHub',
|
||||||
enterRepoUrl: 'Enter GitHub repository URL',
|
enterRepoUrl: 'Enter GitHub repository URL',
|
||||||
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
|
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ const jaJP = {
|
|||||||
saveConfigSuccessDebugPlugin:
|
saveConfigSuccessDebugPlugin:
|
||||||
'設定を保存しました。手動でプラグインを再起動してください',
|
'設定を保存しました。手動でプラグインを再起動してください',
|
||||||
saveConfigError: '設定の保存に失敗しました:',
|
saveConfigError: '設定の保存に失敗しました:',
|
||||||
|
fileUpload: {
|
||||||
|
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
||||||
|
success: 'ファイルのアップロードに成功しました',
|
||||||
|
failed: 'ファイルのアップロードに失敗しました',
|
||||||
|
uploading: 'アップロード中...',
|
||||||
|
chooseFile: 'ファイルを選択',
|
||||||
|
addFile: 'ファイルを追加',
|
||||||
|
},
|
||||||
installFromGithub: 'GitHubから',
|
installFromGithub: 'GitHubから',
|
||||||
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
|
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
|
||||||
repoUrlPlaceholder: '例: https://github.com/owner/repo',
|
repoUrlPlaceholder: '例: https://github.com/owner/repo',
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ const zhHans = {
|
|||||||
saveConfigSuccessNormal: '保存配置成功',
|
saveConfigSuccessNormal: '保存配置成功',
|
||||||
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
|
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
|
||||||
saveConfigError: '保存配置失败:',
|
saveConfigError: '保存配置失败:',
|
||||||
|
fileUpload: {
|
||||||
|
tooLarge: '文件大小超过 10MB 限制',
|
||||||
|
success: '文件上传成功',
|
||||||
|
failed: '文件上传失败',
|
||||||
|
uploading: '上传中...',
|
||||||
|
chooseFile: '选择文件',
|
||||||
|
addFile: '添加文件',
|
||||||
|
},
|
||||||
installFromGithub: '来自 GitHub',
|
installFromGithub: '来自 GitHub',
|
||||||
enterRepoUrl: '请输入 GitHub 仓库地址',
|
enterRepoUrl: '请输入 GitHub 仓库地址',
|
||||||
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
||||||
|
|||||||
@@ -229,6 +229,14 @@ const zhHant = {
|
|||||||
saveConfigSuccessNormal: '儲存配置成功',
|
saveConfigSuccessNormal: '儲存配置成功',
|
||||||
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
||||||
saveConfigError: '儲存配置失敗:',
|
saveConfigError: '儲存配置失敗:',
|
||||||
|
fileUpload: {
|
||||||
|
tooLarge: '檔案大小超過 10MB 限制',
|
||||||
|
success: '檔案上傳成功',
|
||||||
|
failed: '檔案上傳失敗',
|
||||||
|
uploading: '上傳中...',
|
||||||
|
chooseFile: '選擇檔案',
|
||||||
|
addFile: '新增檔案',
|
||||||
|
},
|
||||||
enterRepoUrl: '請輸入 GitHub 倉庫地址',
|
enterRepoUrl: '請輸入 GitHub 倉庫地址',
|
||||||
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
||||||
fetchingReleases: '正在獲取 Release 列表...',
|
fetchingReleases: '正在獲取 Release 列表...',
|
||||||
|
|||||||
Reference in New Issue
Block a user