Compare commits

...

9 Commits

Author SHA1 Message Date
TyperBody 6c05f3dfcb feat(plugins): show installed state in marketplace and stream install tasks
- report download progress (bytes, speed) and human-readable install stages
  from the plugin runtime connector via the install task context
- add a marketplace installed-state helper so market cards reflect whether a
  plugin is already installed
- surface install progress / queue state in the plugin install-task UI
- extend BackendClient and API entities with install-task status
- i18n for the new marketplace / install strings
2026-09-13 00:02:08 +08:00
huanghuoguoguo d26d0635c5 fix(vector): correct SeekDB adapter semantics (#2536) 2026-09-12 19:40:30 +08:00
彼方 58cde8c022 Merge pull request #2534 from langbot-app/fix/i18n-passkey-keys
fix(i18n): complete passkey keys across all locale files
2026-09-12 16:47:27 +08:00
彼方 9eb8683997 Merge pull request #2530 from langbot-app/feat/passkey-login
feat(auth): support passkey (webauthn) login and credential management
2026-09-12 16:00:10 +08:00
BiFangKNT 19526e1400 test(api): define explicit fixtures for passkey integration tests 2026-09-12 15:51:56 +08:00
BiFangKNT dfde9578c1 fix(ci): fix ruff lint errors and postgres legacy migration table exclusion 2026-09-12 15:35:47 +08:00
Hyu ec5b8cc8a8 docs(space): sync Runner usage recommendation contract (#2533)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 13:37:14 +08:00
BiFangKNT 9db6650274 style(tests): Remove unused time import from test file 2026-09-12 12:26:51 +08:00
BiFangKNT b594cf23e4 feat(auth): add webauthn authentication support 2026-09-12 12:17:26 +08:00
40 changed files with 2444 additions and 234 deletions
+3
View File
@@ -57,3 +57,6 @@ testsdk/
# Next.js build cache (legacy)
web/.next/
web/.pnpm-home
.tmp
Caddyfile
+3 -1
View File
@@ -81,6 +81,7 @@ dependencies = [
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
"webauthn>=3.0.0",
]
keywords = [
"bot",
@@ -109,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]
+34 -1
View File
@@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens),
then send it as a Bearer token:
```
Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
Authorization: Bearer <your-pat>
```
Requests without a valid PAT get `401 Unauthorized`.
## Client configuration
@@ -66,6 +69,36 @@ All tools are read-only.
state (available, unprobed, unavailable), then Space recommendation. Each
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Runner usage recommendations
Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and
setup-wizard recommendations, or `runner_usage: "event"` for event processors.
The component kind remains `Runner`. Only these two exact values are accepted;
omit the optional field to preserve unfiltered browsing.
```json
{"query":"", "runner_usage":"agent", "page":1, "page_size":100}
```
Plugin results include `latest_version` and `runner_usages: string[]`, the
explicit union of usages in that latest installable version. Only recommend a
plugin when this array explicitly contains the target usage. Missing, empty,
malformed, or unknown usages must never mean agent-compatible. Event-only
plugins must never enter Agent recommendations. Empty filtered results are
valid while legacy packages await corrected releases; never remove the filter
to fill a recommendation list.
REST callers use `runner_usage` on both
`POST /api/v1/marketplace/extensions/search` and the compatibility
`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add
`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint.
Usage is ANDed with other filters before pagination and `total`; MCP/Skill items
do not match. Invalid REST values return HTTP 400.
Open the same filter in the webpage:
`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent`
(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
@@ -1,9 +1,12 @@
from __future__ import annotations
import quart
import argon2
import asyncio
import datetime
import hmac
import time
import typing
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -64,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
return redirect_uri
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
origin = ''
if json_data and isinstance(json_data, dict):
origin = json_data.get('origin', '')
if not origin:
origin = quart.request.headers.get('Origin', '')
if not origin:
origin = quart.request.headers.get('Referer', '')
if not origin:
origin = quart.request.url_root.rstrip('/')
parsed = urlsplit(origin)
rp_id = parsed.hostname or 'localhost'
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
return clean_origin, rp_id
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -387,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
capabilities['passkey_login_enabled'] = True
capabilities['passkey_supported'] = True
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -477,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Generate WebAuthn registration options for current account."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = (await quart.request.json) or {}
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
account_uuid=user_obj.uuid,
rp_id=rp_id,
origin=origin,
rp_name='LangBot',
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Verify WebAuthn registration response and save credential."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
name = json_data.get('name')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
cred = await self.ap.user_service.verify_and_save_passkey_registration(
challenge_token=challenge_token,
credential_data=credential,
name=name,
)
return self.success(
data={
'uuid': cred.uuid,
'name': cred.name,
'created_at': cred.created_at.isoformat() if cred.created_at else None,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Generate WebAuthn authentication options for passkey login."""
json_data = (await quart.request.json) or {}
email = json_data.get('email')
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
rp_id=rp_id,
origin=origin,
email=email,
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Verify WebAuthn authentication response and log in."""
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
challenge_token=challenge_token,
credential_data=credential,
)
return self.success(
data={
'token': token,
'user': user_obj.user,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""List registered passkeys for the current user."""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
return self.success(
data=[
{
'uuid': pk.uuid,
'name': pk.name,
'aaguid': pk.aaguid,
'transports': pk.transports,
'backed_up': pk.backed_up,
'created_at': pk.created_at.isoformat() if pk.created_at else None,
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
}
for pk in passkeys
]
)
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Rename a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
name = (json_data.get('name') or '').strip()
if not name:
return self.fail(1, 'Passkey name cannot be empty')
updated = await self.ap.user_service.rename_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
new_name=name,
)
if not updated:
return self.http_status(404, -1, 'Passkey not found')
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Delete/revoke a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
deleted = await self.ap.user_service.delete_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
)
if not deleted:
return self.http_status(404, -1, 'Passkey not found')
return self.success()
async def _handle_space_direct_launch(
self,
launch_assertion: str,
+333
View File
@@ -4,6 +4,7 @@ import sqlalchemy
import argon2
import jwt
import datetime
import json
import typing
import asyncio
import dataclasses
@@ -12,10 +13,19 @@ import hashlib
import secrets
import time
import uuid
import webauthn
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
class AccountExistsLoginRequiredError(ValueError):
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
launch_workspace_uuid: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class PasskeyChallengeData:
challenge: bytes
purpose: typing.Literal['register', 'auth']
rp_id: str
origin: str
expires_at: float
account_uuid: str | None = None
user_email: str | None = None
class UserService:
ap: Application
_create_user_lock: asyncio.Lock
@@ -65,6 +89,9 @@ class UserService:
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
self._passkey_challenge_lock = asyncio.Lock()
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
@@ -850,3 +877,309 @@ class UserService:
await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
def _prune_passkey_challenges(self, now: float) -> None:
while self._passkey_challenge_expiry_heap:
expires_at, token = self._passkey_challenge_expiry_heap[0]
entry = self._passkey_challenges.get(token)
if entry is None or entry.expires_at != expires_at:
heapq.heappop(self._passkey_challenge_expiry_heap)
continue
if expires_at > now:
break
heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(token, None)
max_heap_entries = max(
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
)
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
self._passkey_challenge_expiry_heap[:] = [
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
]
heapq.heapify(self._passkey_challenge_expiry_heap)
async def issue_passkey_challenge(
self,
purpose: typing.Literal['register', 'auth'],
rp_id: str,
origin: str,
*,
account_uuid: str | None = None,
user_email: str | None = None,
ttl_seconds: int = 300,
) -> tuple[str, bytes]:
now = time.monotonic()
challenge_bytes = secrets.token_bytes(32)
challenge_token = secrets.token_urlsafe(32)
expires_at = now + ttl_seconds
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
if not self._passkey_challenge_expiry_heap:
break
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(oldest_token, None)
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
challenge=challenge_bytes,
purpose=purpose,
rp_id=rp_id,
origin=origin,
expires_at=expires_at,
account_uuid=account_uuid,
user_email=user_email,
)
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
return challenge_token, challenge_bytes
async def consume_passkey_challenge(
self,
challenge_token: str,
purpose: typing.Literal['register', 'auth'],
) -> PasskeyChallengeData:
now = time.monotonic()
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
data = self._passkey_challenges.pop(challenge_token, None)
if data is None or data.expires_at < now:
raise ValueError('Invalid or expired passkey challenge')
if data.purpose != purpose:
raise ValueError('Passkey challenge purpose mismatch')
return data
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
statement = (
sqlalchemy.select(passkey.PasskeyCredential)
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
.order_by(passkey.PasskeyCredential.created_at.desc())
)
async with self._session_factory()() as session:
result = await session.scalars(statement)
return list(result.all())
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.credential_id == credential_id
)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def generate_passkey_registration_options(
self,
account_uuid: str,
rp_id: str,
origin: str,
rp_name: str = 'LangBot',
) -> tuple[dict[str, typing.Any], str]:
account = await self.get_user_by_uuid(account_uuid)
if account is None:
raise ValueError('User not found')
self._require_active_account(account)
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='register',
rp_id=rp_id,
origin=origin,
account_uuid=account_uuid,
user_email=account.user,
)
existing_passkeys = await self.get_user_passkeys(account_uuid)
exclude_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
]
options = webauthn.generate_registration_options(
rp_id=rp_id,
rp_name=rp_name,
user_name=account.user,
user_id=account.uuid.encode('utf-8'),
user_display_name=account.user,
challenge=challenge_bytes,
exclude_credentials=exclude_credentials or None,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
),
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_and_save_passkey_registration(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
name: str | None = None,
) -> passkey.PasskeyCredential:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
if not challenge_data.account_uuid:
raise ValueError('Registration challenge must be bound to an account')
verification = webauthn.verify_registration_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
require_user_verification=False,
)
cred_id_str = bytes_to_base64url(verification.credential_id)
pub_key_str = bytes_to_base64url(verification.credential_public_key)
transports = None
if isinstance(credential_data, dict):
resp = credential_data.get('response', {})
if isinstance(resp, dict) and 'transports' in resp:
t_list = resp.get('transports')
if isinstance(t_list, list):
transports = ','.join(str(x) for x in t_list)
credential_name = (name or '').strip()
if not credential_name:
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
record = passkey.PasskeyCredential(
uuid=str(uuid.uuid4()),
account_uuid=challenge_data.account_uuid,
name=credential_name,
credential_id=cred_id_str,
public_key=pub_key_str,
sign_count=verification.sign_count,
aaguid=verification.aaguid,
transports=transports,
backed_up=verification.credential_backed_up,
)
async with self._session_factory()() as session:
async with session.begin():
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def generate_passkey_authentication_options(
self,
rp_id: str,
origin: str,
email: str | None = None,
) -> tuple[dict[str, typing.Any], str]:
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='auth',
rp_id=rp_id,
origin=origin,
user_email=email,
)
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
if email:
user_obj = await self.get_user_by_email(email)
if user_obj:
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
if user_passkeys:
allow_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
]
options = webauthn.generate_authentication_options(
rp_id=rp_id,
challenge=challenge_bytes,
allow_credentials=allow_credentials or None,
user_verification=UserVerificationRequirement.PREFERRED,
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_passkey_authentication(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
) -> tuple[str, user.User]:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
if not raw_id:
raise ValueError('Missing credential id')
stored_credential = await self.get_passkey_by_credential_id(raw_id)
if stored_credential is None:
raise ValueError('Passkey credential not recognized')
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
if user_obj is None:
raise ValueError('Associated user not found')
self._require_active_account(user_obj)
verification = webauthn.verify_authentication_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
credential_public_key=base64url_to_bytes(stored_credential.public_key),
credential_current_sign_count=stored_credential.sign_count,
require_user_verification=False,
)
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.id == stored_credential.id
)
)
if record:
record.sign_count = verification.new_sign_count
record.last_used_at = datetime.datetime.now()
record.backed_up = verification.credential_backed_up
token = await self.generate_jwt_token(user_obj)
return token, user_obj
async def rename_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
new_name: str,
) -> passkey.PasskeyCredential | None:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return None
record.name = new_name
await session.flush()
await session.refresh(record)
return record
async def delete_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
) -> bool:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return False
await session.delete(record)
return True
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class PasskeyCredential(Base):
__tablename__ = 'passkey_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
)
@@ -0,0 +1,54 @@
"""add passkey credentials table
Revision ID: 0024_passkey_credentials
Revises: 0023_bot_scoped_sessions
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0024_passkey_credentials'
down_revision = '0023_bot_scoped_sessions'
branch_labels = None
depends_on = None
_TABLE_NAME = 'passkey_credentials'
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('credential_id', sa.String(255), nullable=False),
sa.Column('public_key', sa.Text(), nullable=False),
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('aaguid', sa.String(64), nullable=True),
sa.Column('transports', sa.String(255), nullable=True),
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
def downgrade() -> None:
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
+1
View File
@@ -63,6 +63,7 @@ _ALEMBIC_TENANT_TABLES = {
'mcp_servers',
'model_providers',
'codex_credentials',
'passkey_credentials',
'llm_models',
'embedding_models',
'rerank_models',
+41
View File
@@ -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(
+25 -26
View File
@@ -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"
)
+6
View File
@@ -310,6 +310,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
'passkey_login_enabled': True,
'passkey_supported': True,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited()
@@ -334,6 +336,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio
@@ -355,6 +359,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio
@@ -0,0 +1,190 @@
"""
Integration smoke tests for Passkey API endpoints.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
import pytest
from tests.factories import FakeApp
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')]
@pytest.fixture(scope='module')
def mock_circular_import_chain():
class FakeMinimalApplication:
pass
mock_app = Mock()
mock_app.Application = FakeMinimalApplication
mock_entities = Mock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.system',
'langbot.pkg.api.http.controller.groups.user',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401
yield
@pytest.fixture
def fake_api_app():
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True},
}
)
app.user_service = Mock()
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
return app
@pytest.fixture
async def quart_test_client(fake_api_app, http_controller_cls):
controller = http_controller_cls(fake_api_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
class TestPasskeyPublicEndpoints:
@pytest.mark.asyncio
async def test_auth_options_endpoint(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={'origin': 'http://localhost:3000'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['challenge_token'] == 'token_123'
assert data['data']['options']['rpId'] == 'localhost'
@pytest.mark.asyncio
async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app):
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/verify',
json={},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] != 0
assert 'Missing challenge_token or credential' in data['msg']
@pytest.mark.asyncio
async def test_auth_verify_success(self, quart_test_client, fake_api_app):
fake_api_app.user_service.verify_passkey_authentication = AsyncMock(
return_value=('jwt_token_abc', Mock(user='user@example.com'))
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/verify',
json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['token'] == 'jwt_token_abc'
assert data['data']['user'] == 'user@example.com'
class TestPasskeyProtectedEndpoints:
@pytest.mark.asyncio
async def test_register_options_requires_auth(self, quart_test_client):
response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_passkeys_list_requires_auth(self, quart_test_client):
response = await quart_test_client.get('/api/v1/user/passkeys')
assert response.status_code == 401
class TestPasskeyReverseProxyScenarios:
@pytest.mark.asyncio
async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={'origin': 'https://proxy.company.com:8443'},
headers={'Host': '127.0.0.1:5300'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='proxy.company.com',
origin='https://proxy.company.com:8443',
email=None,
)
@pytest.mark.asyncio
async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={},
headers={'Origin': 'https://bot.example.com'},
)
assert response.status_code == 200
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='bot.example.com',
origin='https://bot.example.com',
email=None,
)
@pytest.mark.asyncio
async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app):
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer')
)
response = await quart_test_client.post(
'/api/v1/user/passkey/auth/options',
json={},
headers={'Referer': 'https://bot.example.com:9000/login'},
)
assert response.status_code == 200
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
rp_id='bot.example.com',
origin='https://bot.example.com:9000',
email=None,
)
@@ -550,11 +550,13 @@ class TestPostgreSQLWorkspaceMigration:
)
assert 'workspaces' not in tables_before_migration
assert 'codex_credentials' not in tables_before_migration
assert 'passkey_credentials' not in tables_before_migration
await manager._initialize_managed_schema()
async with postgres_engine.connect() as conn:
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
assert 'passkey_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
workspace = (
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
+123
View File
@@ -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])
@@ -0,0 +1,103 @@
"""
Unit tests for Passkey WebAuthn service operations in UserService.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.entity.persistence.user import AccountStatus, User
pytestmark = pytest.mark.asyncio
class TestPasskeyChallengeLifecycle:
async def test_challenge_issuance_and_consumption(self):
service = UserService(SimpleNamespace())
token, challenge_bytes = await service.issue_passkey_challenge(
purpose='register',
rp_id='localhost',
origin='http://localhost:3000',
account_uuid='acc-123',
user_email='user@example.com',
)
assert len(token) > 20
assert len(challenge_bytes) == 32
data = await service.consume_passkey_challenge(token, 'register')
assert data.challenge == challenge_bytes
assert data.rp_id == 'localhost'
assert data.origin == 'http://localhost:3000'
assert data.account_uuid == 'acc-123'
assert data.user_email == 'user@example.com'
# Replay should fail
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
await service.consume_passkey_challenge(token, 'register')
async def test_challenge_purpose_mismatch_fails(self):
service = UserService(SimpleNamespace())
token, _ = await service.issue_passkey_challenge(
purpose='register',
rp_id='localhost',
origin='http://localhost:3000',
)
with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'):
await service.consume_passkey_challenge(token, 'auth')
async def test_challenge_expiration(self):
service = UserService(SimpleNamespace())
token, _ = await service.issue_passkey_challenge(
purpose='auth',
rp_id='localhost',
origin='http://localhost:3000',
ttl_seconds=0,
)
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
await service.consume_passkey_challenge(token, 'auth')
class TestPasskeyOptionsGeneration:
async def test_generate_registration_options(self):
service = UserService(SimpleNamespace())
mock_user = Mock(spec=User)
mock_user.uuid = 'acc-test-uuid'
mock_user.user = 'test@example.com'
mock_user.status = AccountStatus.ACTIVE.value
service.get_user_by_uuid = AsyncMock(return_value=mock_user)
service.get_user_passkeys = AsyncMock(return_value=[])
options, token = await service.generate_passkey_registration_options(
account_uuid='acc-test-uuid',
rp_id='localhost',
origin='http://localhost:3000',
rp_name='LangBot Test',
)
assert isinstance(options, dict)
assert options['rp']['name'] == 'LangBot Test'
assert options['rp']['id'] == 'localhost'
assert options['user']['name'] == 'test@example.com'
assert 'challenge' in options
assert len(token) > 0
async def test_generate_authentication_options_discoverable(self):
service = UserService(SimpleNamespace())
options, token = await service.generate_passkey_authentication_options(
rp_id='localhost',
origin='http://localhost:3000',
)
assert isinstance(options, dict)
assert options['rpId'] == 'localhost'
assert 'challenge' in options
assert len(token) > 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')",
]
+96
View File
@@ -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]]
Generated
+173 -74
View File
@@ -608,6 +608,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
]
[[package]]
name = "cbor2"
version = "6.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" },
{ url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" },
{ url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" },
{ url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" },
{ url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" },
{ url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" },
{ url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" },
{ url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" },
{ url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" },
{ url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" },
{ url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" },
{ url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" },
{ url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" },
{ url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" },
{ url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" },
{ url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" },
{ url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" },
{ url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" },
{ url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" },
{ url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" },
{ url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" },
{ url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" },
{ url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" },
{ url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" },
{ url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" },
{ url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -1018,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" },
@@ -1051,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]]
@@ -2085,11 +2133,13 @@ dependencies = [
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "webauthn" },
{ name = "websockets" },
]
[package.optional-dependencies]
seekdb = [
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "pyseekdb" },
]
@@ -2154,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" },
@@ -2180,6 +2231,7 @@ requires-dist = [
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "uv", specifier = ">=0.11.15" },
{ 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" },
]
provides-extras = ["seekdb"]
@@ -3247,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" },
@@ -3286,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" },
@@ -3298,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" },
@@ -3328,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" },
@@ -3342,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" },
@@ -4073,6 +4125,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pybase64"
version = "1.4.3"
@@ -4411,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]]
@@ -4490,6 +4560,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pyopenssl"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
name = "pypdf2"
version = "3.0.1"
@@ -4537,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'" },
@@ -4551,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]]
@@ -5172,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 = [
@@ -5222,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 = [
@@ -5293,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 = [
@@ -5673,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" },
@@ -5729,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 = [
@@ -5998,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 = [
@@ -6166,6 +6249,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
[[package]]
name = "webauthn"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cbor2" },
{ name = "cryptography" },
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
{ name = "pyopenssl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" },
]
[[package]]
name = "websocket-client"
version = "1.9.0"
+1
View File
@@ -55,6 +55,7 @@
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@simplewebauthn/browser": "^14.0.0",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
+17
View File
@@ -93,6 +93,9 @@ dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
'@simplewebauthn/browser':
specifier: ^14.0.0
version: 14.0.0
'@tailwindcss/postcss':
specifier: ^4.1.5
version: 4.1.18
@@ -1846,6 +1849,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1855,6 +1859,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -1864,6 +1869,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1873,6 +1879,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1882,6 +1889,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -1891,6 +1899,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -1942,6 +1951,10 @@ packages:
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
dev: false
/@simplewebauthn/browser@14.0.0:
resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==}
dev: false
/@standard-schema/utils@0.3.0:
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
dev: false
@@ -4240,6 +4253,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -4259,6 +4273,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -4278,6 +4293,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@@ -4297,6 +4313,7 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@@ -12,7 +12,17 @@ import {
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import {
Loader2,
ExternalLink,
KeyRound,
Layers,
Fingerprint,
Plus,
Trash2,
Pencil,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
@@ -22,6 +32,16 @@ interface AccountSettingsPanelProps {
onEmailResolved?: (email: string) => void;
}
interface PasskeyItem {
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
@@ -33,10 +53,14 @@ export default function AccountSettingsPanel({
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
}
}, [active]);
@@ -55,6 +79,67 @@ export default function AccountSettingsPanel({
}
}
async function loadPasskeys() {
setPasskeyLoading(true);
try {
const list = await httpClient.getPasskeys();
setPasskeys(list);
} catch {
// ignore
} finally {
setPasskeyLoading(false);
}
}
const handleAddPasskey = async () => {
setRegisteringPasskey(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyRegisterOptions(window.location.origin);
const regResp = await startRegistration({ optionsJSON: options });
const defaultName =
prompt(t('account.passkeyNamePlaceholder')) || undefined;
await httpClient.verifyPasskeyRegister(
challenge_token,
regResp,
defaultName,
);
toast.success(t('account.passkeyAddedSuccess'));
await loadPasskeys();
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled
} else {
toast.error(error?.message || t('common.error'));
}
} finally {
setRegisteringPasskey(false);
}
};
const handleDeletePasskey = async (uuid: string) => {
if (!confirm(t('account.deletePasskeyConfirm'))) return;
try {
await httpClient.deletePasskey(uuid);
toast.success(t('account.passkeyDeleteSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleRenamePasskey = async (uuid: string, currentName: string) => {
const newName = prompt(t('account.passkeyName'), currentName);
if (!newName || !newName.trim() || newName === currentName) return;
try {
await httpClient.renamePasskey(uuid, newName.trim());
toast.success(t('account.passkeyRenameSuccess'));
await loadPasskeys();
} catch (error: any) {
toast.error(error?.message || t('common.error'));
}
};
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
@@ -148,6 +233,105 @@ export default function AccountSettingsPanel({
</ItemActions>
)}
</Item>
{/* Passkey Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.passkeySectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.passkeySectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddPasskey}
disabled={
registeringPasskey || !systemInfo.allow_modify_login_info
}
className="cursor-pointer"
>
{registeringPasskey ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('account.addPasskey')}
</Button>
</div>
{passkeyLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noPasskeys')}
</div>
) : (
<div className="space-y-2">
{passkeys.map((pk) => (
<Item
key={pk.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Fingerprint className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{pk.name}</ItemTitle>
<ItemDescription>
{pk.created_at && (
<span>
{t('account.passkeyCreated', {
date: new Date(
pk.created_at,
).toLocaleDateString(),
})}
</span>
)}
{pk.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.passkeyLastUsed', {
date: new Date(
pk.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
disabled={!systemInfo.allow_modify_login_info}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
onClick={() => handleDeletePasskey(pk.uuid)}
disabled={!systemInfo.allow_modify_login_info}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</ItemActions>
</Item>
))}
</div>
)}
</div>
</div>
)}
@@ -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.
@@ -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],
);
}
@@ -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">
@@ -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;
}
}
+2
View File
@@ -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;
}
+80
View File
@@ -1304,12 +1304,92 @@ export class BackendClient extends BaseHttpClient {
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
passkey_login_enabled?: boolean;
passkey_supported?: boolean;
}> {
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/auth/options',
{ email, origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyAuth(
challenge_token: string,
credential: any,
): Promise<{ token: string; user: string }> {
return this.post(
'/api/v1/user/passkey/auth/verify',
{ challenge_token, credential },
{ skipWorkspace: true },
);
}
public getPasskeyRegisterOptions(
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
return this.post(
'/api/v1/user/passkey/register/options',
{ origin },
{ skipWorkspace: true },
);
}
public verifyPasskeyRegister(
challenge_token: string,
credential: any,
name?: string,
): Promise<{ uuid: string; name: string; created_at?: string }> {
return this.post(
'/api/v1/user/passkey/register/verify',
{ challenge_token, credential, name },
{ skipWorkspace: true },
);
}
public getPasskeys(): Promise<
Array<{
uuid: string;
name: string;
aaguid?: string;
transports?: string;
backed_up?: boolean;
created_at?: string;
last_used_at?: string;
}>
> {
return this.get('/api/v1/user/passkeys', undefined, {
skipWorkspace: true,
});
}
public renamePasskey(
uuid: string,
name: string,
): Promise<{ uuid: string; name: string }> {
return this.patch(
`/api/v1/user/passkey/${encodeURIComponent(uuid)}`,
{ name },
{ skipWorkspace: true },
);
}
public deletePasskey(uuid: string): Promise<void> {
return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, {
skipWorkspace: true,
});
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+51 -1
View File
@@ -35,7 +35,9 @@ import {
AlertCircle,
RefreshCw,
Layers,
Fingerprint,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -63,6 +65,8 @@ export default function Login() {
const [spaceLoading, setSpaceLoading] = useState(false);
const [showLocalLogin, setShowLocalLogin] = useState(false);
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
@@ -90,6 +94,9 @@ export default function Login() {
}
setShowLocalLogin(res.password_login_enabled !== false);
setShowSpaceLogin(res.space_login_enabled !== false);
setShowPasskeyLogin(
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
);
setLoading(false);
// Also check if already logged in
@@ -184,6 +191,30 @@ export default function Login() {
handleLogin(values.email, values.password);
}
async function handlePasskeyLogin() {
setPasskeyLoading(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyAuthOptions(
undefined,
window.location.origin,
);
const authResp = await startAuthentication({ optionsJSON: options });
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
if (await finishLogin(res.token, res.user)) {
toast.success(t('common.passkeyLoginSuccess'));
}
} catch (error: any) {
if (error?.name === 'NotAllowedError') {
// User cancelled the biometric prompt
} else {
toast.error(error?.message || t('common.passkeyLoginFailed'));
}
} finally {
setPasskeyLoading(false);
}
}
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
@@ -324,8 +355,27 @@ export default function Login() {
</div>
)}
{showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="w-full cursor-pointer"
onClick={handlePasskeyLogin}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Fingerprint className="mr-2 h-4 w-4" />
)}
{t('common.loginWithPasskey')}
</Button>
</div>
)}
{/* Divider - only show if both login methods are available */}
{showSpaceLogin && showLocalLogin && (
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
+21
View File
@@ -86,6 +86,10 @@ const enUS = {
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account',
loginWithPassword: 'Login with password',
loginWithPasskey: 'Sign in with Passkey',
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -744,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: {
@@ -1339,6 +1346,20 @@ const enUS = {
bindSpaceWarning:
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
bindSpaceSuccess: 'LangBot Account bound successfully',
passkeySectionTitle: 'Passkeys',
passkeySectionDesc:
'Sign in securely without passwords using biometrics or security keys',
addPasskey: 'Add Passkey',
passkeyName: 'Key Name',
passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey',
passkeyCreated: 'Created on {{date}}',
passkeyLastUsed: 'Last used: {{date}}',
noPasskeys: 'No passkeys registered yet',
deletePasskeyConfirm:
'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.',
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
+22
View File
@@ -89,6 +89,11 @@ const esES = {
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local',
loginWithPassword: 'Iniciar sesión con contraseña',
loginWithPasskey: 'Iniciar sesión con Passkey',
passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...',
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
passkeyNotSupported:
'Passkey no es compatible en este navegador o dispositivo',
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar',
@@ -764,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',
@@ -1376,6 +1384,20 @@ const esES = {
bindSpaceWarning:
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
passkeySectionTitle: 'Llaves de acceso (Passkeys)',
passkeySectionDesc:
'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad',
addPasskey: 'Añadir llave de acceso',
passkeyName: 'Nombre de la llave',
passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey',
passkeyCreated: 'Creada el {{date}}',
passkeyLastUsed: 'Último uso: {{date}}',
noPasskeys: 'No hay llaves de acceso registradas',
deletePasskeyConfirm:
'¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.',
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
passkeyDeleteSuccess: 'Llave de acceso eliminada',
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
+22
View File
@@ -87,6 +87,11 @@ const jaJP = {
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン',
loginWithPasskey: 'パスキーでログイン',
passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...',
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -753,6 +758,9 @@ const jaJP = {
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
loadFailed:
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
installed: 'インストール済み',
updateAvailable: '更新あり',
alreadyInstalled: '{{name}} はインストール済みです',
noDescription: '説明がありません',
recommendation: {
pause: '自動ローテーションを一時停止',
@@ -1345,6 +1353,20 @@ const jaJP = {
bindSpaceWarning:
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
passkeySectionTitle: 'パスキー (Passkey)',
passkeySectionDesc:
'生体認証やセキュリティキーを使って、パスワード不要で安全にログインします',
addPasskey: 'パスキーを追加',
passkeyName: 'キー名',
passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey',
passkeyCreated: '作成日: {{date}}',
passkeyLastUsed: '最終使用: {{date}}',
noPasskeys: '登録されているパスキーはありません',
deletePasskeyConfirm:
'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。',
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
+22
View File
@@ -86,6 +86,11 @@ const ruRU = {
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью',
loginWithPassword: 'Войти с паролем',
loginWithPasskey: 'Войти с помощью Passkey',
passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...',
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
passkeyNotSupported:
'Passkey не поддерживается в этом браузере или на устройстве',
spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
@@ -758,6 +763,9 @@ const ruRU = {
downloadComplete: 'Плагин "{{name}}" загружен',
installFailed: 'Ошибка установки, попробуйте позже',
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
installed: 'Установлено',
updateAvailable: 'Доступно обновление',
alreadyInstalled: '{{name}} уже установлен',
noDescription: 'Описание отсутствует',
recommendation: {
pause: 'Приостановить авто-прокрутку',
@@ -1350,6 +1358,20 @@ const ruRU = {
bindSpaceWarning:
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
passkeySectionTitle: 'Ключи доступа (Passkey)',
passkeySectionDesc:
'Безопасный вход без пароля с помощью биометрии или аппаратного ключа',
addPasskey: 'Добавить ключ доступа',
passkeyName: 'Название ключа',
passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey',
passkeyCreated: 'Создан {{date}}',
passkeyLastUsed: 'Последнее использование: {{date}}',
noPasskeys: 'Нет зарегистрированных ключей доступа',
deletePasskeyConfirm:
'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.',
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
passkeyDeleteSuccess: 'Ключ доступа удален',
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
+21
View File
@@ -86,6 +86,10 @@ const thTH = {
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey',
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
@@ -737,6 +741,9 @@ const thTH = {
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
installed: 'ติดตั้งแล้ว',
updateAvailable: 'มีอัปเดต',
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
noDescription: 'ไม่มีคำอธิบาย',
recommendation: {
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
@@ -1321,6 +1328,20 @@ const thTH = {
bindSpaceWarning:
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
passkeySectionTitle: 'พาสคีย์ (Passkey)',
passkeySectionDesc:
'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย',
addPasskey: 'เพิ่มพาสคีย์',
passkeyName: 'ชื่อคีย์',
passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey',
passkeyCreated: 'สร้างเมื่อ {{date}}',
passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}',
noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน',
deletePasskeyConfirm:
'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก',
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
+21
View File
@@ -87,6 +87,10 @@ const viVN = {
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
loginLocal: 'Đăng nhập với tài khoản cục bộ',
loginWithPassword: 'Đăng nhập bằng mật khẩu',
loginWithPasskey: 'Đăng nhập bằng Passkey',
passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...',
passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại',
passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey',
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginDescription:
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
@@ -752,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',
@@ -1344,6 +1351,20 @@ const viVN = {
bindSpaceWarning:
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
passkeySectionTitle: 'Mã khóa truy cập (Passkey)',
passkeySectionDesc:
'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật',
addPasskey: 'Thêm mã khóa truy cập',
passkeyName: 'Tên khóa',
passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey',
passkeyCreated: 'Được tạo vào {{date}}',
passkeyLastUsed: 'Sử dụng lần cuối: {{date}}',
noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký',
deletePasskeyConfirm:
'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.',
passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công',
passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập',
passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công',
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
bindSpaceInvalidState:
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
+20
View File
@@ -84,6 +84,10 @@ const zhHans = {
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录',
loginWithPasskey: '使用 Passkey 登录',
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -711,6 +715,9 @@ const zhHans = {
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
downloadComplete: '插件 "{{name}}" 下载完成',
installFailed: '安装失败,请稍后重试',
installed: '已安装',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安装',
loadFailed: '获取插件列表失败,请稍后重试',
noDescription: '暂无描述',
recommendation: {
@@ -1274,6 +1281,19 @@ const zhHans = {
bindSpaceWarning:
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。',
bindSpaceSuccess: 'LangBot 账号绑定成功',
passkeySectionTitle: '通行密钥 (Passkey)',
passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录',
addPasskey: '添加通行密钥',
passkeyName: '密钥名称',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '创建于 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '暂未绑定任何通行密钥',
deletePasskeyConfirm:
'确定要删除此通行密钥吗?删除后将无法使用该密钥登录。',
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
+20
View File
@@ -84,6 +84,10 @@ const zhHant = {
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入',
loginWithPasskey: '使用 Passkey 登入',
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
passkeyLoginFailed: 'Passkey 登入失敗',
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
spaceLoginTitle: '透過 LangBot 帳號登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼',
@@ -715,6 +719,9 @@ const zhHant = {
downloadComplete: '插件 "{{name}}" 下載完成',
installFailed: '安裝失敗,請稍後重試',
loadFailed: '取得插件列表失敗,請稍後重試',
installed: '已安裝',
updateAvailable: '有更新',
alreadyInstalled: '{{name}} 已安裝',
noDescription: '暫無描述',
recommendation: {
pause: '暫停自動輪播',
@@ -1275,6 +1282,19 @@ const zhHant = {
bindSpaceWarning:
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
bindSpaceSuccess: 'LangBot 帳號綁定成功',
passkeySectionTitle: '通行密鑰 (Passkey)',
passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入',
addPasskey: '新增通行密鑰',
passkeyName: '金鑰名稱',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '建立於 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '尚未綁定任何通行密鑰',
deletePasskeyConfirm:
'確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。',
passkeyAddedSuccess: '通行密鑰新增成功',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyRenameSuccess: '通行密鑰重新命名成功',
bindSpaceFailed: '綁定 LangBot 帳號失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',