Compare commits

...

15 Commits

Author SHA1 Message Date
Junyan Qin
2c2a89d9db chore: bump version 4.4.1 2025-11-06 00:09:35 +08:00
Junyan Qin (Chin)
c91e2f0efe feat: add file array[file] and text type plugin config fields (#1750)
* feat: add   and  type plugin config fields

* chore: add hant and jp i18n

* feat: plugin config file auto clean

* chore: bump langbot-plugin to 0.1.8

* chore: fix linter errors
2025-11-06 00:07:57 +08:00
Junyan Qin
411d082d2a chore: fix linter errors 2025-11-06 00:07:43 +08:00
Junyan Qin
d4e08a1765 chore: bump langbot-plugin to 0.1.8 2025-11-06 00:05:03 +08:00
Junyan Qin
b529d07479 feat: plugin config file auto clean 2025-11-06 00:02:25 +08:00
Junyan Qin
d44df75e5c chore: add hant and jp i18n 2025-11-05 23:54:34 +08:00
Junyan Qin
b74e07b608 feat: add and type plugin config fields 2025-11-05 23:48:59 +08:00
Junyan Qin
4a868afecd fix: plugin mgm page mistakely refreshed when open acc option menu 2025-11-05 18:59:40 +08:00
Junyan Qin
1cb9560663 perf: only check connecting mcp server when it's enabled 2025-11-05 18:53:17 +08:00
Junyan Qin
8f878673ae feat: add supports for showing image in plugin readme 2025-11-05 18:42:14 +08:00
Junyan Qin
74a5e37892 perf: plugin market layout 2025-11-05 18:34:40 +08:00
Copilot
76a69ecc7e Add environment variable override support for config.yaml (#1748)
* Initial plan

* Add environment variable override support for config.yaml

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

* Refactor env override code based on review feedback

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

* Add test for template completion with env overrides

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

* Move env override logic to load_config.py as requested

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

* perf: add print log

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-05 18:15:15 +08:00
Alfons
f06e3d3efa fix: disabling potential thinking param for model testing (#1733)
* fix: 禁用模型默认思考功能以减少测试延迟

- 调整导入语句顺序
- 为没有显式设置 thinking 参数的模型添加禁用配置
- 避免某些模型厂商默认开启思考功能导致的测试延迟

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: 确保 extra_args 为空时也禁用思考功能

修复条件判断逻辑,当 extra_args 为空字典时也会添加思考功能禁用配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(fe): increase default timeout

* perf: llm model testing prompt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-05 15:52:17 +08:00
Guanchao Wang
973e7bae42 fix: wecombot id (#1747) 2025-11-05 12:14:01 +08:00
Junyan Qin
94aa175c1a chore: bump langbot-plugin to 0.1.7 2025-11-05 12:11:46 +08:00
28 changed files with 1049 additions and 72 deletions

View File

@@ -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'):

View File

@@ -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:

View File

@@ -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)}')

View File

@@ -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,
) )

View File

@@ -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(

View File

@@ -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='',

View File

@@ -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(

View File

@@ -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"""

View File

@@ -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",

View File

@@ -0,0 +1 @@
# Config unit tests

View 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'])

View File

@@ -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">

View File

@@ -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} />;
} }

View File

@@ -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>
); );

View File

@@ -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')}

View File

@@ -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 })}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
}, },

View File

@@ -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}`;
} }

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 列表...',