Compare commits

..

1 Commits

Author SHA1 Message Date
dadachann 8e3d541876 docs(readme): remove retired public demo from all languages 2026-09-14 17:08:31 +00:00
31 changed files with 190 additions and 1189 deletions
-2
View File
@@ -69,5 +69,3 @@ packaging/fnos/ICON_256.PNG
packaging/fnos/app/ui/images/
packaging/fnos/app/desktop/images/
packaging/fnos/*.fpk
r.ps1
-1
View File
@@ -58,7 +58,6 @@ dependencies = [
"python-docx>=1.1.0",
"pandas>=2.2.2",
"chardet>=5.2.0",
"xxhash>=3.5.0",
"markdown>=3.6",
"beautifulsoup4>=4.12.3",
"ebooklib>=0.18",
@@ -47,21 +47,6 @@ class FilesRouterGroup(group.RouterGroup):
return quart.Response(image_bytes, mimetype=mime_type)
@self.route(
'/media/<filename>',
methods=['GET'],
auth_type=group.AuthType.NONE,
)
async def get_media_file(filename: str) -> quart.Response:
media = await self.ap.storage_mgr.media_cache.get_media(filename)
if media is None:
return quart.Response('Media not found or expired', status=404)
media_bytes, mime_type = media
headers = {
'Cache-Control': 'public, max-age=2592000, immutable',
}
return quart.Response(media_bytes, mimetype=mime_type, headers=headers)
@self.route(
'/images',
methods=['POST'],
@@ -80,40 +80,16 @@ class MaintenanceService:
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
media_cfg = self.ap.instance_config.data.get('storage', {}).get('media_cache', {})
media_retention_days = self._positive_int(
media_cfg.get('retention_days'),
30,
'storage.media_cache.retention_days',
)
media_max_size_mb = self._non_negative_int(
media_cfg.get('max_size_mb'),
0,
'storage.media_cache.max_size_mb',
)
media_cache = getattr(getattr(self.ap, 'storage_mgr', None), 'media_cache', None)
is_singleton = await self._is_oss_singleton(context)
media_cleanup = (
await media_cache.cleanup(
media_retention_days,
media_max_size_mb,
)
if media_cache is not None and is_singleton
else {}
)
result = {
return {
'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
'log_files': await asyncio.to_thread(
self._cleanup_expired_log_files,
log_retention_days,
)
if is_singleton
if await self._is_oss_singleton(context)
else 0,
}
if media_cache is not None and is_singleton:
result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0)
return result
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
require_workspace_uuid(context)
@@ -490,17 +466,6 @@ class MaintenanceService:
count += len(files)
return count
def _non_negative_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 0:
self.ap.logger.warning(f'{name} must be non-negative: {value!r}, using {default}')
return default
return parsed
def _positive_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
@@ -1,6 +1,5 @@
from __future__ import annotations
import re
import uuid
import datetime
import functools
@@ -418,32 +417,6 @@ class MonitoringService:
# ========== Recording Methods ==========
def _sanitize_message_content(self, content: str) -> str:
"""Strip raw base64 data to protect database storage from unbounded bloating."""
if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
return content
try:
data = json.loads(content)
def _strip_node(node):
if isinstance(node, list):
return [_strip_node(x) for x in node]
if isinstance(node, dict):
res = dict(node)
if res.get('type') == 'Image' and res.get('base64'):
res['base64'] = None
for k, v in list(res.items()):
if isinstance(v, (list, dict)):
res[k] = _strip_node(v)
return res
return node
return json.dumps(_strip_node(data), ensure_ascii=False)
except Exception:
return re.sub(
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
)
@_workspace_transaction
async def record_message(
self,
@@ -466,7 +439,6 @@ class MonitoringService:
"""Record a message"""
workspace_uuid = self._require_write_context(context)
message_id = str(uuid.uuid4())
message_content = self._sanitize_message_content(message_content)
message_data = {
'id': message_id,
'workspace_uuid': workspace_uuid,
-1
View File
@@ -43,7 +43,6 @@ required_deps = {
'slack_sdk': 'slack_sdk',
'asyncpg': 'asyncpg',
'litellm': 'litellm',
'xxhash': 'xxhash',
}
@@ -48,10 +48,7 @@ class MonitoringHelper:
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
chain_dump = query.message_chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
else:
message_content = str(query)
@@ -171,10 +168,7 @@ class MonitoringHelper:
if hasattr(last_resp, 'get_content_platform_message_chain'):
chain = last_resp.get_content_platform_message_chain()
if hasattr(chain, 'model_dump'):
chain_dump = chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = json.dumps(chain.model_dump(), ensure_ascii=False)
else:
message_content = str(chain)
else:
+1 -4
View File
@@ -210,10 +210,7 @@ class RuntimeBot:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
chain_dump = message_chain.model_dump()
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
else:
message_content = str(message_chain)
-41
View File
@@ -87,10 +87,8 @@ async def _read_httpx_response_limited(
response: httpx.Response,
*,
max_bytes: int,
task_context: taskmgr.TaskContext | None = None,
) -> bytes:
content_length = response.headers.get('content-length')
declared_size: int | None = None
if content_length is not None:
try:
declared_size = int(content_length)
@@ -99,23 +97,11 @@ async def _read_httpx_response_limited(
if declared_size is not None and declared_size > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
if task_context is not None and declared_size is not None:
task_context.metadata['download_total'] = declared_size
start_time = time.time()
body = bytearray()
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
body.extend(chunk)
if len(body) > max_bytes:
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
if task_context is not None:
elapsed = time.time() - start_time
task_context.metadata.update(
{
'download_current': len(body),
'download_speed': len(body) / elapsed if elapsed > 0 else 0,
}
)
return bytes(body)
@@ -125,7 +111,6 @@ async def _marketplace_get(
*,
max_bytes: int,
allow_not_found: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> tuple[int, bytes]:
async with client.stream('GET', url) as response:
if allow_not_found and response.status_code == 404:
@@ -134,7 +119,6 @@ async def _marketplace_get(
return response.status_code, await _read_httpx_response_limited(
response,
max_bytes=max_bytes,
task_context=task_context,
)
@@ -1696,7 +1680,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
client,
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
task_context=task_context,
)
return plugin_package, latest_version
@@ -1712,21 +1695,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name = str(install_info.get('plugin_name') or '')
file_bytes: bytes | None
if task_context is not None:
# Reset per-install progress counters so a re-install of the same
# plugin does not inherit stale metadata from a previous task.
task_context.set_current_action('preparing plugin install')
task_context.metadata.update(
{
'download_total': 0,
'download_current': 0,
'download_speed': 0,
}
)
if install_source == PluginInstallSource.MARKETPLACE:
if task_context is not None:
task_context.set_current_action('downloading plugin package')
file_bytes, version = await self._download_marketplace_package(
execution_context,
plugin_author,
@@ -1750,8 +1719,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
else:
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
if task_context is not None:
task_context.set_current_action('inspecting plugin package')
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
if not manifest_author or not manifest_name:
raise ValueError('Plugin package manifest identity is missing')
@@ -1763,12 +1730,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if task_context is not None:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
if task_context is not None:
task_context.set_current_action('storing plugin package')
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
if task_context is not None:
task_context.set_current_action('installing plugin dependencies')
try:
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
execution_context,
@@ -1786,8 +1749,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author=plugin_author,
plugin_name=plugin_name,
)
if task_context is not None:
task_context.set_current_action('launching plugin')
await self._apply_desired_state(
PluginInstallationDesiredState(binding=binding, enabled=True),
artifact_package=file_bytes,
@@ -1805,8 +1766,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
if task_context is not None:
task_context.set_current_action('waiting for plugin to become ready')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin(
-231
View File
@@ -1,231 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import copy
import datetime
import mimetypes
import os
import re
import xxhash
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ...core import app
from . import mgr as storage_mgr
DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_SIZE_MB = 0
MEDIA_DIR = 'media_cache'
SAFE_MEDIA_FILENAME = re.compile(r'^[a-f0-9]{32,64}(\.[a-zA-Z0-9]{1,10})?$')
class MediaCache:
"""Content-addressable storage cache for images and media attachments.
Deduplicates media files using xxHash3-128,
offloads payloads from SQLite to StorageProvider, and implements LRU
and age-based retention cleanup.
"""
def __init__(self, ap: app.Application, storage_mgr: storage_mgr.StorageMgr):
self.ap = ap
self.storage_mgr = storage_mgr
@staticmethod
def hash_bytes(data: bytes) -> str:
"""Compute content-addressable hash for binary data."""
return xxhash.xxh3_128_hexdigest(data)
@staticmethod
def parse_data_url(data_url: str) -> tuple[bytes, str] | None:
"""Parse a data URL or raw base64 string into bytes and mime type."""
if not data_url or not isinstance(data_url, str):
return None
try:
if data_url.startswith('data:'):
split_index = data_url.find(';base64,')
if split_index != -1:
mime_type = data_url[5:split_index]
b64_data = data_url[split_index + 8 :]
return base64.b64decode(b64_data), mime_type
# Try raw base64 if sufficiently long
if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')):
return base64.b64decode(data_url), 'application/octet-stream'
except Exception:
return None
return None
@staticmethod
def guess_extension(mime_type: str | None, default: str = '.jpg') -> str:
"""Guess appropriate file extension from MIME type."""
if not mime_type:
return default
mime_lower = mime_type.lower()
if 'png' in mime_lower:
return '.png'
if 'webp' in mime_lower:
return '.webp'
if 'gif' in mime_lower:
return '.gif'
if 'jpeg' in mime_lower or 'jpg' in mime_lower:
return '.jpg'
ext = mimetypes.guess_extension(mime_type)
if ext == '.jpe':
return '.jpg'
return ext or default
async def save_media(self, data: bytes, mime_type: str | None = None) -> tuple[str, str, int]:
"""Save media bytes into content-addressable storage cache.
Returns:
Tuple of (hash_str, storage_key, byte_size)
"""
hash_str = self.hash_bytes(data)
ext = self.guess_extension(mime_type)
storage_key = f'{MEDIA_DIR}/{hash_str}{ext}'
provider = self.storage_mgr.storage_provider
if not await provider.exists(storage_key):
await provider.save(storage_key, data)
else:
await self.touch(storage_key)
return hash_str, storage_key, len(data)
async def get_media(self, filename_or_key: str) -> tuple[bytes, str] | None:
"""Retrieve media bytes and mime type by key or filename."""
filename = os.path.basename(filename_or_key)
if not SAFE_MEDIA_FILENAME.match(filename):
return None
storage_key = f'{MEDIA_DIR}/{filename}'
provider = self.storage_mgr.storage_provider
if not await provider.exists(storage_key):
return None
data = await self.storage_mgr._load_object_bounded(storage_key)
mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
await self.touch(storage_key)
return data, mime_type
async def touch(self, storage_key: str) -> None:
"""Update access/modified time of a media file for LRU tracking."""
provider = getattr(self.storage_mgr, 'storage_provider', None)
if provider is not None and provider.__class__.__name__ == 'LocalStorageProvider':
full_path = os.path.join('data', 'storage', storage_key)
if os.path.exists(full_path):
now = datetime.datetime.now().timestamp()
try:
await asyncio.to_thread(os.utime, full_path, (now, now))
except Exception:
# Failures to update mtime are intentionally ignored because LRU touch is opportunistic.
pass
async def cleanup(
self,
retention_days: int = DEFAULT_RETENTION_DAYS,
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
) -> dict[str, int]:
"""Perform age-based and LRU size-based cleanup on media cache.
Args:
retention_days: Retain media accessed within this many days (default 30).
max_size_mb: Maximum total size in MB (0 means unlimited).
Returns:
Dictionary of cleanup metrics.
"""
provider = getattr(self.storage_mgr, 'storage_provider', None)
if provider is None or provider.__class__.__name__ != 'LocalStorageProvider':
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
target_dir = Path('data/storage') / MEDIA_DIR
if not target_dir.exists() or not target_dir.is_dir():
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
now = datetime.datetime.now().timestamp()
cutoff = (now - retention_days * 86400) if retention_days > 0 else 0
expired_deleted = 0
size_deleted = 0
bytes_freed = 0
remaining: list[tuple[Path, int, float]] = []
for entry in target_dir.iterdir():
if not entry.is_file():
continue
try:
stat = entry.stat()
except OSError:
continue
if cutoff > 0 and stat.st_mtime < cutoff:
try:
entry.unlink(missing_ok=True)
expired_deleted += 1
bytes_freed += stat.st_size
except OSError:
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
pass
else:
remaining.append((entry, stat.st_size, stat.st_mtime))
if max_size_mb > 0:
max_bytes = max_size_mb * 1024 * 1024
total_bytes = sum(item[1] for item in remaining)
if total_bytes > max_bytes:
remaining.sort(key=lambda item: item[2])
for path, size, _ in remaining:
if total_bytes <= max_bytes:
break
try:
path.unlink(missing_ok=True)
size_deleted += 1
bytes_freed += size
total_bytes -= size
except OSError:
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
continue
return {
'expired_deleted': expired_deleted,
'size_deleted': size_deleted,
'bytes_freed': bytes_freed,
}
async def externalize_chain_dump(self, chain_dump: Any) -> Any:
"""Recursively extract raw base64 media into cache and replace with references."""
if isinstance(chain_dump, list):
return [await self.externalize_chain_dump(item) for item in chain_dump]
if isinstance(chain_dump, dict):
node = copy.copy(chain_dump)
node_type = node.get('type')
if node_type == 'Image':
b64 = node.get('base64')
if b64 and isinstance(b64, str):
try:
parsed = self.parse_data_url(b64)
if parsed is not None:
raw_bytes, mime_type = parsed
hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
filename = os.path.basename(storage_key)
current_url = node.get('url') or ''
if current_url and not current_url.startswith('data:'):
node['original_url'] = current_url
node['url'] = f'/api/v1/files/media/{filename}'
node['base64'] = None
node['hash'] = hash_str
node['storage_key'] = storage_key
node['size'] = size
node['mime_type'] = mime_type
except Exception as e:
if hasattr(self.ap, 'logger') and self.ap.logger:
self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
node['base64'] = None
for k, v in list(node.items()):
if isinstance(v, (list, dict)):
node[k] = await self.externalize_chain_dump(v)
return node
return chain_dump
-3
View File
@@ -34,9 +34,6 @@ class StorageMgr:
def __init__(self, ap: app.Application):
self.ap = ap
from . import media
self.media_cache = media.MediaCache(ap, self)
def _object_read_limit(self) -> int:
config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
-6
View File
@@ -227,12 +227,6 @@ storage:
# Bound every object materialized into Core memory. Built-in Local/S3
# providers enforce this while reading (hard cap: 64 MiB).
max_object_read_bytes: 10485760
# Media content cache (images & attachments externalized from monitoring and pipelines)
media_cache:
# Retention period in days for cached media (defaults to 30 days)
retention_days: 30
# Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0)
max_size_mb: 0
cleanup:
# Enable periodic cleanup of local/S3 uploaded files and old log files
enabled: true
@@ -1,215 +0,0 @@
import base64
import datetime
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from quart import Quart
from langbot.pkg.storage.media import MediaCache, SAFE_MEDIA_FILENAME
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup
class TestMediaCache:
def setup_method(self):
self.mock_app = Mock()
self.mock_app.logger = Mock()
self.mock_storage_mgr = Mock()
self.mock_provider = Mock()
self.mock_provider.__class__.__name__ = 'LocalStorageProvider'
self.mock_provider.exists = AsyncMock(return_value=False)
self.mock_provider.save = AsyncMock()
self.mock_provider.load = AsyncMock()
self.mock_storage_mgr.storage_provider = self.mock_provider
self.mock_storage_mgr._load_object_bounded = AsyncMock()
self.media_cache = MediaCache(self.mock_app, self.mock_storage_mgr)
def test_safe_media_filename_regex(self):
assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png')
assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.jpg')
assert SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c')
assert not SAFE_MEDIA_FILENAME.match('../etc/passwd')
assert not SAFE_MEDIA_FILENAME.match('foo/bar.png')
assert not SAFE_MEDIA_FILENAME.match('test.exe')
assert not SAFE_MEDIA_FILENAME.match('3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3cXpng')
def test_hash_bytes(self):
data1 = b'hello image content'
data2 = b'hello image content'
data3 = b'different content'
assert self.media_cache.hash_bytes(data1) == self.media_cache.hash_bytes(data2)
assert self.media_cache.hash_bytes(data1) != self.media_cache.hash_bytes(data3)
def test_parse_data_url(self):
raw = b'png binary data here'
b64_str = base64.b64encode(raw).decode('ascii')
data_url = f'data:image/png;base64,{b64_str}'
parsed = self.media_cache.parse_data_url(data_url)
assert parsed is not None
data, mime = parsed
assert data == raw
assert mime == 'image/png'
@pytest.mark.asyncio
async def test_save_media_deduplication(self):
raw = b'fake png bytes'
hash_str = self.media_cache.hash_bytes(raw)
# First save: provider.exists is False -> calls provider.save
h1, key1, size1 = await self.media_cache.save_media(raw, 'image/png')
assert h1 == hash_str
assert key1 == f'media_cache/{hash_str}.png'
assert size1 == len(raw)
self.mock_provider.save.assert_called_once_with(key1, raw)
# Second save: provider.exists is True -> does not call provider.save again
self.mock_provider.exists.return_value = True
self.mock_provider.save.reset_mock()
h2, key2, size2 = await self.media_cache.save_media(raw, 'image/png')
assert h2 == h1
assert key2 == key1
self.mock_provider.save.assert_not_called()
@pytest.mark.asyncio
async def test_get_media(self):
raw = b'stored bytes'
self.mock_provider.exists.return_value = True
self.mock_storage_mgr._load_object_bounded.return_value = raw
valid_name = '3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png'
res = await self.media_cache.get_media(valid_name)
assert res is not None
data, mime = res
assert data == raw
assert mime == 'image/png'
# Rejects invalid names
assert await self.media_cache.get_media('../malicious.png') is None
@pytest.mark.asyncio
async def test_externalize_chain_dump(self):
raw = b'tiny image'
b64 = f'data:image/png;base64,{base64.b64encode(raw).decode("ascii")}'
chain_dump = [
{'type': 'Plain', 'text': 'hello'},
{'type': 'Image', 'url': 'https://multimedia.nt.qq.com.cn/download?appid=1407', 'base64': b64},
{'type': 'Quote', 'origin': [{'type': 'Image', 'url': '', 'base64': b64}]},
]
result = await self.media_cache.externalize_chain_dump(chain_dump)
# Root image
img = result[1]
assert img['base64'] is None
assert img['hash'] == self.media_cache.hash_bytes(raw)
assert img['storage_key'].startswith('media_cache/')
assert img['original_url'] == 'https://multimedia.nt.qq.com.cn/download?appid=1407'
assert img['url'] == f'/api/v1/files/media/{img["hash"]}.png'
assert img['size'] == len(raw)
# Nested quote image
nested_img = result[2]['origin'][0]
assert nested_img['base64'] is None
assert nested_img['hash'] == self.media_cache.hash_bytes(raw)
assert nested_img['url'] == f'/api/v1/files/media/{nested_img["hash"]}.png'
@pytest.mark.asyncio
async def test_externalize_chain_dump_error_resilience(self):
raw = b'broken image'
b64 = f'data:image/png;base64,{base64.b64encode(raw).decode("ascii")}'
chain_dump = [{'type': 'Image', 'base64': b64}]
with patch.object(self.media_cache, 'save_media', side_effect=OSError('Disk full')):
result = await self.media_cache.externalize_chain_dump(chain_dump)
# Should not raise; base64 should be stripped as fallback
assert result[0]['base64'] is None
@pytest.mark.asyncio
async def test_cleanup_retention_and_max_size(self):
with tempfile.TemporaryDirectory() as temp_dir:
base_path = Path(temp_dir)
cache_dir = base_path / 'data' / 'storage' / 'media_cache'
cache_dir.mkdir(parents=True)
# Create 3 test files with different mtimes and sizes
f1 = cache_dir / 'old_expired.png'
f1.write_bytes(b'x' * 1000)
old_time = (datetime.datetime.now() - datetime.timedelta(days=35)).timestamp()
os.utime(f1, (old_time, old_time))
f2 = cache_dir / 'recent_large1.png'
f2.write_bytes(b'x' * 500)
t2 = (datetime.datetime.now() - datetime.timedelta(days=5)).timestamp()
os.utime(f2, (t2, t2))
f3 = cache_dir / 'recent_large2.png'
f3.write_bytes(b'x' * 500)
t3 = (datetime.datetime.now() - datetime.timedelta(days=1)).timestamp()
os.utime(f3, (t3, t3))
with patch('langbot.pkg.storage.media.Path') as mock_path:
mock_path.return_value = base_path / 'data' / 'storage'
# Run cleanup with 30-day retention and max_size_mb = 0 (unlimited)
stats = await self.media_cache.cleanup(retention_days=30, max_size_mb=0)
assert stats['expired_deleted'] == 1
assert not f1.exists()
assert f2.exists()
assert f3.exists()
# Run cleanup with max_size_mb limited to ~0.0006 MB (< 1000 bytes)
# Total is currently 1000 bytes (f2=500 + f3=500). Max size 600 bytes -> oldest f2 must be purged
stats2 = await self.media_cache.cleanup(retention_days=30, max_size_mb=0.0006)
assert stats2['size_deleted'] >= 1
assert not f2.exists()
assert f3.exists()
@pytest.mark.asyncio
async def test_files_media_endpoint(self):
quart_app = Quart(__name__)
mock_app = Mock()
mock_app.storage_mgr = self.mock_storage_mgr
router = FilesRouterGroup(mock_app, quart_app)
await router.initialize()
client = quart_app.test_client()
# 1. 404 on not found
mock_cache = Mock()
mock_cache.get_media = AsyncMock(return_value=None)
self.mock_storage_mgr.media_cache = mock_cache
resp_404 = await client.get('/api/v1/files/media/3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png')
assert resp_404.status_code == 404
# 2. 200 on found with cache headers
mock_cache.get_media.return_value = (b'fake image data', 'image/png')
resp_200 = await client.get('/api/v1/files/media/3f8a9b1c2d3e4f5a6b7c8d9e0f1a2b3c.png')
assert resp_200.status_code == 200
assert await resp_200.get_data() == b'fake image data'
assert 'public' in resp_200.headers.get('Cache-Control', '')
assert 'image/png' in resp_200.headers.get('Content-Type', '')
class TestMonitoringServiceSanitization:
def test_sanitize_oversized_base64_payload(self):
svc = MonitoringService.__new__(MonitoringService)
small_content = '{"type": "Image", "base64": "data:image/png;base64,tiny"}'
# Should leave small contents untouched
assert svc._sanitize_message_content(small_content) == small_content
# Large content with base64 data URL
huge_b64 = 'A' * 60000
large_content = f'{{"type": "Image", "base64": "data:image/png;base64,{huge_b64}"}}'
sanitized = svc._sanitize_message_content(large_content)
assert huge_b64 not in sanitized
assert '"base64": null' in sanitized or '"base64":null' in sanitized
# Non-JSON content with multi-line base64
multiline_b64 = ('A' * 70 + '\r\n') * 300
raw_corrupted = 'prefix data:image/png;base64,' + multiline_b64 + ' suffix'
sanitized_raw = svc._sanitize_message_content(raw_corrupted)
assert '[base64 image omitted]' in sanitized_raw
assert multiline_b64 not in sanitized_raw
Generated
+59 -61
View File
@@ -1066,7 +1066,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder" },
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1099,34 +1099,34 @@ wheels = [
[package.optional-dependencies]
cudart = [
{ name = "nvidia-cuda-runtime" },
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufft = [
{ name = "nvidia-cufft" },
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufile = [
{ name = "nvidia-cufile" },
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cupti = [
{ name = "nvidia-cuda-cupti" },
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
curand = [
{ name = "nvidia-curand" },
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusolver = [
{ name = "nvidia-cusolver" },
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusparse = [
{ name = "nvidia-cusparse" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvtx = [
{ name = "nvidia-nvtx" },
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
[[package]]
@@ -2135,7 +2135,6 @@ dependencies = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "webauthn" },
{ name = "websockets" },
{ name = "xxhash" },
]
[package.optional-dependencies]
@@ -2234,7 +2233,6 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "webauthn", specifier = ">=3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
{ name = "xxhash", specifier = ">=3.5.0" },
]
provides-extras = ["seekdb"]
@@ -3301,7 +3299,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3340,7 +3338,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3352,7 +3350,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3382,9 +3380,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3396,7 +3394,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4489,7 +4487,7 @@ name = "pylibseekdb"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymysql" },
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" },
@@ -5257,10 +5255,10 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -5307,7 +5305,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5378,14 +5376,14 @@ name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "scikit-learn" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
{ name = "typing-extensions" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
@@ -5758,21 +5756,21 @@ name = "torch"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5814,15 +5812,15 @@ name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
{ name = "typer" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
@@ -6083,9 +6081,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button';
import {
Download,
Package,
Rocket,
Server,
Sparkles,
CheckCircle2,
@@ -40,27 +39,11 @@ const STAGES: {
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
},
];
/**
* Find the row that should be highlighted for a given stage.
* LAUNCHING/INITIALIZING/DONE collapse onto the launching row.
*/
function getStageIndex(stage: InstallStage): number {
if (
stage === InstallStage.LAUNCHING ||
stage === InstallStage.INITIALIZING ||
stage === InstallStage.DONE
) {
return STAGES.length - 1;
}
const idx = STAGES.findIndex((s) => s.key === stage);
return idx >= 0 ? idx : 0;
return idx >= 0 ? idx : -1;
}
function formatFileSize(bytes: number): string {
@@ -186,12 +169,9 @@ function formatSpeed(bytesPerSec: number): string {
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const { t } = useTranslation();
const currentStageIndex = getStageIndex(task.stage);
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
// When a task fails, `stage` becomes ERROR — fall back to the furthest
// stage it actually reached so the failed phase is still displayed.
const displayStage = isError && task.lastStage ? task.lastStage : task.stage;
const currentStageIndex = getStageIndex(displayStage);
// MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps.
@@ -27,9 +27,6 @@ export interface PluginInstallTask {
pluginName: string; // display name
source: 'github' | 'marketplace' | 'local';
stage: InstallStage;
/** Furthest non-terminal stage reached kept when the task fails so the
* UI can still show which phase failed. */
lastStage?: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
@@ -46,8 +43,6 @@ export interface PluginInstallTask {
depsSpeed?: number; // deps download speed bytes/s
error?: string;
startedAt: number; // timestamp
/** Timestamp when the current stage began; used for smooth creeping. */
stageStartedAt?: number;
currentAction: string; // raw backend action string
}
@@ -88,159 +83,43 @@ export function usePluginInstallTasks() {
return ctx;
}
/**
* Ordered lifecycle stages. Used to enforce forward-only transitions so the
* progress bar never moves backwards while a task is running.
*/
const STAGE_ORDER: InstallStage[] = [
InstallStage.DOWNLOADING,
InstallStage.INSTALLING_DEPS,
InstallStage.INITIALIZING,
InstallStage.LAUNCHING,
InstallStage.DONE,
];
/**
* Lower bound (%) for each stage. A task's progress is never allowed to drop
* below the floor of the furthest stage it has already reached.
*/
const STAGE_FLOOR: Record<InstallStage, number> = {
[InstallStage.DOWNLOADING]: 2,
[InstallStage.INSTALLING_DEPS]: 55,
[InstallStage.INITIALIZING]: 85,
[InstallStage.LAUNCHING]: 94,
[InstallStage.DONE]: 100,
[InstallStage.ERROR]: 0,
};
/** Get the lower-bound percentage for a stage. */
function stageFloor(stage: InstallStage): number {
return STAGE_FLOOR[stage] ?? 0;
}
/** Get the lower bound of the stage that follows the given one. */
function nextStageFloor(stage: InstallStage): number {
const idx = STAGE_ORDER.indexOf(stage);
const next = idx >= 0 ? STAGE_ORDER[idx + 1] : undefined;
return next ? stageFloor(next) : 100;
}
/** Return whichever stage is further along in the lifecycle. */
function maxStage(current: InstallStage, incoming: InstallStage): InstallStage {
const currentIdx = STAGE_ORDER.indexOf(current);
const incomingIdx = STAGE_ORDER.indexOf(incoming);
if (currentIdx === -1) return incoming;
if (incomingIdx === -1) return current;
return incomingIdx >= currentIdx ? incoming : current;
}
/**
* Map backend `current_action` to our InstallStage.
*
* Unknown / transitional actions must NOT map back to an earlier stage,
* otherwise the bar would jump backwards mid-install.
*/
function mapActionToStage(action: string): InstallStage {
const lower = (action || '').toLowerCase();
if (!lower) return InstallStage.DOWNLOADING;
// "preparing"/"resolving" happen before any bytes land on disk.
if (lower.includes('prepar') || lower.includes('resolv'))
return InstallStage.DOWNLOADING;
if (lower.includes('download') && !lower.includes('dependenc'))
return InstallStage.DOWNLOADING;
// Activation / readiness tail phase — its own slice of the bar.
if (
lower.includes('launch') ||
lower.includes('start') ||
lower.includes('wait') ||
lower.includes('ready') ||
lower.includes('initializ')
) {
return InstallStage.LAUNCHING;
}
// Dependency installation and package finalization.
if (
lower.includes('dependenc') ||
lower.includes('requirements') ||
lower.includes('parsing') ||
lower.includes('extract') ||
lower.includes('inspect') ||
lower.includes('persist') ||
lower.includes('stor') ||
lower.includes('install') ||
lower.includes('setting')
) {
if (!action) return InstallStage.DOWNLOADING;
const lower = action.toLowerCase();
if (lower.includes('download')) return InstallStage.DOWNLOADING;
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
}
// Unknown transitional actions belong to the busy middle of the install.
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
}
/**
* Time-based creep so the bar keeps moving when no counters exist.
*
* Uses an asymptote so the increment decelerates as it approaches the stage
* ceiling the bar always feels alive but never overshoots into the next
* stage's range.
* Get overall progress percentage from a stage.
*/
function creep(stageStartedAt: number, span: number): number {
if (span <= 0) return 0;
const elapsed = (Date.now() - stageStartedAt) / 1000;
// Approaching `span` asymptotically: after ~60s we are ~86% of the span.
const ratio = 1 - Math.exp(-elapsed / 30);
return span * ratio;
}
/**
* Compute a progress value for the current stage.
*
* Real byte / dependency counters drive the value when available; otherwise
* the value creeps forward slowly based on elapsed time. Callers are expected
* to combine the result with the previous value via `Math.max` so it is
* monotonic.
*/
function computeStageProgress(
task: PluginInstallTask,
stage: InstallStage,
): number {
const floor = stageFloor(stage);
const ceiling = Math.max(floor, nextStageFloor(stage) - 1);
// Creep from when this stage began so a stage change restarts the ramp
// instead of inheriting the previous stage's elapsed time.
const stageStartedAt = task.stageStartedAt ?? task.startedAt;
const creepValue = Math.min(
ceiling,
floor + creep(stageStartedAt, ceiling - floor),
);
if (stage === InstallStage.DOWNLOADING) {
const total = task.downloadTotal ?? task.fileSize;
const current = task.downloadCurrent;
if (total && total > 0 && current != null && current > 0) {
const ratio = Math.min(1, current / total);
// Never let a stale counter pull the value below the creep baseline.
return Math.max(creepValue, floor + (ceiling - floor) * ratio);
}
return creepValue;
function stageToProgress(stage: InstallStage): number {
switch (stage) {
case InstallStage.DOWNLOADING:
return 10;
case InstallStage.INSTALLING_DEPS:
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
return 85;
case InstallStage.DONE:
return 100;
case InstallStage.ERROR:
return 0;
default:
return 0;
}
if (stage === InstallStage.INSTALLING_DEPS) {
const total = task.depsTotal;
const installed = task.depsInstalled;
if (total && total > 0 && installed != null && installed > 0) {
const ratio = Math.min(1, installed / total);
// Leave headroom for the finalize/launch phase that has no counters.
return Math.max(creepValue, floor + (ceiling - floor) * ratio * 0.9);
}
return creepValue;
}
return creepValue;
}
/**
@@ -267,14 +146,8 @@ function isPluginInstallTask(name: string): boolean {
/**
* Convert a backend AsyncTask to our PluginInstallTask.
*
* `previous` (when provided) carries monotonic state forward so re-syncing
* after a refresh or a poll cannot make the progress bar move backwards.
*/
function asyncTaskToPluginInstallTask(
task: AsyncTask,
previous?: PluginInstallTask,
): PluginInstallTask {
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
const source = extractSourceFromName(task.name);
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
const action = task.task_context?.current_action || '';
@@ -284,6 +157,24 @@ function asyncTaskToPluginInstallTask(
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
if (done) {
if (exception) {
stage = InstallStage.ERROR;
overallProgress = 0;
error = exception;
} else {
stage = InstallStage.DONE;
overallProgress = 100;
}
} else {
stage = mapActionToStage(action);
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
@@ -293,75 +184,6 @@ function asyncTaskToPluginInstallTask(
extensionType = 'skill';
}
// Prefer the task's real creation time so a refresh (or first sync) restores
// the correct elapsed baseline instead of restarting the ramp from zero.
const backendStartedAt =
typeof task.created_at === 'number' && task.created_at > 0
? task.created_at * 1000
: undefined;
const startedAt = previous?.startedAt ?? backendStartedAt ?? Date.now();
let stageStartedAt =
previous?.stageStartedAt ??
previous?.startedAt ??
backendStartedAt ??
startedAt;
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
// Furthest non-terminal stage reached, kept across failures.
let lastStage = previous?.lastStage ?? previous?.stage;
if (done) {
if (exception) {
// Preserve how far the task got before failing, so the bar shows the
// failure point instead of jumping back to zero.
stage = InstallStage.ERROR;
overallProgress = previous?.overallProgress ?? 0;
error = exception;
} else {
stage = InstallStage.DONE;
overallProgress = 100;
}
} else {
const incoming = mapActionToStage(action);
// Forward-only: never move back to an earlier stage than we already reached.
stage = previous ? maxStage(previous.stage, incoming) : incoming;
if (!previous || previous.stage !== stage) {
stageStartedAt = Date.now();
}
lastStage = stage;
const counters: PluginInstallTask = {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
extensionType,
stage,
overallProgress: 0,
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
currentDep: str(md.current_dep) ?? previous?.currentDep,
depsDownloadedSize:
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
startedAt,
stageStartedAt,
currentAction: action,
};
const computed = computeStageProgress(counters, stage);
overallProgress = Math.max(previous?.overallProgress ?? 0, computed);
// Keep the bar strictly below 100 until the backend confirms completion.
overallProgress = Math.round(Math.min(99, overallProgress));
}
return {
id: `${source}-${task.id}`,
taskId: task.id,
@@ -369,21 +191,18 @@ function asyncTaskToPluginInstallTask(
source,
extensionType,
stage,
lastStage,
overallProgress,
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
currentDep: str(md.current_dep) ?? previous?.currentDep,
depsDownloadedSize:
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
downloadCurrent: num(md.download_current),
downloadTotal: num(md.download_total),
downloadSpeed: num(md.download_speed),
depsTotal: num(md.deps_total),
depsInstalled: num(md.deps_installed),
depsRemaining: num(md.deps_remaining),
currentDep: str(md.current_dep),
depsDownloadedSize: num(md.deps_downloaded_size),
depsSpeed: num(md.deps_speed),
error,
startedAt,
stageStartedAt,
startedAt: Date.now(),
currentAction: action,
};
}
@@ -496,11 +315,8 @@ export function PluginInstallTaskProvider({
return {
...t,
stage: InstallStage.ERROR,
// Keep the phase that failed for the UI to display.
lastStage: t.lastStage ?? t.stage,
error: exception,
// Show where it failed instead of resetting to 0.
overallProgress: t.overallProgress,
overallProgress: 0,
currentAction: action,
...progressFields,
};
@@ -516,28 +332,26 @@ export function PluginInstallTaskProvider({
};
}
// Forward-only stage transition.
const incoming = mapActionToStage(action);
const stage = maxStage(t.stage, incoming);
// Reset the per-stage ramp whenever we enter a new stage.
const stageAdvanced = stage !== t.stage;
const stage = mapActionToStage(action);
const baseProgress = stageToProgress(stage);
// Add small time-based increment within stage
const elapsed = (Date.now() - t.startedAt) / 1000;
const withinStageIncrement = Math.min(
15,
Math.floor(elapsed / 2),
);
const progress = Math.min(
95,
baseProgress + withinStageIncrement,
);
const next: PluginInstallTask = {
return {
...t,
stage,
lastStage: stage,
stageStartedAt: stageAdvanced
? Date.now()
: (t.stageStartedAt ?? t.startedAt),
overallProgress: progress,
currentAction: action,
...progressFields,
};
const computed = computeStageProgress(next, stage);
// Progress must never move backwards while the task runs.
const overallProgress = Math.round(
Math.min(99, Math.max(t.overallProgress, computed)),
);
return { ...next, overallProgress };
}),
);
})
@@ -563,61 +377,46 @@ export function PluginInstallTaskProvider({
);
setTasks((prevTasks) => {
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
const updatedTasks = [...prevTasks];
// Collect tasks that need polling started after state is committed.
const toPoll: Array<{ key: string; taskId: number }> = [];
for (const bt of backendTasks) {
// Skip tasks that the user has dismissed
if (dismissedTaskIds.current.has(bt.id)) continue;
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
if (idx === -1) {
if (!existingTaskIds.has(bt.id)) {
// New task from backend (e.g. after page refresh) — add it
const newTask = asyncTaskToPluginInstallTask(bt);
updatedTasks.push(newTask);
// If not done, start polling for progress
if (!bt.runtime.done) {
toPoll.push({ key: newTask.id, taskId: bt.id });
pollTask(newTask.id, bt.id);
} else {
// Mark as already notified so we don't re-trigger toasts for old completed tasks
notifiedTaskIds.current.add(bt.id);
}
continue;
}
// Already tracking — merge the backend snapshot into the existing
// task. Passing `existing` keeps `startedAt`, `pluginName` and
// progress monotonic so re-syncing never rewinds the bar.
const existing = updatedTasks[idx];
const converted = asyncTaskToPluginInstallTask(bt, existing);
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
// Never downgrade a terminal task that is already done/failed locally,
// unless the backend reports it finished as well.
if (
(existing.stage === InstallStage.DONE ||
existing.stage === InstallStage.ERROR) &&
!bt.runtime.done
) {
continue;
}
updatedTasks[idx] = converted;
if (!bt.runtime.done) {
toPoll.push({ key: converted.id, taskId: bt.id });
} else {
// Already tracking — if it's done in backend but still active locally, update it
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
if (idx !== -1) {
const existing = updatedTasks[idx];
if (
bt.runtime.done &&
existing.stage !== InstallStage.DONE &&
existing.stage !== InstallStage.ERROR
) {
const converted = asyncTaskToPluginInstallTask(bt);
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
updatedTasks[idx] = converted;
}
}
}
}
// Schedule polling outside the state updater.
queueMicrotask(() => {
toPoll.forEach(({ key, taskId }) => pollTask(key, taskId));
});
return updatedTasks;
});
} catch {
@@ -665,7 +464,6 @@ export function PluginInstallTaskProvider({
// Remove from dismissed set if re-added
dismissedTaskIds.current.delete(params.taskId);
const startedAt = Date.now();
const newTask: PluginInstallTask = {
id: taskKey,
taskId: params.taskId,
@@ -673,11 +471,9 @@ export function PluginInstallTaskProvider({
source: params.source,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
// Start at the downloading floor and creep up from real counters.
overallProgress: stageFloor(InstallStage.DOWNLOADING),
overallProgress: 5,
fileSize: params.fileSize,
downloadTotal: params.fileSize,
startedAt,
startedAt: Date.now(),
currentAction: '',
};
@@ -7,7 +7,6 @@ import {
CheckCircle2,
XCircle,
Loader2,
Rocket,
X,
ListTodo,
Puzzle,
@@ -31,7 +30,6 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
@@ -97,8 +95,6 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return isDone
? getInstallCompleteMessage()
@@ -1,11 +1,4 @@
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
Suspense,
} from 'react';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import {
@@ -58,10 +51,6 @@ import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
import {
resolveInstalledState,
useMarketplaceInstalledIndex,
} from './marketplace-installed';
interface SortOption {
value: string;
@@ -102,20 +91,6 @@ function MarketPageContent({
const { t } = useTranslation();
const [searchParams] = useSearchParams();
// Installed-extension lookup, recomputed whenever the sidebar lists change
// (e.g. right after an install completes).
const installedIndex = useMarketplaceInstalledIndex();
const decorateInstalled = useCallback(
(vo: PluginMarketCardVO): PluginMarketCardVO => {
const state = resolveInstalledState(installedIndex, vo);
vo.installed = state.installed;
vo.hasUpdate = state.hasUpdate;
return vo;
},
[installedIndex],
);
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
@@ -596,12 +571,7 @@ function MarketPageContent({
};
}, []);
// Decorate with installed state at render time so the badge updates the
// moment the sidebar lists refresh (e.g. after an install completes).
const visiblePlugins = useMemo(
() => plugins.map((plugin) => decorateInstalled(plugin)),
[plugins, decorateInstalled],
);
const visiblePlugins = plugins;
// 加载更多
const loadMore = useCallback(() => {
@@ -8,10 +8,6 @@ import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
resolveInstalledState,
useMarketplaceInstalledIndex,
} from './marketplace-installed';
export interface RecommendationList {
uuid: string;
@@ -70,7 +66,6 @@ function RecommendationListRow({
isLast: boolean;
}) {
const { t } = useTranslation();
const installedIndex = useMarketplaceInstalledIndex();
const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
@@ -266,22 +261,16 @@ function RecommendationListRow({
ref={gridRef}
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
>
{visiblePlugins.map((plugin) => {
const cardVO = pluginToVO(plugin, t);
const state = resolveInstalledState(installedIndex, cardVO);
cardVO.installed = state.installed;
cardVO.hasUpdate = state.hasUpdate;
return (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={cardVO}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
);
})}
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
</div>
{totalPages > 1 && !isLast && (
<div className="border-b border-border mt-6" />
@@ -1,85 +0,0 @@
import { useMemo } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface MarketplaceInstalledState {
installed: boolean;
hasUpdate: boolean;
}
export interface InstalledIndexEntry {
hasUpdate: boolean;
}
/** Composite key used to look up installed extensions: `type:author/name`. */
export function installedExtensionKey(
type: string | undefined,
author: string,
name: string,
): string {
return `${type || 'plugin'}:${author}/${name}`;
}
/**
* Build a lookup of already-installed extensions.
*
* The sidebar identifies each kind differently:
* - plugins: `author/name`
* - MCP servers: `author__name` (double underscore)
* - skills: the bare skill name
*/
export function buildInstalledIndex(
plugins: { id: string; hasUpdate?: boolean }[],
mcpServers: { id: string }[],
skills: { id: string }[],
): Map<string, InstalledIndexEntry> {
const index = new Map<string, InstalledIndexEntry>();
for (const plugin of plugins) {
index.set(`plugin:${plugin.id}`, { hasUpdate: plugin.hasUpdate ?? false });
}
for (const server of mcpServers) {
index.set(`mcp:${server.id.replace(/__/g, '/')}`, { hasUpdate: false });
}
for (const skill of skills) {
index.set(`skill:${skill.id}`, { hasUpdate: false });
}
return index;
}
/**
* Resolve whether a marketplace extension is installed.
*
* Marketplace entries always use `author/name`; skills may be stored under
* their bare name, so both keys are checked for that case.
*/
export function resolveInstalledState(
index: Map<string, InstalledIndexEntry>,
extension: { type?: string; author: string; pluginName: string },
): MarketplaceInstalledState {
const type = extension.type || 'plugin';
const keys = [
`${type}:${extension.author}/${extension.pluginName}`,
`${type}:${extension.pluginName}`,
];
for (const key of keys) {
const entry = index.get(key);
if (entry) {
return { installed: true, hasUpdate: entry.hasUpdate };
}
}
return { installed: false, hasUpdate: false };
}
/**
* Reactive installed-extension index derived from the sidebar data context.
* Recomputes automatically after an install finishes and the sidebar refreshes.
*/
export function useMarketplaceInstalledIndex(): Map<
string,
InstalledIndexEntry
> {
const { plugins, mcpServers, skills } = useSidebarData();
return useMemo(
() => buildInstalledIndex(plugins, mcpServers, skills),
[plugins, mcpServers, skills],
);
}
@@ -3,14 +3,7 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import {
CheckCircle2,
Info,
Package,
ExternalLink,
Heart,
Loader2,
} from 'lucide-react';
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -55,10 +48,6 @@ export default function PluginMarketCardComponent({
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
// Already installed → swap the download count for an "installed" marker.
// Click behaviour stays identical to a normal card.
const isInstalled = cardVO.installed === true;
const showTypeBadge = cardVO.type;
const typeLabel =
cardVO.type === 'mcp'
@@ -331,34 +320,23 @@ export default function PluginMarketCardComponent({
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* Installed extensions replace the download count with an
"installed" marker so the card reflects local state. */}
{isInstalled ? (
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<CheckCircle2 className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-green-600 dark:text-green-400 flex-shrink-0" />
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 font-medium whitespace-nowrap">
{t('market.installed')}
</div>
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
</div>
) : (
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
</div>
</div>
)}
</div>
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
@@ -12,10 +12,6 @@ export interface IPluginMarketCardVO {
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
/** Whether this extension is already installed in the current workspace. */
installed?: boolean;
/** Whether an installed extension has a newer marketplace version. */
hasUpdate?: boolean;
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -32,8 +28,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
installed?: boolean;
hasUpdate?: boolean;
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -49,7 +43,5 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
this.installed = prop.installed ?? false;
this.hasUpdate = prop.hasUpdate ?? false;
}
}
-2
View File
@@ -460,8 +460,6 @@ export interface AsyncTask {
name: string;
label: string;
task_type: string; // system or user
/** Unix epoch seconds (float) when the task was created. */
created_at?: number;
runtime: AsyncTaskRuntimeInfo;
task_context: AsyncTaskTaskContext;
}
-3
View File
@@ -748,9 +748,6 @@ const enUS = {
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
downloadComplete: 'Plugin "{{name}}" download completed',
installFailed: 'Installation failed, please try again later',
installed: 'Installed',
updateAvailable: 'Update available',
alreadyInstalled: '{{name}} is already installed',
loadFailed: 'Failed to get plugin list, please try again later',
noDescription: 'No description available',
recommendation: {
-3
View File
@@ -769,9 +769,6 @@ const esES = {
installFailed: 'Error en la instalación, por favor inténtalo más tarde',
loadFailed:
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
installed: 'Instalado',
updateAvailable: 'Actualización disponible',
alreadyInstalled: '{{name}} ya está instalado',
noDescription: 'No hay descripción disponible',
recommendation: {
pause: 'Pausar rotación automática',
-3
View File
@@ -758,9 +758,6 @@ const jaJP = {
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
loadFailed:
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
installed: 'インストール済み',
updateAvailable: '更新あり',
alreadyInstalled: '{{name}} はインストール済みです',
noDescription: '説明がありません',
recommendation: {
pause: '自動ローテーションを一時停止',
-3
View File
@@ -763,9 +763,6 @@ const ruRU = {
downloadComplete: 'Плагин "{{name}}" загружен',
installFailed: 'Ошибка установки, попробуйте позже',
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
installed: 'Установлено',
updateAvailable: 'Доступно обновление',
alreadyInstalled: '{{name}} уже установлен',
noDescription: 'Описание отсутствует',
recommendation: {
pause: 'Приостановить авто-прокрутку',
-3
View File
@@ -741,9 +741,6 @@ const thTH = {
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
installed: 'ติดตั้งแล้ว',
updateAvailable: 'มีอัปเดต',
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
noDescription: 'ไม่มีคำอธิบาย',
recommendation: {
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
-3
View File
@@ -756,9 +756,6 @@ const viVN = {
downloadComplete: 'Tải plugin "{{name}}" hoàn tất',
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
installed: 'Đã cài đặt',
updateAvailable: 'Có bản cập nhật',
alreadyInstalled: '{{name}} đã được cài đặt',
noDescription: 'Không có mô tả',
recommendation: {
pause: 'Tạm dừng tự động xoay',
-3
View File
@@ -715,9 +715,6 @@ const zhHans = {
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
downloadComplete: '插件 "{{name}}" 下载完成',
installFailed: '安装失败,请稍后重试',
installed: '已安装',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安装',
loadFailed: '获取插件列表失败,请稍后重试',
noDescription: '暂无描述',
recommendation: {
-3
View File
@@ -719,9 +719,6 @@ const zhHant = {
downloadComplete: '插件 "{{name}}" 下載完成',
installFailed: '安裝失敗,請稍後重試',
loadFailed: '取得插件列表失敗,請稍後重試',
installed: '已安裝',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安裝',
noDescription: '暫無描述',
recommendation: {
pause: '暫停自動輪播',