mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-13 13:27:14 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c05f3dfcb | |||
| d26d0635c5 | |||
| 58cde8c022 |
+2
-1
@@ -110,7 +110,8 @@ classifiers = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"pyseekdb==1.4.0.post1",
|
||||
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -87,8 +87,10 @@ 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)
|
||||
@@ -97,11 +99,23 @@ 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)
|
||||
|
||||
|
||||
@@ -111,6 +125,7 @@ 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:
|
||||
@@ -119,6 +134,7 @@ async def _marketplace_get(
|
||||
return response.status_code, await _read_httpx_response_limited(
|
||||
response,
|
||||
max_bytes=max_bytes,
|
||||
task_context=task_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -1680,6 +1696,7 @@ 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
|
||||
|
||||
@@ -1695,7 +1712,21 @@ 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,
|
||||
@@ -1719,6 +1750,8 @@ 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')
|
||||
@@ -1730,8 +1763,12 @@ 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,
|
||||
@@ -1749,6 +1786,8 @@ 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,
|
||||
@@ -1766,6 +1805,8 @@ 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(
|
||||
|
||||
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
self._collection_configs: Dict[str, HNSWConfiguration] = {}
|
||||
self._runtime_cache_limit = runtime_cache_limit(ap)
|
||||
|
||||
self._escape_table = str.maketrans(
|
||||
{
|
||||
'\x00': '',
|
||||
'\\': '\\\\',
|
||||
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
|
||||
'"': '\\"',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
}
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._collections.clear()
|
||||
self._collection_configs.clear()
|
||||
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
return coll
|
||||
|
||||
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
|
||||
return {
|
||||
k: v.translate(self._escape_table)
|
||||
if isinstance(v, str)
|
||||
else v
|
||||
if v is None or isinstance(v, (int, float, bool))
|
||||
else str(v)
|
||||
for k, v in meta.items()
|
||||
if v is not None
|
||||
}
|
||||
"""Keep supported scalar metadata values without altering strings."""
|
||||
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
|
||||
|
||||
@staticmethod
|
||||
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
|
||||
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
|
||||
distances = results.get('distances')
|
||||
if not isinstance(distances, list):
|
||||
return
|
||||
|
||||
results['distances'] = [
|
||||
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
|
||||
if isinstance(batch, list)
|
||||
else batch
|
||||
for batch in distances
|
||||
]
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create collection (without vector size - will use default)."""
|
||||
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
|
||||
if documents is not None:
|
||||
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
|
||||
await asyncio.to_thread(coll.add, **kwargs)
|
||||
kwargs['documents'] = documents
|
||||
await asyncio.to_thread(coll.upsert, **kwargs)
|
||||
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
|
||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
# Route by search type.
|
||||
# pyseekdb's query() always requires embeddings, so full-text and
|
||||
# hybrid modes use hybrid_search() which supports text-only queries
|
||||
# and returns the same nested-list format with distances.
|
||||
# and returns relevance scores in the nested ``distances`` field.
|
||||
returns_relevance_scores = False
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
if not query_text:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
|
||||
elif search_type == SearchType.HYBRID:
|
||||
if not query_text:
|
||||
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
if returns_relevance_scores:
|
||||
self._relevance_scores_to_distances(results)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Real embedded SeekDB regression tests.
|
||||
|
||||
Install the optional dependency before running these slow tests::
|
||||
|
||||
uv sync --dev --extra seekdb
|
||||
uv run pytest tests/integration/vector/test_seekdb.py -m slow -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyseekdb')
|
||||
|
||||
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def backend(tmp_path):
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'vdb': {
|
||||
'runtime_cache_limit': 16,
|
||||
'seekdb': {
|
||||
'mode': 'embedded',
|
||||
'path': str(tmp_path),
|
||||
'database': 'langbot_test',
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=SimpleNamespace(
|
||||
info=lambda *args, **kwargs: None,
|
||||
warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
database = SeekDBVectorDatabase(app)
|
||||
collection = f'test_{uuid.uuid4().hex}'
|
||||
yield database, collection
|
||||
|
||||
await database.delete_collection(collection)
|
||||
await database.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_text_round_trip(backend) -> None:
|
||||
database, collection = backend
|
||||
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
|
||||
updated = f'Updated: {original}'
|
||||
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['document-a'],
|
||||
[[1.0, 0.0, 0.0]],
|
||||
[{'file_id': 'file-a', 'text': original}],
|
||||
[original],
|
||||
)
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['document-a'],
|
||||
[[0.0, 1.0, 0.0]],
|
||||
[{'file_id': 'file-a', 'text': updated}],
|
||||
[updated],
|
||||
)
|
||||
|
||||
items, _ = await database.list_by_filter(collection, {'file_id': 'file-a'})
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]['id'] == 'document-a'
|
||||
assert items[0]['document'] == updated
|
||||
assert items[0]['metadata']['text'] == updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None:
|
||||
database, collection = backend
|
||||
documents = [
|
||||
'orchid orchid orchid flower',
|
||||
'orchid grows in a garden with many other beautiful plants',
|
||||
'a completely unrelated topic',
|
||||
]
|
||||
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['best', 'weak', 'noise'],
|
||||
[[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]],
|
||||
[
|
||||
{'file_id': item_id, 'document_id': item_id, 'text': document}
|
||||
for item_id, document in zip(['best', 'weak', 'noise'], documents, strict=True)
|
||||
],
|
||||
documents,
|
||||
)
|
||||
seekdb_collection = await database.get_or_create_collection(collection)
|
||||
await asyncio.to_thread(seekdb_collection.refresh_index)
|
||||
|
||||
full_text = await database.search(
|
||||
collection,
|
||||
[1.0, 0.0, 0.0],
|
||||
k=3,
|
||||
search_type='full_text',
|
||||
query_text='orchid',
|
||||
)
|
||||
hybrid = await database.search(
|
||||
collection,
|
||||
[1.0, 0.0, 0.0],
|
||||
k=3,
|
||||
search_type='hybrid',
|
||||
query_text='orchid',
|
||||
vector_weight=0.65,
|
||||
)
|
||||
|
||||
assert full_text['ids'][0][:2] == ['best', 'weak']
|
||||
assert full_text['distances'][0] == sorted(full_text['distances'][0])
|
||||
assert hybrid['ids'][0] == ['best', 'weak', 'noise']
|
||||
assert hybrid['distances'][0] == sorted(hybrid['distances'][0])
|
||||
@@ -12,4 +12,7 @@ def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
|
||||
project = pyproject['project']
|
||||
base_dependencies = project['dependencies']
|
||||
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
|
||||
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
|
||||
assert project['optional-dependencies']['seekdb'] == [
|
||||
'pyseekdb==1.4.0.post1',
|
||||
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
|
||||
|
||||
|
||||
def _adapter_with_collection(collection: MagicMock) -> SeekDBVectorDatabase:
|
||||
adapter = SeekDBVectorDatabase.__new__(SeekDBVectorDatabase)
|
||||
adapter.ap = SimpleNamespace(logger=MagicMock())
|
||||
adapter.client = MagicMock()
|
||||
adapter.client.has_collection.return_value = True
|
||||
adapter._collections = {'knowledge_base': collection}
|
||||
adapter._runtime_cache_limit = 16
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_embeddings_upserts_and_preserves_text() -> None:
|
||||
collection = MagicMock()
|
||||
adapter = _adapter_with_collection(collection)
|
||||
adapter._get_or_create_collection_internal = AsyncMock(return_value=collection)
|
||||
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
|
||||
|
||||
await adapter.add_embeddings(
|
||||
collection='knowledge_base',
|
||||
ids=['document-a'],
|
||||
embeddings_list=[[1.0, 0.0, 0.0]],
|
||||
metadatas=[{'text': original}],
|
||||
documents=[original],
|
||||
)
|
||||
|
||||
collection.upsert.assert_called_once_with(
|
||||
ids=['document-a'],
|
||||
embeddings=[[1.0, 0.0, 0.0]],
|
||||
metadatas=[{'text': original}],
|
||||
documents=[original],
|
||||
)
|
||||
collection.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('search_type', 'scores', 'expected_distances'),
|
||||
[
|
||||
('full_text', [0.4508196721, 0.25], [0.5491803279, 0.75]),
|
||||
('hybrid', [0.0328, 0.0323, 0.0159], [0.9672, 0.9677, 0.9841]),
|
||||
],
|
||||
)
|
||||
async def test_search_converts_relevance_scores_to_distances(
|
||||
search_type: str,
|
||||
scores: list[float],
|
||||
expected_distances: list[float],
|
||||
) -> None:
|
||||
collection = MagicMock()
|
||||
collection.hybrid_search.return_value = {
|
||||
'ids': [['best', 'weak', 'noise'][: len(scores)]],
|
||||
'metadatas': [[{} for _ in scores]],
|
||||
'distances': [scores],
|
||||
}
|
||||
adapter = _adapter_with_collection(collection)
|
||||
|
||||
results = await adapter.search(
|
||||
collection='knowledge_base',
|
||||
query_embedding=[1.0, 0.0, 0.0],
|
||||
k=len(scores),
|
||||
search_type=search_type,
|
||||
query_text='orchid',
|
||||
vector_weight=0.65,
|
||||
)
|
||||
|
||||
assert results['distances'][0] == pytest.approx(expected_distances)
|
||||
assert results['distances'][0] == sorted(results['distances'][0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_search_keeps_seekdb_cosine_distances() -> None:
|
||||
collection = MagicMock()
|
||||
collection.query.return_value = {
|
||||
'ids': [['best', 'weak']],
|
||||
'metadatas': [[{}, {}]],
|
||||
'distances': [[0.1, 0.25]],
|
||||
}
|
||||
adapter = _adapter_with_collection(collection)
|
||||
|
||||
results = await adapter.search(
|
||||
collection='knowledge_base',
|
||||
query_embedding=[1.0, 0.0, 0.0],
|
||||
k=2,
|
||||
search_type='vector',
|
||||
)
|
||||
|
||||
assert results['distances'] == [[0.1, 0.25]]
|
||||
@@ -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]]
|
||||
@@ -2139,6 +2139,7 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
seekdb = [
|
||||
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
|
||||
{ name = "pyseekdb" },
|
||||
]
|
||||
|
||||
@@ -2203,10 +2204,11 @@ requires-dist = [
|
||||
{ name = "pycryptodome", specifier = ">=3.22.0" },
|
||||
{ name = "pydantic", specifier = ">2.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = "==1.4.0" },
|
||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.4.0.post1" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||
@@ -3297,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" },
|
||||
@@ -3336,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" },
|
||||
@@ -3348,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" },
|
||||
@@ -3378,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" },
|
||||
@@ -3392,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" },
|
||||
@@ -4482,21 +4484,18 @@ crypto = [
|
||||
|
||||
[[package]]
|
||||
name = "pylibseekdb"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" },
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/93/e9a13b996b5561f89c9a4f1b62796f8a6230a5dce215869e3cfef8adc4f1/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e37b931417b7fc7fc88d15fd8b9b0dad499cd05e693cc483aa3743840e85f0c0", size = 49442143, upload-time = "2026-08-27T13:03:41.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cd/e54bb304512042cac0514fd607175f5425bd0924330e3eb74937c2afe827/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6aaa3c9e4865d32f533af04eb8eab06d2c10fc581ac38097d618efb44c05dc5b", size = 53975703, upload-time = "2026-08-27T13:04:41.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/03/4380094699cbd4539971c0b943701f776408c94c28cc3ecdaed7c217bb29/pylibseekdb-1.4.0-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:2fee55af299f2992dd5d61c9e239ef8855629f4117ee8b4c21dc877160707004", size = 52171602, upload-time = "2026-08-27T13:05:15.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b5/ea71acbee58925a51cb1a7137afd2d1c4e3fbb5bf2a0144cd53d299a20df/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f606579904a19bcd7ec96bc117251db9d4202485fc7355f2a289804d1b3b2c1b", size = 49438937, upload-time = "2026-08-27T13:03:48.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/f3/452485e45676a7d738720a7a3f7abbf4d24a3d272cbacabef41ae8b8e52c/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d32d6f0d3b92b0c719b6c4230a24748ce10666f67052c696359e69138d8fbe7", size = 53972507, upload-time = "2026-08-27T13:04:46.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4621,7 +4620,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyseekdb"
|
||||
version = "1.1.0.post3"
|
||||
version = "1.4.0.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "python_full_version < '3.14'" },
|
||||
@@ -4635,7 +4634,7 @@ dependencies = [
|
||||
{ name = "tqdm", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/87/d5dd862faa3d4adf3847c1ce19c3ea5ecd0dcfda9c2584a95bfd2b0fac0f/pyseekdb-1.4.0.post1-py3-none-any.whl", hash = "sha256:a3379f6962a0c01aa029d3e5a8f0c0f5a59b27a689b8aae1931d9ce5563f252c", size = 158375, upload-time = "2026-08-03T08:56:59.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5256,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 = [
|
||||
@@ -5306,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 = [
|
||||
@@ -5377,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 = [
|
||||
@@ -5757,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" },
|
||||
@@ -5813,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 = [
|
||||
@@ -6082,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 = [
|
||||
|
||||
+22
-2
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Rocket,
|
||||
Server,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
@@ -39,11 +40,27 @@ 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 : -1;
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -169,9 +186,12 @@ 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.
|
||||
|
||||
+302
-98
@@ -27,6 +27,9 @@ 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
|
||||
@@ -43,6 +46,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -84,42 +89,158 @@ export function usePluginInstallTasks() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend `current_action` to our InstallStage.
|
||||
* Ordered lifecycle stages. Used to enforce forward-only transitions so the
|
||||
* progress bar never moves backwards while a task is running.
|
||||
*/
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall progress percentage from a stage.
|
||||
* 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 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;
|
||||
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')
|
||||
) {
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
}
|
||||
|
||||
// Unknown transitional actions belong to the busy middle of the install.
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,8 +267,14 @@ 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): PluginInstallTask {
|
||||
function asyncTaskToPluginInstallTask(
|
||||
task: AsyncTask,
|
||||
previous?: PluginInstallTask,
|
||||
): PluginInstallTask {
|
||||
const source = extractSourceFromName(task.name);
|
||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||
const action = task.task_context?.current_action || '';
|
||||
@@ -157,24 +284,6 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
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';
|
||||
@@ -184,6 +293,75 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
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,
|
||||
@@ -191,18 +369,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
source,
|
||||
extensionType,
|
||||
stage,
|
||||
lastStage,
|
||||
overallProgress,
|
||||
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),
|
||||
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,
|
||||
error,
|
||||
startedAt: Date.now(),
|
||||
startedAt,
|
||||
stageStartedAt,
|
||||
currentAction: action,
|
||||
};
|
||||
}
|
||||
@@ -315,8 +496,11 @@ 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,
|
||||
overallProgress: 0,
|
||||
// Show where it failed instead of resetting to 0.
|
||||
overallProgress: t.overallProgress,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
@@ -332,26 +516,28 @@ export function PluginInstallTaskProvider({
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
// 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;
|
||||
|
||||
return {
|
||||
const next: PluginInstallTask = {
|
||||
...t,
|
||||
stage,
|
||||
overallProgress: progress,
|
||||
lastStage: stage,
|
||||
stageStartedAt: stageAdvanced
|
||||
? Date.now()
|
||||
: (t.stageStartedAt ?? t.startedAt),
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
})
|
||||
@@ -377,46 +563,61 @@ 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;
|
||||
|
||||
if (!existingTaskIds.has(bt.id)) {
|
||||
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||
|
||||
if (idx === -1) {
|
||||
// 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) {
|
||||
pollTask(newTask.id, bt.id);
|
||||
toPoll.push({ key: newTask.id, taskId: bt.id });
|
||||
} else {
|
||||
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
||||
notifiedTaskIds.current.add(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;
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule polling outside the state updater.
|
||||
queueMicrotask(() => {
|
||||
toPoll.forEach(({ key, taskId }) => pollTask(key, taskId));
|
||||
});
|
||||
|
||||
return updatedTasks;
|
||||
});
|
||||
} catch {
|
||||
@@ -464,6 +665,7 @@ 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,
|
||||
@@ -471,9 +673,11 @@ export function PluginInstallTaskProvider({
|
||||
source: params.source,
|
||||
extensionType: params.extensionType,
|
||||
stage: InstallStage.DOWNLOADING,
|
||||
overallProgress: 5,
|
||||
// Start at the downloading floor and creep up from real counters.
|
||||
overallProgress: stageFloor(InstallStage.DOWNLOADING),
|
||||
fileSize: params.fileSize,
|
||||
startedAt: Date.now(),
|
||||
downloadTotal: params.fileSize,
|
||||
startedAt,
|
||||
currentAction: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Rocket,
|
||||
X,
|
||||
ListTodo,
|
||||
Puzzle,
|
||||
@@ -30,6 +31,7 @@ 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,
|
||||
};
|
||||
@@ -95,6 +97,8 @@ 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,4 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
Suspense,
|
||||
} from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -51,6 +58,10 @@ 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;
|
||||
@@ -91,6 +102,20 @@ 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 = [
|
||||
@@ -571,7 +596,12 @@ function MarketPageContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visiblePlugins = plugins;
|
||||
// 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 loadMore = useCallback(() => {
|
||||
|
||||
@@ -8,6 +8,10 @@ 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;
|
||||
@@ -66,6 +70,7 @@ 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.
|
||||
@@ -261,16 +266,22 @@ function RecommendationListRow({
|
||||
ref={gridRef}
|
||||
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
|
||||
>
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.author + ' / ' + plugin.name}
|
||||
cardVO={pluginToVO(plugin, t)}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && !isLast && (
|
||||
<div className="border-b border-border mt-6" />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
+39
-17
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '../PluginComponentList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Package,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -48,6 +55,10 @@ 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'
|
||||
@@ -320,23 +331,34 @@ 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">
|
||||
<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'}
|
||||
{/* 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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">
|
||||
|
||||
+8
@@ -12,6 +12,10 @@ 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 {
|
||||
@@ -28,6 +32,8 @@ 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;
|
||||
@@ -43,5 +49,7 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +460,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -748,6 +748,9 @@ 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: {
|
||||
|
||||
@@ -769,6 +769,9 @@ 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',
|
||||
|
||||
@@ -758,6 +758,9 @@ const jaJP = {
|
||||
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
|
||||
loadFailed:
|
||||
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
||||
installed: 'インストール済み',
|
||||
updateAvailable: '更新あり',
|
||||
alreadyInstalled: '{{name}} はインストール済みです',
|
||||
noDescription: '説明がありません',
|
||||
recommendation: {
|
||||
pause: '自動ローテーションを一時停止',
|
||||
|
||||
@@ -763,6 +763,9 @@ const ruRU = {
|
||||
downloadComplete: 'Плагин "{{name}}" загружен',
|
||||
installFailed: 'Ошибка установки, попробуйте позже',
|
||||
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
||||
installed: 'Установлено',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
alreadyInstalled: '{{name}} уже установлен',
|
||||
noDescription: 'Описание отсутствует',
|
||||
recommendation: {
|
||||
pause: 'Приостановить авто-прокрутку',
|
||||
|
||||
@@ -741,6 +741,9 @@ const thTH = {
|
||||
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
|
||||
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
||||
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
||||
installed: 'ติดตั้งแล้ว',
|
||||
updateAvailable: 'มีอัปเดต',
|
||||
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
|
||||
noDescription: 'ไม่มีคำอธิบาย',
|
||||
recommendation: {
|
||||
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
||||
|
||||
@@ -756,6 +756,9 @@ 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',
|
||||
|
||||
@@ -715,6 +715,9 @@ const zhHans = {
|
||||
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
||||
downloadComplete: '插件 "{{name}}" 下载完成',
|
||||
installFailed: '安装失败,请稍后重试',
|
||||
installed: '已安装',
|
||||
updateAvailable: '有更新',
|
||||
alreadyInstalled: '{{name}} 已安装',
|
||||
loadFailed: '获取插件列表失败,请稍后重试',
|
||||
noDescription: '暂无描述',
|
||||
recommendation: {
|
||||
|
||||
@@ -719,6 +719,9 @@ const zhHant = {
|
||||
downloadComplete: '插件 "{{name}}" 下載完成',
|
||||
installFailed: '安裝失敗,請稍後重試',
|
||||
loadFailed: '取得插件列表失敗,請稍後重試',
|
||||
installed: '已安裝',
|
||||
updateAvailable: '有更新',
|
||||
alreadyInstalled: '{{name}} 已安裝',
|
||||
noDescription: '暫無描述',
|
||||
recommendation: {
|
||||
pause: '暫停自動輪播',
|
||||
|
||||
Reference in New Issue
Block a user