mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 03:15:06 +08:00
Compare commits
8 Commits
copilot/op
...
copilot/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1de9408d | ||
|
|
f49f9fdff1 | ||
|
|
6b20cb7144 | ||
|
|
2e1f16d7b4 | ||
|
|
50c33c5213 | ||
|
|
ace6d62d76 | ||
|
|
b7c4c21796 | ||
|
|
66602da9cb |
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.1.12",
|
||||
"langbot-plugin==0.1.13b1",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -32,6 +32,7 @@ class AsyncDifyServiceClient:
|
||||
conversation_id: str = '',
|
||||
files: list[dict[str, typing.Any]] = [],
|
||||
timeout: float = 30.0,
|
||||
model_config: dict[str, typing.Any] | None = None,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""发送消息"""
|
||||
if response_mode != 'streaming':
|
||||
@@ -42,6 +43,16 @@ class AsyncDifyServiceClient:
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload = {
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
'model_config': model_config or {},
|
||||
}
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/chat-messages',
|
||||
@@ -49,14 +60,7 @@ class AsyncDifyServiceClient:
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json={
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
|
||||
@@ -82,6 +82,16 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/readme',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
language = quart.request.args.get('language', 'en')
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
@@ -96,6 +106,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return quart.Response(icon_data, mimetype=mime_type)
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/assets/<filepath>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||
mime_type = asset_data['mime_type']
|
||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
|
||||
@@ -99,7 +99,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
content_list: list[provider_message.ContentElement] = []
|
||||
|
||||
plain_text = ''
|
||||
qoute_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
||||
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
||||
|
||||
for me in query.message_chain:
|
||||
if isinstance(me, platform_message.Plain):
|
||||
@@ -114,7 +114,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
elif isinstance(me, platform_message.File):
|
||||
# if me.url is not None:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
elif isinstance(me, platform_message.Quote) and qoute_msg:
|
||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||
for msg in me.origin:
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||
|
||||
@@ -40,6 +40,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
text_message=str(query.message_chain),
|
||||
message_chain=query.message_chain,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
|
||||
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入qoute
|
||||
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入Quote
|
||||
for forward_msg_datas in msg_data['data']['content']:
|
||||
for forward_msg_data in forward_msg_datas['message']:
|
||||
await process_message_data(forward_msg_data, reply_list)
|
||||
@@ -259,7 +259,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
# await process_message_data(msg_data, yiri_msg_list)
|
||||
pass
|
||||
|
||||
elif msg.type == 'reply': # 此处处理引用消息传入Qoute
|
||||
elif msg.type == 'reply': # 此处处理引用消息传入Quote
|
||||
msg_datas = await bot.get_msg(message_id=msg.data['id'])
|
||||
|
||||
for msg_data in msg_datas['message']:
|
||||
|
||||
@@ -7,6 +7,7 @@ import typing
|
||||
import os
|
||||
import sys
|
||||
import httpx
|
||||
import sqlalchemy
|
||||
from async_lru import alru_cache
|
||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||
|
||||
@@ -27,6 +28,7 @@ from langbot_plugin.api.entities.builtin.command import (
|
||||
)
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
from ..core import taskmgr
|
||||
from ..entity.persistence import plugin as persistence_plugin
|
||||
|
||||
|
||||
class PluginRuntimeConnector:
|
||||
@@ -279,7 +281,61 @@ class PluginRuntimeConnector:
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
return await self.handler.list_plugins()
|
||||
plugins = await self.handler.list_plugins()
|
||||
|
||||
# Sort plugins: debug plugins first, then by installation time (newest first)
|
||||
# Get installation timestamps from database in a single query
|
||||
plugin_timestamps = {}
|
||||
|
||||
if plugins:
|
||||
# Build list of (author, name) tuples for all plugins
|
||||
plugin_ids = []
|
||||
for plugin in plugins:
|
||||
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||
if author and name:
|
||||
plugin_ids.append((author, name))
|
||||
|
||||
# Fetch all timestamps in a single query using OR conditions
|
||||
if plugin_ids:
|
||||
conditions = [
|
||||
sqlalchemy.and_(
|
||||
persistence_plugin.PluginSetting.plugin_author == author,
|
||||
persistence_plugin.PluginSetting.plugin_name == name,
|
||||
)
|
||||
for author, name in plugin_ids
|
||||
]
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
persistence_plugin.PluginSetting.plugin_author,
|
||||
persistence_plugin.PluginSetting.plugin_name,
|
||||
persistence_plugin.PluginSetting.created_at,
|
||||
).where(sqlalchemy.or_(*conditions))
|
||||
)
|
||||
|
||||
for row in result:
|
||||
plugin_id = f'{row.plugin_author}/{row.plugin_name}'
|
||||
plugin_timestamps[plugin_id] = row.created_at
|
||||
|
||||
# Sort: debug plugins first (descending), then by created_at (descending)
|
||||
def sort_key(plugin):
|
||||
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||
plugin_id = f'{author}/{name}'
|
||||
|
||||
is_debug = plugin.get('debug', False)
|
||||
created_at = plugin_timestamps.get(plugin_id)
|
||||
|
||||
# Return tuple: (not is_debug, -timestamp)
|
||||
# not is_debug: False (0) for debug plugins, True (1) for non-debug
|
||||
# -timestamp: to sort newest first (will be None for plugins without timestamp)
|
||||
timestamp_value = -created_at.timestamp() if created_at else 0
|
||||
return (not is_debug, timestamp_value)
|
||||
|
||||
plugins.sort(key=sort_key)
|
||||
|
||||
return plugins
|
||||
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_info(author, plugin_name)
|
||||
@@ -291,6 +347,14 @@ class PluginRuntimeConnector:
|
||||
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
|
||||
|
||||
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||
|
||||
@alru_cache(ttl=5 * 60)
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
|
||||
async def emit_event(
|
||||
self,
|
||||
event: events.BaseEventModel,
|
||||
|
||||
@@ -602,6 +602,51 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
"""Get plugin readme"""
|
||||
try:
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_PLUGIN_README,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'language': language,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return ''
|
||||
|
||||
readme_file_key = result.get('readme_file_key')
|
||||
if not readme_file_key:
|
||||
return ''
|
||||
|
||||
readme_bytes = await self.read_local_file(readme_file_key)
|
||||
await self.delete_local_file(readme_file_key)
|
||||
|
||||
return readme_bytes.decode('utf-8')
|
||||
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
"""Get plugin assets"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'file_path': filepath,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
asset_file_key = result['file_file_key']
|
||||
mime_type = result['mime_type']
|
||||
asset_bytes = await self.read_local_file(asset_file_key)
|
||||
await self.delete_local_file(asset_file_key)
|
||||
return {
|
||||
'asset_base64': base64.b64encode(asset_bytes).decode('utf-8'),
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||
"""Cleanup plugin settings and binary storage"""
|
||||
# Delete plugin settings
|
||||
@@ -637,7 +682,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'query_id': query_id,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=60,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
return result['tool_response']
|
||||
|
||||
1
tests/unit_tests/plugin/__init__.py
Normal file
1
tests/unit_tests/plugin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Plugin connector unit tests
|
||||
228
tests/unit_tests/plugin/test_plugin_list_sorting.py
Normal file
228
tests/unit_tests/plugin/test_plugin_list_sorting.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Test plugin list sorting functionality."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_debug_first():
|
||||
"""Test that debug plugins appear before non-debug plugins."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data with different debug states and timestamps
|
||||
now = datetime.now()
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin1',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': True,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'plugin2',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author3',
|
||||
'name': 'plugin3',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
# plugin1: oldest, plugin2: middle, plugin3: newest
|
||||
mock_row1 = MagicMock()
|
||||
mock_row1.plugin_author = 'author1'
|
||||
mock_row1.plugin_name = 'plugin1'
|
||||
mock_row1.created_at = now - timedelta(days=2)
|
||||
mock_rows.append(mock_row1)
|
||||
|
||||
mock_row2 = MagicMock()
|
||||
mock_row2.plugin_author = 'author2'
|
||||
mock_row2.plugin_name = 'plugin2'
|
||||
mock_row2.created_at = now - timedelta(days=1)
|
||||
mock_rows.append(mock_row2)
|
||||
|
||||
mock_row3 = MagicMock()
|
||||
mock_row3.plugin_author = 'author3'
|
||||
mock_row3.plugin_name = 'plugin3'
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify sorting: debug plugin should be first
|
||||
assert len(result) == 3
|
||||
assert result[0]['debug'] is True # plugin2 (debug)
|
||||
assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin2'
|
||||
|
||||
# Remaining should be sorted by created_at (newest first)
|
||||
assert result[1]['debug'] is False
|
||||
assert result[1]['manifest']['manifest']['metadata']['name'] == 'plugin3' # newest non-debug
|
||||
assert result[2]['debug'] is False
|
||||
assert result[2]['manifest']['manifest']['metadata']['name'] == 'plugin1' # oldest non-debug
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_by_installation_time():
|
||||
"""Test that non-debug plugins are sorted by installation time (newest first)."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data - all non-debug with different installation times
|
||||
now = datetime.now()
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'oldest_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'middle_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author3',
|
||||
'name': 'newest_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
# oldest_plugin: oldest, middle_plugin: middle, newest_plugin: newest
|
||||
mock_row1 = MagicMock()
|
||||
mock_row1.plugin_author = 'author1'
|
||||
mock_row1.plugin_name = 'oldest_plugin'
|
||||
mock_row1.created_at = now - timedelta(days=10)
|
||||
mock_rows.append(mock_row1)
|
||||
|
||||
mock_row2 = MagicMock()
|
||||
mock_row2.plugin_author = 'author2'
|
||||
mock_row2.plugin_name = 'middle_plugin'
|
||||
mock_row2.created_at = now - timedelta(days=5)
|
||||
mock_rows.append(mock_row2)
|
||||
|
||||
mock_row3 = MagicMock()
|
||||
mock_row3.plugin_author = 'author3'
|
||||
mock_row3.plugin_name = 'newest_plugin'
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify sorting: newest first
|
||||
assert len(result) == 3
|
||||
assert result[0]['manifest']['manifest']['metadata']['name'] == 'newest_plugin'
|
||||
assert result[1]['manifest']['manifest']['metadata']['name'] == 'middle_plugin'
|
||||
assert result[2]['manifest']['manifest']['metadata']['name'] == 'oldest_plugin'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_empty():
|
||||
"""Test that empty plugin list is handled correctly."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock empty plugin list
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[])
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify empty list
|
||||
assert len(result) == 0
|
||||
1865
web/package-lock.json
generated
1865
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.1.2",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
@@ -59,6 +60,11 @@
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
@@ -72,6 +78,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.4",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@@ -81,5 +88,6 @@
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.31.1"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
||||
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
|
||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
@@ -39,6 +40,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
);
|
||||
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
|
||||
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
|
||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||
PluginOperationType.DELETE,
|
||||
@@ -106,6 +109,11 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleViewReadme(plugin: PluginCardVO) {
|
||||
setReadmePlugin(plugin);
|
||||
setReadmeModalOpen(true);
|
||||
}
|
||||
|
||||
function handlePluginDelete(plugin: PluginCardVO) {
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.DELETE);
|
||||
@@ -316,6 +324,25 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
||||
<DialogTitle>
|
||||
{readmePlugin &&
|
||||
`${readmePlugin.author}/${readmePlugin.name} - ${t('plugins.readme')}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{readmePlugin && (
|
||||
<PluginReadme
|
||||
pluginAuthor={readmePlugin.author}
|
||||
pluginName={readmePlugin.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pluginList.map((vo, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
@@ -324,6 +351,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
onCardClick={() => handlePluginClick(vo)}
|
||||
onDeleteClick={() => handlePluginDelete(vo)}
|
||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||
onViewReadme={() => handleViewReadme(vo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,15 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
BugIcon,
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
Settings,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,53 +27,59 @@ export default function PluginCardComponent({
|
||||
onCardClick,
|
||||
onDeleteClick,
|
||||
onUpgradeClick,
|
||||
onViewReadme,
|
||||
}: {
|
||||
cardVO: PluginCardVO;
|
||||
onCardClick: () => void;
|
||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||
onViewReadme: (cardVO: PluginCardVO) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22]"
|
||||
onClick={onCardClick}
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!dropdownOpen) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* <svg
|
||||
className="w-16 h-16 text-[#2288ee]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 4C8 2.34315 9.34315 1 11 1C12.6569 1 14 2.34315 14 4C14 4.35064 13.9398 4.68722 13.8293 5H18C18.5523 5 19 5.44772 19 6V10.1707C19.3128 10.0602 19.6494 10 20 10C21.6569 10 23 11.3431 23 13C23 14.6569 21.6569 16 20 16C19.6494 16 19.3128 15.9398 19 15.8293V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H8.17071C8.06015 4.68722 8 4.35064 8 4Z"></path>
|
||||
</svg> */}
|
||||
{/* Icon - fixed width */}
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16 rounded-[8%]"
|
||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
|
||||
{/* Content area - flexible width with min-width to prevent overflow */}
|
||||
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
{/* Top content area - allows overflow with max height */}
|
||||
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex flex-col items-start justify-start w-full min-w-0">
|
||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||
{cardVO.author} / {cardVO.name}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
|
||||
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] flex-shrink-0"
|
||||
>
|
||||
v{cardVO.version}
|
||||
</Badge>
|
||||
{cardVO.debug && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-orange-400 text-orange-400"
|
||||
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
|
||||
>
|
||||
<BugIcon className="w-4 h-4" />
|
||||
{t('plugins.debugging')}
|
||||
@@ -76,23 +90,15 @@ export default function PluginCardComponent({
|
||||
{cardVO.install_source === 'github' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-blue-400 text-blue-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
cardVO.install_info.github_url,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
|
||||
>
|
||||
{t('plugins.fromGithub')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
{cardVO.install_source === 'local' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-green-400 text-green-400"
|
||||
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
|
||||
>
|
||||
{t('plugins.fromLocal')}
|
||||
</Badge>
|
||||
@@ -100,20 +106,9 @@ export default function PluginCardComponent({
|
||||
{cardVO.install_source === 'marketplace' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-purple-400 text-purple-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
getCloudServiceClientSync().getPluginMarketplaceURL(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
|
||||
>
|
||||
{t('plugins.fromMarketplace')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
@@ -121,12 +116,13 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999]">
|
||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
|
||||
{cardVO.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||
{/* Components list - fixed at bottom */}
|
||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem] flex-shrink-0 min-h-[1.5rem]">
|
||||
<PluginComponentList
|
||||
components={(() => {
|
||||
const componentKindCount: Record<string, number> = {};
|
||||
@@ -148,13 +144,25 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between h-full">
|
||||
{/* Menu button - fixed width and position */}
|
||||
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
|
||||
<div className="flex items-center justify-center"></div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenu
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open);
|
||||
if (!open) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
|
||||
>
|
||||
<Ellipsis className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -173,8 +181,33 @@ export default function PluginCardComponent({
|
||||
<span>{t('plugins.update')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/**view source */}
|
||||
{(cardVO.install_source === 'github' ||
|
||||
cardVO.install_source === 'marketplace') && (
|
||||
<DropdownMenuItem
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (cardVO.install_source === 'github') {
|
||||
window.open(cardVO.install_info.github_url, '_blank');
|
||||
} else if (cardVO.install_source === 'marketplace') {
|
||||
window.open(
|
||||
getCloudServiceClientSync().getPluginMarketplaceURL(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span>{t('plugins.viewSource')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteClick(cardVO);
|
||||
@@ -189,6 +222,45 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay with action buttons */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewReadme(cardVO);
|
||||
}}
|
||||
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{t('plugins.readme')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCardClick();
|
||||
}}
|
||||
variant="outline"
|
||||
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('plugins.config')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function PluginForm({
|
||||
itemConfigList={pluginInfo.manifest.manifest.spec.config}
|
||||
initialValues={pluginConfig.config as Record<string, object>}
|
||||
onSubmit={(values) => {
|
||||
// 只保存表单值的引用,不触发状态更新
|
||||
// 只保存表单值的引用,不触发状态更新
|
||||
currentFormValues.current = values;
|
||||
}}
|
||||
onFileUploaded={(fileKey) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { getAPILanguageCode } from '@/i18n/I18nProvider';
|
||||
import './github-markdown.css';
|
||||
|
||||
export default function PluginReadme({
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
}: {
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [readme, setReadme] = useState<string>('');
|
||||
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
|
||||
|
||||
const language = getAPILanguageCode();
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch plugin README
|
||||
setIsLoadingReadme(true);
|
||||
httpClient
|
||||
.getPluginReadme(pluginAuthor, pluginName, language)
|
||||
.then((res) => {
|
||||
setReadme(res.readme);
|
||||
})
|
||||
.catch(() => {
|
||||
setReadme('');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingReadme(false);
|
||||
});
|
||||
}, [pluginAuthor, pluginName]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-auto">
|
||||
{isLoadingReadme ? (
|
||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.loadingReadme')}
|
||||
</div>
|
||||
) : readme ? (
|
||||
<div className="markdown-body p-6 max-w-none pt-0">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
rehypeHighlight,
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: 'wrap',
|
||||
properties: {
|
||||
className: ['anchor'],
|
||||
},
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
img: ({ src, alt, ...props }) => {
|
||||
let imageSrc = src || '';
|
||||
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
imageSrc &&
|
||||
!imageSrc.startsWith('http://') &&
|
||||
!imageSrc.startsWith('https://') &&
|
||||
!imageSrc.startsWith('data:')
|
||||
) {
|
||||
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
|
||||
if (!imageSrc.startsWith('assets/')) {
|
||||
imageSrc = `assets/${imageSrc}`;
|
||||
}
|
||||
|
||||
const assetPath = imageSrc.replace(/^assets\//, '');
|
||||
imageSrc = httpClient.getPluginAssetURL(
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
assetPath,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.noReadme')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/* GitHub-style Markdown CSS */
|
||||
.markdown-body {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
color: var(--color-fg-default);
|
||||
background-color: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',
|
||||
Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Hide light theme highlight.js styles in dark mode */
|
||||
.dark .markdown-body .hljs {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Ensure code blocks have proper styling */
|
||||
.markdown-body pre code.hljs {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-body .octicon {
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-body h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.markdown-body h6 {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 0 1em;
|
||||
color: var(--color-fg-muted);
|
||||
border-left: 0.25em solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-body li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body li > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body li > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Nested lists */
|
||||
.markdown-body ul ul,
|
||||
.markdown-body ul ol,
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ol ul {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.markdown-body ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: var(--color-neutral-muted);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: var(--color-canvas-subtle);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
line-height: inherit;
|
||||
word-wrap: normal;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--color-accent-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body table tr {
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: var(--color-canvas-subtle);
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
background-color: var(--color-canvas-subtle);
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 50%;
|
||||
box-sizing: content-box;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: var(--color-border-default);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Light theme colors */
|
||||
.markdown-body {
|
||||
--color-fg-default: #1f2328;
|
||||
--color-fg-muted: #656d76;
|
||||
--color-canvas-subtle: #f6f8fa;
|
||||
--color-border-default: #d0d7de;
|
||||
--color-border-muted: #d8dee4;
|
||||
--color-neutral-muted: rgba(175, 184, 193, 0.2);
|
||||
--color-accent-fg: #0969da;
|
||||
}
|
||||
|
||||
/* Dark theme colors */
|
||||
.dark .markdown-body {
|
||||
--color-fg-default: #e6edf3;
|
||||
--color-fg-muted: #8d96a0;
|
||||
--color-canvas-subtle: #161b22;
|
||||
--color-border-default: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
--color-neutral-muted: rgba(110, 118, 129, 0.4);
|
||||
--color-accent-fg: #4493f8;
|
||||
}
|
||||
|
||||
/* Code highlighting styles */
|
||||
.markdown-body .hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--color-fg-default);
|
||||
}
|
||||
|
||||
/* Light theme syntax highlighting */
|
||||
.markdown-body .hljs-comment,
|
||||
.markdown-body .hljs-quote {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-keyword,
|
||||
.markdown-body .hljs-selector-tag,
|
||||
.markdown-body .hljs-subst {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-number,
|
||||
.markdown-body .hljs-literal,
|
||||
.markdown-body .hljs-variable,
|
||||
.markdown-body .hljs-template-variable,
|
||||
.markdown-body .hljs-tag .hljs-attr {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-string,
|
||||
.markdown-body .hljs-doctag {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-title,
|
||||
.markdown-body .hljs-section,
|
||||
.markdown-body .hljs-selector-id {
|
||||
color: #6f42c1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-type,
|
||||
.markdown-body .hljs-class .hljs-title {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-tag,
|
||||
.markdown-body .hljs-name,
|
||||
.markdown-body .hljs-attribute {
|
||||
color: #22863a;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-regexp,
|
||||
.markdown-body .hljs-link {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-symbol,
|
||||
.markdown-body .hljs-bullet {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-built_in,
|
||||
.markdown-body .hljs-builtin-name {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-meta {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-deletion {
|
||||
background-color: #ffeef0;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-addition {
|
||||
background-color: #e6ffed;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-body .hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Dark theme syntax highlighting */
|
||||
.dark .markdown-body .hljs-comment,
|
||||
.dark .markdown-body .hljs-quote {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-keyword,
|
||||
.dark .markdown-body .hljs-selector-tag,
|
||||
.dark .markdown-body .hljs-subst {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-number,
|
||||
.dark .markdown-body .hljs-literal,
|
||||
.dark .markdown-body .hljs-variable,
|
||||
.dark .markdown-body .hljs-template-variable,
|
||||
.dark .markdown-body .hljs-tag .hljs-attr {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-string,
|
||||
.dark .markdown-body .hljs-doctag {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-title,
|
||||
.dark .markdown-body .hljs-section,
|
||||
.dark .markdown-body .hljs-selector-id {
|
||||
color: #d2a8ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-type,
|
||||
.dark .markdown-body .hljs-class .hljs-title {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-tag,
|
||||
.dark .markdown-body .hljs-name,
|
||||
.dark .markdown-body .hljs-attribute {
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-regexp,
|
||||
.dark .markdown-body .hljs-link {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-symbol,
|
||||
.dark .markdown-body .hljs-bullet {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-built_in,
|
||||
.dark .markdown-body .hljs-builtin-name {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-meta {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-deletion {
|
||||
background-color: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.dark .markdown-body .hljs-addition {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function PluginMarketCardComponent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] relative"
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -137,25 +137,33 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
{/* Hover overlay with action buttons */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-gray-100/65 dark:bg-black/40 rounded-[10px] flex items-center justify-center gap-3 transition-opacity duration-200">
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('market.install')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleViewDetailsClick}
|
||||
variant="outline"
|
||||
className="bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-lg flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('market.viewDetails')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('market.install')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleViewDetailsClick}
|
||||
variant="outline"
|
||||
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('market.viewDetails')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,6 +483,27 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.delete(`/api/v1/plugins/config-files/${fileKey}`);
|
||||
}
|
||||
|
||||
public getPluginReadme(
|
||||
author: string,
|
||||
name: string,
|
||||
language: string = 'en',
|
||||
): Promise<{ readme: string }> {
|
||||
return this.get(
|
||||
`/api/v1/plugins/${author}/${name}/readme?language=${language}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginAssetURL(
|
||||
author: string,
|
||||
name: string,
|
||||
filepath: string,
|
||||
): string {
|
||||
return (
|
||||
this.instance.defaults.baseURL +
|
||||
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginIconURL(author: string, name: string): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
const url = window.location.href;
|
||||
|
||||
@@ -280,6 +280,11 @@ const enUS = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'Configuration saved successfully, please manually restart the plugin',
|
||||
saveConfigError: 'Configuration save failed: ',
|
||||
config: 'Configuration',
|
||||
readme: 'Documentation',
|
||||
viewSource: 'View Source',
|
||||
loadingReadme: 'Loading documentation...',
|
||||
noReadme: 'This plugin does not provide README documentation',
|
||||
fileUpload: {
|
||||
tooLarge: 'File size exceeds 10MB limit',
|
||||
success: 'File uploaded successfully',
|
||||
|
||||
@@ -281,6 +281,11 @@ const jaJP = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'設定を保存しました。手動でプラグインを再起動してください',
|
||||
saveConfigError: '設定の保存に失敗しました:',
|
||||
config: '設定',
|
||||
readme: 'ドキュメント',
|
||||
viewSource: 'ソースを表示',
|
||||
loadingReadme: 'ドキュメントを読み込み中...',
|
||||
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
||||
fileUpload: {
|
||||
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
||||
success: 'ファイルのアップロードに成功しました',
|
||||
|
||||
@@ -266,6 +266,11 @@ const zhHans = {
|
||||
saveConfigSuccessNormal: '保存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
|
||||
saveConfigError: '保存配置失败:',
|
||||
config: '配置',
|
||||
readme: '文档',
|
||||
viewSource: '查看来源',
|
||||
loadingReadme: '正在加载文档...',
|
||||
noReadme: '该插件没有提供 README 文档',
|
||||
fileUpload: {
|
||||
tooLarge: '文件大小超过 10MB 限制',
|
||||
success: '文件上传成功',
|
||||
|
||||
@@ -265,6 +265,11 @@ const zhHant = {
|
||||
saveConfigSuccessNormal: '儲存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
||||
saveConfigError: '儲存配置失敗:',
|
||||
config: '配置',
|
||||
readme: '文件',
|
||||
viewSource: '查看來源',
|
||||
loadingReadme: '正在載入文件...',
|
||||
noReadme: '該插件沒有提供 README 文件',
|
||||
fileUpload: {
|
||||
tooLarge: '檔案大小超過 10MB 限制',
|
||||
success: '檔案上傳成功',
|
||||
|
||||
10
web/src/i18next.d.ts
vendored
Normal file
10
web/src/i18next.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'react-i18next';
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'translation';
|
||||
resources: {
|
||||
translation: typeof import('./i18n/locales/zh-Hans').default;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user