mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
Merge remote-tracking branch 'origin/master' into dev/4.11.x
# Conflicts: # pyproject.toml # src/langbot/pkg/pipeline/controller.py # uv.lock # web/src/app/home/bots/components/bot-form/BotForm.tsx # web/src/app/home/components/home-sidebar/HomeSidebar.tsx # web/src/app/home/components/home-sidebar/SidebarDataContext.tsx # web/src/app/home/plugin-pages/page.tsx # web/src/app/infra/entities/api/index.ts
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build and deploy production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [deploy/prod]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: langbot-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
||||||
|
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
||||||
|
SPACE_REF: 178b1634c104e635c706e1fb4ca37cb3de073cf9
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build exact Core image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
||||||
|
${{ env.CORE_IMAGE }}:deploy-prod
|
||||||
|
cache-from: type=gha,scope=core-prod
|
||||||
|
cache-to: type=gha,mode=max,scope=core-prod
|
||||||
|
- name: Checkout production Cloud adapter
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: langbot-app/langbot-space
|
||||||
|
ref: ${{ env.SPACE_REF }}
|
||||||
|
token: ${{ secrets.CLA_PAT }}
|
||||||
|
path: .space
|
||||||
|
- name: Build exact Cloud Core image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .space
|
||||||
|
file: .space/Dockerfile.cloud
|
||||||
|
push: true
|
||||||
|
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
||||||
|
tags: |
|
||||||
|
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
|
||||||
|
${{ env.CLOUD_IMAGE }}:deploy-prod
|
||||||
|
cache-from: type=gha,scope=cloud-core-prod
|
||||||
|
cache-to: type=gha,mode=max,scope=cloud-core-prod
|
||||||
Executable
+97
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
cd /opt/langbot-cloud-prod
|
||||||
|
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
|
||||||
|
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
|
||||||
|
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
|
||||||
|
|
||||||
|
rendered_compose=$(docker compose config)
|
||||||
|
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
||||||
|
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
|
||||||
|
exit 4
|
||||||
|
}
|
||||||
|
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
||||||
|
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
|
||||||
|
exit 5
|
||||||
|
}
|
||||||
|
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
|
||||||
|
echo 'Cloud telemetry ingest token must be configured' >&2
|
||||||
|
exit 6
|
||||||
|
}
|
||||||
|
|
||||||
|
update_env() {
|
||||||
|
local key=$1 value=$2
|
||||||
|
python3 - "$key" "$value" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path('.env')
|
||||||
|
key, value = sys.argv[1:]
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
updated = False
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.startswith(f'{key}='):
|
||||||
|
lines[index] = f'{key}={value}'
|
||||||
|
updated = True
|
||||||
|
break
|
||||||
|
if not updated:
|
||||||
|
lines.append(f'{key}={value}')
|
||||||
|
temporary = Path('.env.tmp')
|
||||||
|
temporary.write_text('\n'.join(lines) + '\n')
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
temporary.replace(path)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
update_env LANGBOT_IMAGE_TAG "$TAG"
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
|
||||||
|
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if docker compose pull postgres redis migrate plugin-runtime core; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 5 ]; then
|
||||||
|
echo "docker compose pull failed after $attempt attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
delay=$((attempt * 10))
|
||||||
|
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
docker compose up -d postgres redis
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
|
||||||
|
|
||||||
|
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
|
||||||
|
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
|
||||||
|
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
|
||||||
|
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
|
||||||
|
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
|
||||||
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
|
||||||
|
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
|
||||||
|
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
|
||||||
|
GRANT USAGE ON SCHEMA public TO langbot_runtime;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
docker compose --profile tools run --rm migrate
|
||||||
|
|
||||||
|
docker compose up -d --remove-orphans plugin-runtime core
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
|
||||||
|
docker compose ps
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker compose logs --tail=200 core plugin-runtime >&2
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
container_name: langbot-cloud-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: langbot
|
||||||
|
POSTGRES_USER: langbot_operator
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
container_name: langbot-cloud-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
||||||
|
profiles: [tools]
|
||||||
|
command: [uv, run, langbot, migrate, --cloud]
|
||||||
|
environment: &core-env
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
|
||||||
|
SYSTEM__EDITION: cloud
|
||||||
|
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
|
||||||
|
SYSTEM__JWT__SECRET: ${JWT_SECRET}
|
||||||
|
SYSTEM__LIMITATION__MAX_BOTS: "2"
|
||||||
|
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
|
||||||
|
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
|
||||||
|
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
|
||||||
|
API__WEBHOOK_PREFIX: https://cloud.langbot.app
|
||||||
|
API__WEBUI_URL: https://cloud.langbot.app
|
||||||
|
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
|
||||||
|
DATABASE__USE: postgresql
|
||||||
|
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
|
||||||
|
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
|
||||||
|
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
|
||||||
|
VDB__USE: pgvector
|
||||||
|
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
|
||||||
|
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
|
||||||
|
PLUGIN__ENABLE: "true"
|
||||||
|
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
|
||||||
|
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
|
||||||
|
PLUGIN__WORKER__MAX_CPUS: "0.25"
|
||||||
|
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
|
||||||
|
PLUGIN__WORKER__MAX_PIDS: "128"
|
||||||
|
PLUGIN__WORKER__MAX_WORKERS: "16"
|
||||||
|
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
|
||||||
|
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
|
||||||
|
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
|
||||||
|
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
||||||
|
# Cloud v2 currently grants no managed Box capability. Keep the shared
|
||||||
|
# runtime deployed but disable Core integration until a hard-quota-capable
|
||||||
|
# backend can satisfy the fail-closed Cloud readiness contract.
|
||||||
|
BOX__ENABLED: "false"
|
||||||
|
BOX__BACKEND: nsjail
|
||||||
|
BOX__RUNTIME__ENDPOINT: ws://box:5410
|
||||||
|
BOX__ADMISSION__REQUIRED: "true"
|
||||||
|
BOX__ADMISSION__LOGICAL_SESSION_ID: global
|
||||||
|
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
|
||||||
|
BOX__ADMISSION__MAX_SESSIONS: "1"
|
||||||
|
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
|
||||||
|
BOX__ADMISSION__CPUS: "0.25"
|
||||||
|
BOX__ADMISSION__MEMORY_MB: "256"
|
||||||
|
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
|
||||||
|
BOX__LOCAL__HOST_ROOT: /app/data/box
|
||||||
|
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
|
||||||
|
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
|
||||||
|
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
||||||
|
MCP__STDIO__ENABLED: "false"
|
||||||
|
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
|
||||||
|
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
||||||
|
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
||||||
|
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
|
||||||
|
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
|
||||||
|
SPACE__URL: https://space.langbot.app
|
||||||
|
depends_on:
|
||||||
|
postgres: {condition: service_healthy}
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
plugin-runtime:
|
||||||
|
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
||||||
|
container_name: langbot-cloud-plugin-runtime
|
||||||
|
restart: unless-stopped
|
||||||
|
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
|
||||||
|
environment:
|
||||||
|
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- plugin-data:/app/data
|
||||||
|
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||||
|
cgroup: host
|
||||||
|
privileged: true
|
||||||
|
expose: ["5400"]
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
box:
|
||||||
|
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
||||||
|
container_name: langbot-cloud-box
|
||||||
|
restart: unless-stopped
|
||||||
|
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
|
||||||
|
environment:
|
||||||
|
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
||||||
|
LANGBOT_BOX_ROOT: /app/data/box
|
||||||
|
volumes:
|
||||||
|
- box-data:/app/data/box
|
||||||
|
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||||
|
cgroup: host
|
||||||
|
privileged: true
|
||||||
|
expose: ["5410"]
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
core:
|
||||||
|
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
||||||
|
container_name: langbot-cloud-core
|
||||||
|
restart: unless-stopped
|
||||||
|
environment: *core-env
|
||||||
|
volumes:
|
||||||
|
- core-data:/app/data
|
||||||
|
- box-data:/app/data/box
|
||||||
|
depends_on:
|
||||||
|
postgres: {condition: service_healthy}
|
||||||
|
redis: {condition: service_healthy}
|
||||||
|
plugin-runtime: {condition: service_started}
|
||||||
|
box: {condition: service_started}
|
||||||
|
expose: ["5300"]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
networks: [internal, shared-network]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
shared-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
|
plugin-data:
|
||||||
|
box-data:
|
||||||
|
core-data:
|
||||||
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
|
|||||||
WORKSPACE_VIEW = 'workspace.view'
|
WORKSPACE_VIEW = 'workspace.view'
|
||||||
WORKSPACE_UPDATE = 'workspace.update'
|
WORKSPACE_UPDATE = 'workspace.update'
|
||||||
WORKSPACE_DELETE = 'workspace.delete'
|
WORKSPACE_DELETE = 'workspace.delete'
|
||||||
OWNER_TRANSFER = 'owner.transfer'
|
|
||||||
MEMBER_VIEW = 'member.view'
|
MEMBER_VIEW = 'member.view'
|
||||||
MEMBER_INVITE = 'member.invite'
|
MEMBER_INVITE = 'member.invite'
|
||||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||||
@@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
|||||||
if permission
|
if permission
|
||||||
not in {
|
not in {
|
||||||
Permission.WORKSPACE_DELETE,
|
Permission.WORKSPACE_DELETE,
|
||||||
Permission.OWNER_TRANSFER,
|
|
||||||
Permission.BILLING_LINK_MANAGE,
|
Permission.BILLING_LINK_MANAGE,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class AuthType(enum.Enum):
|
|||||||
|
|
||||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
||||||
{
|
{
|
||||||
Permission.OWNER_TRANSFER.value,
|
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -285,8 +285,25 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
request_context.workspace_uuid,
|
request_context.workspace_uuid,
|
||||||
)
|
)
|
||||||
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
||||||
owner_space_bound = bool(owner and owner.space_account_uuid)
|
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||||
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
|
||||||
|
# Cloud Accounts authenticate through LangBot Account, so every projected
|
||||||
|
# Workspace owner is already bound even when this Core has no local OAuth
|
||||||
|
# token row (model billing uses the owner's control-plane API key).
|
||||||
|
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
||||||
|
if cloud_mode:
|
||||||
|
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
||||||
|
credits = (
|
||||||
|
catalog_service.get_workspace_credits(access.workspace.uuid)
|
||||||
|
if catalog_service is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
credits = (
|
||||||
|
await self.ap.space_service.get_credits(owner.user)
|
||||||
|
if owner is not None and owner.space_account_uuid
|
||||||
|
else None
|
||||||
|
)
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'credits': credits,
|
'credits': credits,
|
||||||
@@ -302,8 +319,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.success(data={'initialized': False})
|
return self.success(data={'initialized': False})
|
||||||
|
|
||||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||||
|
if cloud_mode:
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
|
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import uuid
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||||
|
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
@@ -113,6 +114,23 @@ async def _require_workspace_provider(
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cloud_runtime(ap: app.Application) -> bool:
|
||||||
|
mode = getattr(ap.persistence_mgr, 'mode', None)
|
||||||
|
return getattr(mode, 'value', None) == 'cloud_runtime'
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_cloud_managed_provider_mutable(
|
||||||
|
ap: app.Application,
|
||||||
|
context: TenantContext,
|
||||||
|
provider_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
if not _is_cloud_runtime(ap):
|
||||||
|
return
|
||||||
|
provider = await _require_workspace_provider(ap, context, provider_uuid)
|
||||||
|
if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER:
|
||||||
|
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||||
|
|
||||||
|
|
||||||
async def _require_runtime_provider(
|
async def _require_runtime_provider(
|
||||||
ap: app.Application,
|
ap: app.Application,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
@@ -213,6 +231,7 @@ class LLMModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||||
@@ -291,11 +310,17 @@ class LLMModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
async def update_llm_model(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
model_uuid: str,
|
||||||
|
model_data: dict,
|
||||||
|
) -> None:
|
||||||
"""Update an existing LLM model"""
|
"""Update an existing LLM model"""
|
||||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -321,6 +346,7 @@ class LLMModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -355,6 +381,11 @@ class LLMModelsService:
|
|||||||
|
|
||||||
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an LLM model"""
|
"""Delete an LLM model"""
|
||||||
|
if _is_cloud_runtime(self.ap):
|
||||||
|
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||||
|
if existing_model is None:
|
||||||
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||||
@@ -448,7 +479,10 @@ class EmbeddingModelsService:
|
|||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_embedding_model(
|
async def create_embedding_model(
|
||||||
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
model_data: dict,
|
||||||
|
preserve_uuid: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new embedding model"""
|
"""Create a new embedding model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
@@ -472,6 +506,7 @@ class EmbeddingModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -530,11 +565,17 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
async def update_embedding_model(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
model_uuid: str,
|
||||||
|
model_data: dict,
|
||||||
|
) -> None:
|
||||||
"""Update an existing embedding model"""
|
"""Update an existing embedding model"""
|
||||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -559,6 +600,7 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -593,6 +635,11 @@ class EmbeddingModelsService:
|
|||||||
|
|
||||||
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete an embedding model"""
|
"""Delete an embedding model"""
|
||||||
|
if _is_cloud_runtime(self.ap):
|
||||||
|
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||||
|
if existing_model is None:
|
||||||
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||||
@@ -685,7 +732,12 @@ class RerankModelsService:
|
|||||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||||
|
|
||||||
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
|
async def create_rerank_model(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
model_data: dict,
|
||||||
|
preserve_uuid: bool = False,
|
||||||
|
) -> str:
|
||||||
"""Create a new rerank model"""
|
"""Create a new rerank model"""
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
if not preserve_uuid:
|
if not preserve_uuid:
|
||||||
@@ -708,6 +760,7 @@ class RerankModelsService:
|
|||||||
model_data['provider_uuid'] = provider_uuid
|
model_data['provider_uuid'] = provider_uuid
|
||||||
|
|
||||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -766,11 +819,17 @@ class RerankModelsService:
|
|||||||
|
|
||||||
return model_dict
|
return model_dict
|
||||||
|
|
||||||
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
async def update_rerank_model(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
model_uuid: str,
|
||||||
|
model_data: dict,
|
||||||
|
) -> None:
|
||||||
"""Update an existing rerank model"""
|
"""Update an existing rerank model"""
|
||||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||||
if existing_model is None:
|
if existing_model is None:
|
||||||
raise WorkspaceNotFoundError('Model not found')
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
model_data = model_data.copy()
|
model_data = model_data.copy()
|
||||||
model_data.pop('uuid', None)
|
model_data.pop('uuid', None)
|
||||||
model_data.pop('workspace_uuid', None)
|
model_data.pop('workspace_uuid', None)
|
||||||
@@ -795,6 +854,7 @@ class RerankModelsService:
|
|||||||
|
|
||||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -829,6 +889,11 @@ class RerankModelsService:
|
|||||||
|
|
||||||
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||||
"""Delete a rerank model"""
|
"""Delete a rerank model"""
|
||||||
|
if _is_cloud_runtime(self.ap):
|
||||||
|
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||||
|
if existing_model is None:
|
||||||
|
raise WorkspaceNotFoundError('Model not found')
|
||||||
|
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.RerankModel).where(
|
sqlalchemy.delete(persistence_model.RerankModel).where(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import traceback
|
|||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
@@ -20,6 +21,20 @@ class ModelProviderService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
def _is_cloud_runtime(self) -> bool:
|
||||||
|
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||||
|
return getattr(mode, 'value', None) == 'cloud_runtime'
|
||||||
|
|
||||||
|
def _system_requester_is_reserved(self, requester: object) -> bool:
|
||||||
|
return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None:
|
||||||
|
if not self._is_cloud_runtime():
|
||||||
|
return
|
||||||
|
provider = await self.get_provider(context, provider_uuid)
|
||||||
|
if provider is not None and self._system_requester_is_reserved(provider.get('requester')):
|
||||||
|
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||||
if api_keys is None:
|
if api_keys is None:
|
||||||
@@ -99,6 +114,8 @@ class ModelProviderService:
|
|||||||
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
||||||
"""Create a new provider"""
|
"""Create a new provider"""
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
|
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||||
|
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||||
provider_data['uuid'] = str(uuid.uuid4())
|
provider_data['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(
|
provider_data['api_keys'] = self._normalize_api_keys(
|
||||||
@@ -115,7 +132,10 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
||||||
"""Update an existing provider"""
|
"""Update an existing provider"""
|
||||||
|
await self._assert_provider_mutable(context, provider_uuid)
|
||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
|
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||||
|
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||||
provider_data.pop('uuid', None)
|
provider_data.pop('uuid', None)
|
||||||
provider_data.pop('workspace_uuid', None)
|
provider_data.pop('workspace_uuid', None)
|
||||||
if 'api_keys' in provider_data:
|
if 'api_keys' in provider_data:
|
||||||
@@ -145,6 +165,7 @@ class ModelProviderService:
|
|||||||
|
|
||||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||||
"""Delete a provider (only if no models reference it)"""
|
"""Delete a provider (only if no models reference it)"""
|
||||||
|
await self._assert_provider_mutable(context, provider_uuid)
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
# Check if any models use this provider
|
# Check if any models use this provider
|
||||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -245,6 +266,8 @@ class ModelProviderService:
|
|||||||
api_keys: list,
|
api_keys: list,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Find existing provider or create new one"""
|
"""Find existing provider or create new one"""
|
||||||
|
if self._system_requester_is_reserved(requester):
|
||||||
|
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any, Protocol, runtime_checkable
|
|||||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||||
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
||||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||||
|
from .model_catalog import CloudModelCatalogProvider
|
||||||
|
|
||||||
|
|
||||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||||
@@ -50,6 +51,7 @@ class OpenSourceDeployment:
|
|||||||
)
|
)
|
||||||
directory_provider: None = None
|
directory_provider: None = None
|
||||||
manifest_provider: None = None
|
manifest_provider: None = None
|
||||||
|
model_catalog_provider: None = None
|
||||||
persistence_mode: str = 'oss_compat'
|
persistence_mode: str = 'oss_compat'
|
||||||
required_vector_backend: str | None = None
|
required_vector_backend: str | None = None
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ class VerifiedCloudDeployment:
|
|||||||
entitlement_provider: EntitlementProvider
|
entitlement_provider: EntitlementProvider
|
||||||
directory_provider: DirectoryProjectionProvider
|
directory_provider: DirectoryProjectionProvider
|
||||||
manifest_provider: CloudManifestProvider
|
manifest_provider: CloudManifestProvider
|
||||||
|
model_catalog_provider: CloudModelCatalogProvider
|
||||||
verification_key_id: str
|
verification_key_id: str
|
||||||
mode: str = dataclasses.field(default='cloud', init=False)
|
mode: str = dataclasses.field(default='cloud', init=False)
|
||||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||||
@@ -110,6 +113,8 @@ class VerifiedCloudDeployment:
|
|||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
||||||
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
||||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
||||||
|
if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider):
|
||||||
|
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter')
|
||||||
|
|
||||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -138,8 +143,14 @@ class VerifiedCloudDeployment:
|
|||||||
if plugin_worker.get('require_hard_limits') is not True:
|
if plugin_worker.get('require_hard_limits') is not True:
|
||||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||||
box_config = config.get('box', {})
|
box_config = config.get('box', {})
|
||||||
if box_config.get('enabled') is not True:
|
box_enabled = box_config.get('enabled')
|
||||||
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
if box_enabled is False:
|
||||||
|
# Explicitly disabling Box removes the sandbox surface entirely and
|
||||||
|
# therefore does not weaken tenant isolation. Validate the strict
|
||||||
|
# runtime/admission contract only when the surface is enabled.
|
||||||
|
return
|
||||||
|
if box_enabled is not True:
|
||||||
|
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
|
||||||
if box_config.get('backend') != 'nsjail':
|
if box_config.get('backend') != 'nsjail':
|
||||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
||||||
|
|
||||||
|
from ..entity.persistence import model as persistence_model
|
||||||
|
|
||||||
|
|
||||||
|
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
|
||||||
|
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
|
||||||
|
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
|
||||||
|
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
|
||||||
|
_MODEL_TABLES = (
|
||||||
|
persistence_model.LLMModel,
|
||||||
|
persistence_model.EmbeddingModel,
|
||||||
|
persistence_model.RerankModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CloudModelCatalogItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||||
|
|
||||||
|
uuid: str = Field(min_length=1, max_length=255)
|
||||||
|
model_id: str = Field(min_length=1, max_length=255)
|
||||||
|
category: Literal['chat', 'embedding', 'rerank']
|
||||||
|
llm_abilities: tuple[str, ...] = ()
|
||||||
|
is_featured: bool = False
|
||||||
|
featured_order: int = 0
|
||||||
|
|
||||||
|
@field_validator('llm_abilities', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_missing_abilities(cls, value: Any) -> Any:
|
||||||
|
return () if value is None else value
|
||||||
|
|
||||||
|
@field_validator('llm_abilities')
|
||||||
|
@classmethod
|
||||||
|
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
if any(not item.strip() or len(item) > 64 for item in value):
|
||||||
|
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
|
||||||
|
if len(set(value)) != len(value):
|
||||||
|
raise ValueError('Model abilities must be unique')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CloudWorkspaceModelBilling(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||||
|
|
||||||
|
workspace_uuid: str = Field(min_length=36, max_length=36)
|
||||||
|
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
|
||||||
|
api_key: SecretStr | None = None
|
||||||
|
credits: int | None = None
|
||||||
|
|
||||||
|
@field_validator('workspace_uuid')
|
||||||
|
@classmethod
|
||||||
|
def validate_uuid(cls, value: str) -> str:
|
||||||
|
return str(uuid.UUID(value))
|
||||||
|
|
||||||
|
@field_validator('owner_account_uuid')
|
||||||
|
@classmethod
|
||||||
|
def validate_optional_uuid(cls, value: str | None) -> str | None:
|
||||||
|
return None if value is None else str(uuid.UUID(value))
|
||||||
|
|
||||||
|
|
||||||
|
class CloudModelCatalogSnapshot(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||||
|
|
||||||
|
instance_uuid: str = Field(min_length=1, max_length=255)
|
||||||
|
generated_at: datetime
|
||||||
|
base_url: str = Field(min_length=1, max_length=512)
|
||||||
|
models: tuple[CloudModelCatalogItem, ...]
|
||||||
|
workspaces: tuple[CloudWorkspaceModelBilling, ...]
|
||||||
|
|
||||||
|
@field_validator('base_url')
|
||||||
|
@classmethod
|
||||||
|
def validate_base_url(cls, value: str) -> str:
|
||||||
|
normalized = value.rstrip('/')
|
||||||
|
if not normalized.startswith('https://'):
|
||||||
|
raise ValueError('Cloud model gateway base URL must use HTTPS')
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator('models')
|
||||||
|
@classmethod
|
||||||
|
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
|
||||||
|
if len(value) > 500:
|
||||||
|
raise ValueError('Cloud model catalog exceeds 500 models')
|
||||||
|
identities = {(item.category, item.uuid) for item in value}
|
||||||
|
if len(identities) != len(value):
|
||||||
|
raise ValueError('Cloud model catalog contains duplicate model identities')
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator('workspaces')
|
||||||
|
@classmethod
|
||||||
|
def validate_workspaces(
|
||||||
|
cls, value: tuple[CloudWorkspaceModelBilling, ...]
|
||||||
|
) -> tuple[CloudWorkspaceModelBilling, ...]:
|
||||||
|
if len(value) > 10_000:
|
||||||
|
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
|
||||||
|
identities = {item.workspace_uuid for item in value}
|
||||||
|
if len(identities) != len(value):
|
||||||
|
raise ValueError('Cloud model catalog contains duplicate Workspaces')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CloudModelCatalogProvider(Protocol):
|
||||||
|
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
||||||
|
"""Fetch and verify the complete model catalog and Workspace billing projection."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def system_provider_uuid(workspace_uuid: str) -> str:
|
||||||
|
workspace = str(uuid.UUID(workspace_uuid))
|
||||||
|
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
|
||||||
|
|
||||||
|
|
||||||
|
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
|
||||||
|
workspace = str(uuid.UUID(workspace_uuid))
|
||||||
|
if category not in _SUPPORTED_CATEGORIES:
|
||||||
|
raise ValueError(f'Unsupported model category: {category}')
|
||||||
|
if not upstream_uuid:
|
||||||
|
raise ValueError('Upstream model UUID is required')
|
||||||
|
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
|
||||||
|
|
||||||
|
|
||||||
|
class CloudModelCatalogSyncService:
|
||||||
|
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ap: Any,
|
||||||
|
provider: CloudModelCatalogProvider,
|
||||||
|
instance_uuid: str,
|
||||||
|
*,
|
||||||
|
sync_interval_seconds: float = 3600.0,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(provider, CloudModelCatalogProvider):
|
||||||
|
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
|
||||||
|
if sync_interval_seconds < 10:
|
||||||
|
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
|
||||||
|
self.ap = ap
|
||||||
|
self.provider = provider
|
||||||
|
self.instance_uuid = instance_uuid
|
||||||
|
self.sync_interval_seconds = float(sync_interval_seconds)
|
||||||
|
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
|
||||||
|
# convergence marker so a failed runtime reload is retried even when the
|
||||||
|
# following database reconciliation is a no-op.
|
||||||
|
self._runtime_reload_pending = False
|
||||||
|
self._workspace_credits: dict[str, int | None] = {}
|
||||||
|
|
||||||
|
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
||||||
|
"""Return the latest signed owner-credit projection for a Workspace."""
|
||||||
|
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
await self.sync_once(reload_runtime=False)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self.sync_interval_seconds)
|
||||||
|
try:
|
||||||
|
await self.sync_once(reload_runtime=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
# Exception messages can contain rendered SQL bound values,
|
||||||
|
# including provider API keys. Log only the exception class.
|
||||||
|
self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')
|
||||||
|
|
||||||
|
async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
|
||||||
|
summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
snapshot: CloudModelCatalogSnapshot | None = None
|
||||||
|
sync_error: Exception | None = None
|
||||||
|
reload_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
|
||||||
|
if snapshot.instance_uuid != self.instance_uuid:
|
||||||
|
raise ValueError('Cloud model catalog targets another LangBot instance')
|
||||||
|
|
||||||
|
bindings = await self.ap.workspace_service.list_active_execution_bindings()
|
||||||
|
billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
|
||||||
|
missing = sorted(
|
||||||
|
binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
|
||||||
|
)
|
||||||
|
|
||||||
|
for binding in bindings:
|
||||||
|
counts = await self._sync_workspace(
|
||||||
|
binding.workspace_uuid,
|
||||||
|
snapshot,
|
||||||
|
billing_by_workspace[binding.workspace_uuid],
|
||||||
|
)
|
||||||
|
summary['workspaces'] += 1
|
||||||
|
workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
|
||||||
|
if workspace_changed:
|
||||||
|
# _sync_workspace returns only after its tenant UoW commits.
|
||||||
|
self._runtime_reload_pending = True
|
||||||
|
for key in ('created', 'updated', 'deleted'):
|
||||||
|
summary[key] += counts[key]
|
||||||
|
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
|
||||||
|
except Exception as exc:
|
||||||
|
sync_error = exc
|
||||||
|
finally:
|
||||||
|
model_mgr = getattr(self.ap, 'model_mgr', None)
|
||||||
|
if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
|
||||||
|
try:
|
||||||
|
await model_mgr.load_models_from_db()
|
||||||
|
except Exception as exc:
|
||||||
|
reload_error = exc
|
||||||
|
else:
|
||||||
|
self._runtime_reload_pending = False
|
||||||
|
|
||||||
|
if sync_error is not None:
|
||||||
|
if reload_error is not None:
|
||||||
|
raise sync_error from reload_error
|
||||||
|
raise sync_error
|
||||||
|
if reload_error is not None:
|
||||||
|
raise reload_error
|
||||||
|
|
||||||
|
changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
|
||||||
|
if changed and snapshot is not None:
|
||||||
|
self.ap.logger.info(
|
||||||
|
'Cloud model catalog synchronized '
|
||||||
|
f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
|
||||||
|
f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
async def _sync_workspace(
|
||||||
|
self,
|
||||||
|
workspace_uuid: str,
|
||||||
|
snapshot: CloudModelCatalogSnapshot,
|
||||||
|
billing: CloudWorkspaceModelBilling,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
counts = {'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
provider_uuid = system_provider_uuid(workspace_uuid)
|
||||||
|
desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []
|
||||||
|
|
||||||
|
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
||||||
|
provider = await uow.session.scalar(
|
||||||
|
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||||
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider_values = {
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'name': LANGBOT_MODELS_PROVIDER_NAME,
|
||||||
|
'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
'base_url': snapshot.base_url,
|
||||||
|
'api_keys': desired_keys,
|
||||||
|
}
|
||||||
|
if provider is None:
|
||||||
|
provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
|
||||||
|
uow.session.add(provider)
|
||||||
|
await uow.session.flush()
|
||||||
|
counts['created'] += 1
|
||||||
|
elif self._update_entity(provider, provider_values):
|
||||||
|
counts['updated'] += 1
|
||||||
|
|
||||||
|
existing_by_table: dict[type, dict[str, Any]] = {}
|
||||||
|
for table in _MODEL_TABLES:
|
||||||
|
rows = (
|
||||||
|
await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
|
||||||
|
).all()
|
||||||
|
existing_by_table[table] = {row.uuid: row for row in rows}
|
||||||
|
|
||||||
|
desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
|
||||||
|
for item in snapshot.models:
|
||||||
|
table, values = self._model_values(workspace_uuid, provider_uuid, item)
|
||||||
|
model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
|
||||||
|
desired_ids[table].add(model_uuid)
|
||||||
|
existing = existing_by_table[table].get(model_uuid)
|
||||||
|
if existing is None:
|
||||||
|
uow.session.add(table(uuid=model_uuid, **values))
|
||||||
|
counts['created'] += 1
|
||||||
|
elif self._update_entity(existing, values):
|
||||||
|
counts['updated'] += 1
|
||||||
|
|
||||||
|
for table, entities in existing_by_table.items():
|
||||||
|
for model_uuid, entity in entities.items():
|
||||||
|
if model_uuid not in desired_ids[table]:
|
||||||
|
await uow.session.delete(entity)
|
||||||
|
counts['deleted'] += 1
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
|
||||||
|
changed = False
|
||||||
|
for key, value in values.items():
|
||||||
|
if getattr(entity, key) != value:
|
||||||
|
setattr(entity, key, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _model_values(
|
||||||
|
workspace_uuid: str,
|
||||||
|
provider_uuid: str,
|
||||||
|
item: CloudModelCatalogItem,
|
||||||
|
) -> tuple[type, dict[str, Any]]:
|
||||||
|
ranking = 100 - item.featured_order if item.is_featured else 0
|
||||||
|
common = {
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'name': item.model_id,
|
||||||
|
'provider_uuid': provider_uuid,
|
||||||
|
'extra_args': {},
|
||||||
|
'prefered_ranking': ranking,
|
||||||
|
}
|
||||||
|
if item.category == 'chat':
|
||||||
|
return persistence_model.LLMModel, {
|
||||||
|
**common,
|
||||||
|
'abilities': list(item.llm_abilities),
|
||||||
|
'context_length': None,
|
||||||
|
}
|
||||||
|
if item.category == 'embedding':
|
||||||
|
return persistence_model.EmbeddingModel, common
|
||||||
|
if item.category == 'rerank':
|
||||||
|
return persistence_model.RerankModel, common
|
||||||
|
raise ValueError(f'Unsupported model category: {item.category}')
|
||||||
@@ -56,6 +56,7 @@ from ..cloud import launch as cloud_launch_module
|
|||||||
from ..cloud import support_admin as cloud_support_admin_module
|
from ..cloud import support_admin as cloud_support_admin_module
|
||||||
from ..cloud import directory_projection as cloud_directory_projection_module
|
from ..cloud import directory_projection as cloud_directory_projection_module
|
||||||
from ..cloud import entitlements as cloud_entitlements_module
|
from ..cloud import entitlements as cloud_entitlements_module
|
||||||
|
from ..cloud import model_catalog as cloud_model_catalog_module
|
||||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -147,13 +148,12 @@ class Application:
|
|||||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||||
|
|
||||||
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
||||||
|
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
||||||
|
cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None
|
||||||
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
||||||
|
|
||||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||||
|
|
||||||
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
|
||||||
|
|
||||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||||
|
|
||||||
http_ctrl: http_controller.HTTPController = None
|
http_ctrl: http_controller.HTTPController = None
|
||||||
@@ -320,6 +320,12 @@ class Application:
|
|||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
if self.cloud_model_catalog_service is not None:
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
self.cloud_model_catalog_service.run(),
|
||||||
|
name='cloud-model-catalog-sync',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
if self.manifest_refresh_service is not None:
|
if self.manifest_refresh_service is not None:
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
self.manifest_refresh_service.run(),
|
self.manifest_refresh_service.run(),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from ...cloud import support_admin as cloud_support_admin_module
|
|||||||
from ...cloud.directory import directory_projection_limits_from_config
|
from ...cloud.directory import directory_projection_limits_from_config
|
||||||
from ...cloud.directory_projection import DirectoryProjectionService
|
from ...cloud.directory_projection import DirectoryProjectionService
|
||||||
from ...cloud.entitlements import EntitlementResolver
|
from ...cloud.entitlements import EntitlementResolver
|
||||||
|
from ...cloud.model_catalog import CloudModelCatalogSyncService
|
||||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
from ...api.http.authz import WorkspaceRequiredError
|
from ...api.http.authz import WorkspaceRequiredError
|
||||||
|
|
||||||
@@ -181,6 +182,16 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
# of repeating tenant validation for every manager.
|
# of repeating tenant validation for every manager.
|
||||||
await workspace_service_inst.prime_startup_execution_bindings()
|
await workspace_service_inst.prime_startup_execution_bindings()
|
||||||
|
|
||||||
|
if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment):
|
||||||
|
raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment')
|
||||||
|
cloud_model_catalog_service = CloudModelCatalogSyncService(
|
||||||
|
ap,
|
||||||
|
deployment.model_catalog_provider,
|
||||||
|
constants.instance_id,
|
||||||
|
)
|
||||||
|
await cloud_model_catalog_service.initialize()
|
||||||
|
ap.cloud_model_catalog_service = cloud_model_catalog_service
|
||||||
|
|
||||||
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
||||||
ap,
|
ap,
|
||||||
workspace_service_inst,
|
workspace_service_inst,
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ class WorkspaceMembership(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
||||||
|
sqlalchemy.Index(
|
||||||
|
'uq_workspace_memberships_one_active_owner',
|
||||||
|
'workspace_uuid',
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
||||||
|
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
||||||
|
),
|
||||||
sqlalchemy.CheckConstraint(
|
sqlalchemy.CheckConstraint(
|
||||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||||
name='ck_workspace_memberships_role',
|
name='ck_workspace_memberships_role',
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""add durable replay protection for signed Space launch assertions
|
||||||
|
|
||||||
|
Revision ID: 0016_space_launch_replay
|
||||||
|
Revises: 0015_cloud_core_collab
|
||||||
|
Create Date: 2026-07-31
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0016_space_launch_replay'
|
||||||
|
down_revision = '0015_cloud_core_collab'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE = 'space_launch_assertion_consumptions'
|
||||||
|
_POLICY = 'langbot_directory_projection'
|
||||||
|
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if _TABLE not in set(sa.inspect(conn).get_table_names()):
|
||||||
|
op.create_table(
|
||||||
|
_TABLE,
|
||||||
|
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('jti', sa.String(255), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_space_launch_assertion_consumptions_expiry',
|
||||||
|
_TABLE,
|
||||||
|
['instance_uuid', 'expires_at'],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
if conn.dialect.name == 'postgresql':
|
||||||
|
table = conn.dialect.identifier_preparer.quote(_TABLE)
|
||||||
|
policy = conn.dialect.identifier_preparer.quote(_POLICY)
|
||||||
|
expression = f'instance_uuid::text = {_SETTING}'
|
||||||
|
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||||
|
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||||
|
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||||
|
f'USING ({expression}) WITH CHECK ({expression})'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
|
||||||
|
op.drop_table(_TABLE)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""merge the published Space launch replay and main migration branches
|
||||||
|
|
||||||
|
Revision ID: 0018_merge_launch_replay
|
||||||
|
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
|
||||||
|
Create Date: 2026-08-01
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
revision = '0018_merge_launch_replay'
|
||||||
|
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""enforce one active owner per Workspace
|
||||||
|
|
||||||
|
Revision ID: 0019_single_workspace_owner
|
||||||
|
Revises: 0018_merge_launch_replay
|
||||||
|
Create Date: 2026-08-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0019_single_workspace_owner'
|
||||||
|
down_revision = '0018_merge_launch_replay'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'workspace_memberships' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ownership transfer used to promote a second member without demoting the
|
||||||
|
# original owner. Preserve the Workspace creator where possible and demote
|
||||||
|
# every historical extra owner before installing the database invariant.
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
WITH ranked_owners AS (
|
||||||
|
SELECT membership.uuid,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY membership.workspace_uuid
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
COALESCE(membership.joined_at, membership.created_at),
|
||||||
|
membership.uuid
|
||||||
|
) AS owner_rank
|
||||||
|
FROM workspace_memberships AS membership
|
||||||
|
JOIN workspaces AS workspace
|
||||||
|
ON workspace.uuid = membership.workspace_uuid
|
||||||
|
WHERE membership.role = 'owner'
|
||||||
|
AND membership.status = 'active'
|
||||||
|
)
|
||||||
|
UPDATE workspace_memberships
|
||||||
|
SET role = 'admin'
|
||||||
|
WHERE uuid IN (
|
||||||
|
SELECT uuid
|
||||||
|
FROM ranked_owners
|
||||||
|
WHERE owner_rank > 1
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Fresh installations may already have this index because SQLAlchemy
|
||||||
|
# metadata is created before Alembic advances the revision marker.
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
||||||
|
'uq_workspace_memberships_one_active_owner '
|
||||||
|
'ON workspace_memberships (workspace_uuid) '
|
||||||
|
"WHERE role = 'owner' AND status = 'active'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'workspace_memberships' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
|
||||||
|
if _INDEX_NAME in index_names:
|
||||||
|
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""merge AgentRunner and Cloud Workspace migration heads
|
||||||
|
|
||||||
|
Revision ID: 0020_merge_agent_cloud_heads
|
||||||
|
Revises: 0018_merge_workspace_heads, 0019_single_workspace_owner
|
||||||
|
Create Date: 2026-08-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
revision = '0020_merge_agent_cloud_heads'
|
||||||
|
down_revision = ('0018_merge_workspace_heads', '0019_single_workspace_owner')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -210,7 +210,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
|||||||
'now': sqlalchemy.sql.functions.now,
|
'now': sqlalchemy.sql.functions.now,
|
||||||
'sum': sqlalchemy.sql.functions.sum,
|
'sum': sqlalchemy.sql.functions.sum,
|
||||||
}
|
}
|
||||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
||||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||||
sqlalchemy.sql.dml.UpdateBase,
|
sqlalchemy.sql.dml.UpdateBase,
|
||||||
|
|||||||
@@ -709,28 +709,37 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if len(listener_tasks) >= 100:
|
if len(listener_tasks) >= 100:
|
||||||
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
|
||||||
return
|
return
|
||||||
token = _current_pipeline_uuid.set(pipeline_uuid)
|
listener = typing.cast(
|
||||||
try:
|
typing.Callable[[typing.Any, typing.Any], typing.Awaitable[None]],
|
||||||
task_manager = getattr(self.ap, 'task_mgr', None)
|
listeners[event.__class__],
|
||||||
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
)
|
||||||
listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
|
||||||
else:
|
async def run_listener():
|
||||||
listener_task = task_manager.create_task(
|
token = _current_pipeline_uuid.set(pipeline_uuid)
|
||||||
listeners[event.__class__](event, callback_adapter),
|
try:
|
||||||
kind='websocket-message',
|
await listener(event, callback_adapter)
|
||||||
name=f'websocket-message-{connection.connection_id}',
|
finally:
|
||||||
scopes=[
|
_current_pipeline_uuid.reset(token)
|
||||||
core_entities.LifecycleControlScope.APPLICATION,
|
|
||||||
core_entities.LifecycleControlScope.PLATFORM,
|
listener_coro = run_listener()
|
||||||
],
|
task_manager = getattr(self.ap, 'task_mgr', None)
|
||||||
instance_uuid=connection.instance_uuid,
|
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
|
||||||
workspace_uuid=connection.workspace_uuid,
|
listener_task = asyncio.create_task(listener_coro)
|
||||||
placement_generation=connection.placement_generation,
|
else:
|
||||||
).task
|
listener_task = task_manager.create_task(
|
||||||
listener_tasks.add(listener_task)
|
listener_coro,
|
||||||
listener_task.add_done_callback(self._listener_task_done)
|
kind='websocket-message',
|
||||||
finally:
|
name=f'websocket-message-{connection.connection_id}',
|
||||||
_current_pipeline_uuid.reset(token)
|
scopes=[
|
||||||
|
core_entities.LifecycleControlScope.APPLICATION,
|
||||||
|
core_entities.LifecycleControlScope.PLATFORM,
|
||||||
|
],
|
||||||
|
instance_uuid=connection.instance_uuid,
|
||||||
|
workspace_uuid=connection.workspace_uuid,
|
||||||
|
placement_generation=connection.placement_generation,
|
||||||
|
).task
|
||||||
|
listener_tasks.add(listener_task)
|
||||||
|
listener_task.add_done_callback(self._listener_task_done)
|
||||||
|
|
||||||
def get_websocket_messages(
|
def get_websocket_messages(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -606,6 +606,8 @@ class WorkspaceCollaborationService:
|
|||||||
) -> WorkspaceMembership:
|
) -> WorkspaceMembership:
|
||||||
if role not in {item.value for item in MembershipRole}:
|
if role not in {item.value for item in MembershipRole}:
|
||||||
raise MembershipPermissionError('Unknown Workspace role')
|
raise MembershipPermissionError('Unknown Workspace role')
|
||||||
|
if role == MembershipRole.OWNER.value:
|
||||||
|
raise MembershipPermissionError('Workspace ownership cannot be transferred')
|
||||||
|
|
||||||
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
async def operation(active_session: AsyncSession) -> WorkspaceMembership:
|
||||||
await self._require_active_workspace(active_session, workspace_uuid)
|
await self._require_active_workspace(active_session, workspace_uuid)
|
||||||
@@ -617,8 +619,8 @@ class WorkspaceCollaborationService:
|
|||||||
target_account_uuid,
|
target_account_uuid,
|
||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
self._require_can_manage_target(persisted_actor, target, new_role=role)
|
||||||
if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value:
|
||||||
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
||||||
target.role = role
|
target.role = role
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -644,7 +646,7 @@ class WorkspaceCollaborationService:
|
|||||||
)
|
)
|
||||||
self._require_can_manage_target(persisted_actor, target)
|
self._require_can_manage_target(persisted_actor, target)
|
||||||
if target.role == MembershipRole.OWNER.value:
|
if target.role == MembershipRole.OWNER.value:
|
||||||
await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
|
raise LastOwnerError('The Workspace owner cannot be removed or demoted')
|
||||||
target.status = MembershipStatus.REMOVED.value
|
target.status = MembershipStatus.REMOVED.value
|
||||||
await active_session.flush()
|
await active_session.flush()
|
||||||
return target
|
return target
|
||||||
@@ -751,26 +753,6 @@ class WorkspaceCollaborationService:
|
|||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
return persisted_actor
|
return persisted_actor
|
||||||
|
|
||||||
async def _require_another_owner(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
workspace_uuid: str,
|
|
||||||
excluded_account_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
owners = (
|
|
||||||
await session.scalars(
|
|
||||||
sqlalchemy.select(WorkspaceMembership)
|
|
||||||
.where(
|
|
||||||
WorkspaceMembership.workspace_uuid == workspace_uuid,
|
|
||||||
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
|
|
||||||
WorkspaceMembership.role == MembershipRole.OWNER.value,
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
|
|
||||||
raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
|
|
||||||
|
|
||||||
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
|
||||||
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
|
||||||
raise WorkspaceNotFoundError('Workspace not found')
|
raise WorkspaceNotFoundError('Workspace not found')
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Run: uv run pytest tests/integration/api/test_smoke.py -q
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||||
|
|
||||||
@@ -304,12 +306,34 @@ class TestUserInitEndpoint:
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_authenticated_invitation_acceptance_in_cloud(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.deployment = SimpleNamespace(mode='cloud')
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': True, 'space_login_enabled': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['data'] == {
|
||||||
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
|
||||||
fake_api_app.user_service.is_initialized.return_value = True
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
|||||||
@@ -333,7 +333,6 @@ async def test_support_admin_request_context_has_actor_owner_and_no_membership(s
|
|||||||
assert Permission.RESOURCE_MANAGE.value in permissions
|
assert Permission.RESOURCE_MANAGE.value in permissions
|
||||||
assert not permissions.intersection(
|
assert not permissions.intersection(
|
||||||
{
|
{
|
||||||
Permission.OWNER_TRANSFER.value,
|
|
||||||
Permission.MEMBER_VIEW.value,
|
Permission.MEMBER_VIEW.value,
|
||||||
Permission.MEMBER_INVITE.value,
|
Permission.MEMBER_INVITE.value,
|
||||||
Permission.MEMBER_UPDATE_ROLE.value,
|
Permission.MEMBER_UPDATE_ROLE.value,
|
||||||
|
|||||||
@@ -272,9 +272,10 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
'/api/v1/user/space-credits',
|
'/api/v1/user/space-credits',
|
||||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
)
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data'] == {
|
assert payload['data'] == {
|
||||||
'credits': 25000,
|
'credits': 25000,
|
||||||
'owner_space_bound': True,
|
'owner_space_bound': True,
|
||||||
'is_workspace_owner': True,
|
'is_workspace_owner': True,
|
||||||
@@ -282,6 +283,31 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.user_service.get_workspace_owner = AsyncMock(return_value=None)
|
||||||
|
application.space_service.get_credits = AsyncMock()
|
||||||
|
application.cloud_model_catalog_service = SimpleNamespace(
|
||||||
|
get_workspace_credits=lambda workspace_uuid: 25000 if workspace_uuid == WORKSPACE_UUID else None
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space-credits',
|
||||||
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['data'] == {
|
||||||
|
'credits': 25000,
|
||||||
|
'owner_space_bound': True,
|
||||||
|
'is_workspace_owner': True,
|
||||||
|
}
|
||||||
|
application.space_service.get_credits.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
workspace_uuid = current['workspace']['uuid']
|
workspace_uuid = current['workspace']['uuid']
|
||||||
assert current['membership']['role'] == 'owner'
|
assert current['membership']['role'] == 'owner'
|
||||||
assert 'member.invite' in current['permissions']
|
assert 'member.invite' in current['permissions']
|
||||||
|
assert 'owner.transfer' not in current['permissions']
|
||||||
|
|
||||||
invite_response = await client.post(
|
invite_response = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
@@ -263,6 +264,14 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
|
|||||||
assert member_current['membership']['role'] == 'viewer'
|
assert member_current['membership']['role'] == 'viewer'
|
||||||
assert 'member.invite' not in member_current['permissions']
|
assert 'member.invite' not in member_current['permissions']
|
||||||
|
|
||||||
|
transfer_response = await client.patch(
|
||||||
|
f'/api/v1/workspaces/{workspace_uuid}/members/{member_current["membership"]["account_uuid"]}',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
json={'role': 'owner'},
|
||||||
|
)
|
||||||
|
assert transfer_response.status_code == 403
|
||||||
|
assert (await transfer_response.get_json())['code'] == 'permission_denied'
|
||||||
|
|
||||||
forbidden_invite = await client.post(
|
forbidden_invite = await client.post(
|
||||||
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
f'/api/v1/workspaces/{workspace_uuid}/invitations',
|
||||||
headers=_auth(member_token, workspace_uuid),
|
headers=_auth(member_token, workspace_uuid),
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ class TestSQLiteMigrationBaseline:
|
|||||||
class TestSQLiteMigrationUpgrade:
|
class TestSQLiteMigrationUpgrade:
|
||||||
"""Tests for upgrade to head workflow."""
|
"""Tests for upgrade to head workflow."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine):
|
||||||
|
"""A database released at the production-only 0016 head must remain upgradable."""
|
||||||
|
async with sqlite_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay')
|
||||||
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
|
assert _get_script_head() == '0020_merge_agent_cloud_heads'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
||||||
|
"""A database at the 4.11 development head must absorb later master migrations."""
|
||||||
|
async with sqlite_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
await run_alembic_stamp(sqlite_engine, '0018_merge_workspace_heads')
|
||||||
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
|
assert _get_script_head() == '0020_merge_agent_cloud_heads'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.entity.persistence.user import User
|
||||||
|
from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceMembership
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_owner_migration_demotes_historical_extra_owner_and_installs_unique_index(tmp_path):
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "single-owner.db"}')
|
||||||
|
try:
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
await connection.execute(sa.text('DROP INDEX uq_workspace_memberships_one_active_owner'))
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
workspace_uuid = '00000000-0000-4000-8000-000000000001'
|
||||||
|
creator_uuid = '00000000-0000-4000-8000-000000000010'
|
||||||
|
promoted_uuid = '00000000-0000-4000-8000-000000000020'
|
||||||
|
async with session_factory() as session:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
User(
|
||||||
|
uuid=creator_uuid,
|
||||||
|
user='creator@example.test',
|
||||||
|
normalized_email='creator@example.test',
|
||||||
|
password='hash',
|
||||||
|
account_type='local',
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
uuid=promoted_uuid,
|
||||||
|
user='promoted@example.test',
|
||||||
|
normalized_email='promoted@example.test',
|
||||||
|
password='hash',
|
||||||
|
account_type='local',
|
||||||
|
),
|
||||||
|
Workspace(
|
||||||
|
uuid=workspace_uuid,
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
name='Workspace',
|
||||||
|
slug='workspace',
|
||||||
|
type='team',
|
||||||
|
status='active',
|
||||||
|
source='local',
|
||||||
|
created_by_account_uuid=creator_uuid,
|
||||||
|
),
|
||||||
|
WorkspaceMembership(
|
||||||
|
uuid='00000000-0000-4000-8000-000000000100',
|
||||||
|
workspace_uuid=workspace_uuid,
|
||||||
|
account_uuid=creator_uuid,
|
||||||
|
role='owner',
|
||||||
|
status='active',
|
||||||
|
),
|
||||||
|
WorkspaceMembership(
|
||||||
|
uuid='00000000-0000-4000-8000-000000000200',
|
||||||
|
workspace_uuid=workspace_uuid,
|
||||||
|
account_uuid=promoted_uuid,
|
||||||
|
role='owner',
|
||||||
|
status='active',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await run_alembic_stamp(engine, '0018_merge_launch_replay')
|
||||||
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
roles = dict(
|
||||||
|
(
|
||||||
|
await connection.execute(
|
||||||
|
sa.text(
|
||||||
|
'SELECT account_uuid, role FROM workspace_memberships '
|
||||||
|
'WHERE workspace_uuid = :workspace_uuid ORDER BY account_uuid'
|
||||||
|
),
|
||||||
|
{'workspace_uuid': workspace_uuid},
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert roles == {creator_uuid: 'owner', promoted_uuid: 'admin'}
|
||||||
|
indexes = await connection.run_sync(
|
||||||
|
lambda sync_connection: {
|
||||||
|
index['name'] for index in sa.inspect(sync_connection).get_indexes('workspace_memberships')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert 'uq_workspace_memberships_one_active_owner' in indexes
|
||||||
|
|
||||||
|
with pytest.raises(sa.exc.IntegrityError):
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.execute(
|
||||||
|
sa.text("UPDATE workspace_memberships SET role = 'owner' WHERE account_uuid = :account_uuid"),
|
||||||
|
{'account_uuid': promoted_uuid},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -180,7 +180,7 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega
|
|||||||
assert membership['status'] == 'active'
|
assert membership['status'] == 'active'
|
||||||
|
|
||||||
|
|
||||||
async def test_published_owner_repair_revision_upgrades_to_merged_head(legacy_engine):
|
async def test_development_owner_repair_revision_upgrades_to_merged_head(legacy_engine):
|
||||||
await run_alembic_upgrade(legacy_engine, '0017_local_owner_repair')
|
await run_alembic_upgrade(legacy_engine, '0017_local_owner_repair')
|
||||||
assert await get_alembic_current(legacy_engine) == '0017_local_owner_repair'
|
assert await get_alembic_current(legacy_engine) == '0017_local_owner_repair'
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ def test_owner_has_every_fixed_permission():
|
|||||||
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
|
def test_admin_cannot_delete_workspace_or_link_billing():
|
||||||
ctx = _context(authz.WorkspaceRole.ADMIN)
|
ctx = _context(authz.WorkspaceRole.ADMIN)
|
||||||
|
|
||||||
assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
|
|
||||||
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
|
||||||
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
|
||||||
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Cloud Runtime write protection for the managed LangBot Models catalog."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.service import model as model_service_module
|
||||||
|
from langbot.pkg.api.http.service.model import (
|
||||||
|
EmbeddingModelsService,
|
||||||
|
LLMModelsService,
|
||||||
|
RerankModelsService,
|
||||||
|
_assert_cloud_managed_provider_mutable,
|
||||||
|
)
|
||||||
|
from langbot.pkg.cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACE = 'workspace-a'
|
||||||
|
PROVIDER = 'managed-provider'
|
||||||
|
MODEL = 'managed-model'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_managed_provider_guard_is_cloud_only(monkeypatch) -> None:
|
||||||
|
async def managed_provider(_ap, _context, provider_uuid):
|
||||||
|
assert provider_uuid == PROVIDER
|
||||||
|
return {'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER}
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_service_module, '_require_workspace_provider', managed_provider)
|
||||||
|
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await _assert_cloud_managed_provider_mutable(
|
||||||
|
application,
|
||||||
|
WORKSPACE,
|
||||||
|
PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
application.persistence_mgr.mode.value = 'normal'
|
||||||
|
await _assert_cloud_managed_provider_mutable(
|
||||||
|
application,
|
||||||
|
WORKSPACE,
|
||||||
|
PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('service_type', 'create_method', 'model_data'),
|
||||||
|
[
|
||||||
|
(LLMModelsService, 'create_llm_model', {'provider_uuid': PROVIDER, 'name': 'chat', 'abilities': []}),
|
||||||
|
(EmbeddingModelsService, 'create_embedding_model', {'provider_uuid': PROVIDER, 'name': 'embedding'}),
|
||||||
|
(RerankModelsService, 'create_rerank_model', {'provider_uuid': PROVIDER, 'name': 'rerank'}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_model_types_reject_creation_under_managed_provider(
|
||||||
|
monkeypatch,
|
||||||
|
service_type,
|
||||||
|
create_method: str,
|
||||||
|
model_data: dict,
|
||||||
|
) -> None:
|
||||||
|
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
||||||
|
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
||||||
|
application = SimpleNamespace(
|
||||||
|
persistence_mgr=SimpleNamespace(),
|
||||||
|
provider_service=SimpleNamespace(
|
||||||
|
get_provider=AsyncMock(return_value={'uuid': PROVIDER, 'requester': LANGBOT_MODELS_PROVIDER_REQUESTER})
|
||||||
|
),
|
||||||
|
model_mgr=None,
|
||||||
|
)
|
||||||
|
service = service_type(application)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await getattr(service, create_method)(WORKSPACE, model_data)
|
||||||
|
|
||||||
|
guard.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('service_type', 'get_method', 'write_method', 'payload'),
|
||||||
|
[
|
||||||
|
(LLMModelsService, 'get_llm_model', 'update_llm_model', {'name': 'changed'}),
|
||||||
|
(LLMModelsService, 'get_llm_model', 'delete_llm_model', None),
|
||||||
|
(EmbeddingModelsService, 'get_embedding_model', 'update_embedding_model', {'name': 'changed'}),
|
||||||
|
(EmbeddingModelsService, 'get_embedding_model', 'delete_embedding_model', None),
|
||||||
|
(RerankModelsService, 'get_rerank_model', 'update_rerank_model', {'name': 'changed'}),
|
||||||
|
(RerankModelsService, 'get_rerank_model', 'delete_rerank_model', None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_model_types_reject_update_and_delete_for_managed_provider(
|
||||||
|
monkeypatch,
|
||||||
|
service_type,
|
||||||
|
get_method: str,
|
||||||
|
write_method: str,
|
||||||
|
payload: dict | None,
|
||||||
|
) -> None:
|
||||||
|
guard = AsyncMock(side_effect=ValueError('LangBot Models is managed by Cloud and cannot be modified'))
|
||||||
|
monkeypatch.setattr(model_service_module, '_assert_cloud_managed_provider_mutable', guard)
|
||||||
|
application = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime')))
|
||||||
|
service = service_type(application)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
get_method,
|
||||||
|
AsyncMock(return_value={'uuid': MODEL, 'provider_uuid': PROVIDER, 'extra_args': {}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
args = (WORKSPACE, MODEL) if payload is None else (WORKSPACE, MODEL, payload)
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await getattr(service, write_method)(*args)
|
||||||
|
|
||||||
|
guard.assert_awaited_once()
|
||||||
@@ -25,6 +25,7 @@ from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
|||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
WORKSPACE_UUID = 'workspace-a'
|
WORKSPACE_UUID = 'workspace-a'
|
||||||
|
SYSTEM_REQUESTER = 'space-chat-completions'
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_provider(
|
def _create_mock_provider(
|
||||||
@@ -1005,3 +1006,56 @@ class TestProviderSecretRoundtrip:
|
|||||||
)
|
)
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCloudManagedProviderProtection:
|
||||||
|
@staticmethod
|
||||||
|
def _service() -> ModelProviderService:
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
persistence_mgr=SimpleNamespace(
|
||||||
|
mode=SimpleNamespace(value='cloud_runtime'),
|
||||||
|
execute_async=AsyncMock(),
|
||||||
|
),
|
||||||
|
model_mgr=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
return ModelProviderService(ap)
|
||||||
|
|
||||||
|
async def test_cloud_rejects_user_created_system_requester(self):
|
||||||
|
service = self._service()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='reserved'):
|
||||||
|
await service.create_provider(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
{
|
||||||
|
'name': 'Fake LangBot Models',
|
||||||
|
'requester': SYSTEM_REQUESTER,
|
||||||
|
'base_url': 'https://example.invalid/v1',
|
||||||
|
'api_keys': ['fake'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='reserved'):
|
||||||
|
await service.find_or_create_provider(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
SYSTEM_REQUESTER,
|
||||||
|
'https://api.langbot.cloud/v1',
|
||||||
|
['fake'],
|
||||||
|
)
|
||||||
|
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
||||||
|
service = self._service()
|
||||||
|
service.get_provider = AsyncMock(
|
||||||
|
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
||||||
|
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_oss_does_not_reserve_space_requester(self):
|
||||||
|
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
||||||
|
service = ModelProviderService(ap)
|
||||||
|
assert service._system_requester_is_reserved(SYSTEM_REQUESTER) is False
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class _Provider:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.manifest_provider = _Manifest()
|
self.manifest_provider = _Manifest()
|
||||||
|
|
||||||
|
async def fetch_model_catalog(self, instance_uuid: str):
|
||||||
|
del instance_uuid
|
||||||
|
raise AssertionError('not used by bootstrap contract tests')
|
||||||
|
|
||||||
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
def bootstrap(self, *, instance_uuid: str, instance_config: dict):
|
||||||
del instance_config
|
del instance_config
|
||||||
return VerifiedCloudDeployment(
|
return VerifiedCloudDeployment(
|
||||||
@@ -79,6 +83,7 @@ class _Provider:
|
|||||||
entitlement_provider=_Entitlements(),
|
entitlement_provider=_Entitlements(),
|
||||||
directory_provider=_Directory(),
|
directory_provider=_Directory(),
|
||||||
manifest_provider=self.manifest_provider,
|
manifest_provider=self.manifest_provider,
|
||||||
|
model_catalog_provider=self,
|
||||||
verification_key_id='root-2026',
|
verification_key_id='root-2026',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,10 +233,23 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_runtime_allows_explicitly_disabled_box():
|
||||||
|
config = _cloud_config()
|
||||||
|
config['box']['enabled'] = False
|
||||||
|
|
||||||
|
deployment = await resolve_deployment(
|
||||||
|
instance_uuid='instance-a',
|
||||||
|
instance_config=config,
|
||||||
|
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
||||||
|
now=1_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(deployment, VerifiedCloudDeployment)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
('mutate', 'message'),
|
('mutate', 'message'),
|
||||||
[
|
[
|
||||||
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
|
|
||||||
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
||||||
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.cloud.model_catalog import (
|
||||||
|
CloudModelCatalogSnapshot,
|
||||||
|
CloudModelCatalogSyncService,
|
||||||
|
system_model_uuid,
|
||||||
|
system_provider_uuid,
|
||||||
|
)
|
||||||
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
from langbot.pkg.entity.persistence.model import EmbeddingModel, LLMModel, ModelProvider
|
||||||
|
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
INSTANCE_UUID = 'instance-model-catalog'
|
||||||
|
WORKSPACE_A = '00000000-0000-4000-8000-000000000001'
|
||||||
|
WORKSPACE_B = '00000000-0000-4000-8000-000000000002'
|
||||||
|
OWNER_A = '10000000-0000-4000-8000-000000000001'
|
||||||
|
OWNER_B = '10000000-0000-4000-8000-000000000002'
|
||||||
|
|
||||||
|
|
||||||
|
class _CatalogProvider:
|
||||||
|
def __init__(self, snapshot: CloudModelCatalogSnapshot) -> None:
|
||||||
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
||||||
|
assert instance_uuid == INSTANCE_UUID
|
||||||
|
return self.snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(
|
||||||
|
*,
|
||||||
|
key_a: str | None = 'owner-a-key',
|
||||||
|
model_id: str = 'gpt-test',
|
||||||
|
include_embedding: bool = True,
|
||||||
|
) -> CloudModelCatalogSnapshot:
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
'uuid': 'upstream-chat',
|
||||||
|
'model_id': model_id,
|
||||||
|
'category': 'chat',
|
||||||
|
'llm_abilities': ['chat', 'vision'],
|
||||||
|
'is_featured': True,
|
||||||
|
'featured_order': 7,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if include_embedding:
|
||||||
|
models.append(
|
||||||
|
{
|
||||||
|
'uuid': 'upstream-embedding',
|
||||||
|
'model_id': 'embedding-test',
|
||||||
|
'category': 'embedding',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return CloudModelCatalogSnapshot.model_validate(
|
||||||
|
{
|
||||||
|
'instance_uuid': INSTANCE_UUID,
|
||||||
|
'generated_at': datetime.now(UTC),
|
||||||
|
'base_url': 'https://api.langbot.cloud/v1/',
|
||||||
|
'models': models,
|
||||||
|
'workspaces': [
|
||||||
|
{
|
||||||
|
'workspace_uuid': WORKSPACE_A,
|
||||||
|
'owner_account_uuid': OWNER_A,
|
||||||
|
'api_key': key_a,
|
||||||
|
'credits': 25000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'workspace_uuid': WORKSPACE_B,
|
||||||
|
'owner_account_uuid': OWNER_B,
|
||||||
|
'api_key': 'owner-b-key',
|
||||||
|
'credits': 5000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_snapshot_treats_null_model_abilities_as_empty() -> None:
|
||||||
|
payload = _snapshot().model_dump(mode='json')
|
||||||
|
payload['models'][0]['llm_abilities'] = None
|
||||||
|
|
||||||
|
snapshot = CloudModelCatalogSnapshot.model_validate(payload)
|
||||||
|
|
||||||
|
assert snapshot.models[0].llm_abilities == ()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_reconciles_every_workspace_idempotently_and_tracks_owner_and_downlisting(tmp_path) -> None:
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "model-catalog.db"}')
|
||||||
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||||
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||||
|
bindings = [
|
||||||
|
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_A, placement_generation=1),
|
||||||
|
SimpleNamespace(instance_uuid=INSTANCE_UUID, workspace_uuid=WORKSPACE_B, placement_generation=1),
|
||||||
|
]
|
||||||
|
workspace_service = SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings))
|
||||||
|
reload_counter = _AsyncCounter()
|
||||||
|
runtime_reload = SimpleNamespace(load_models_from_db=reload_counter)
|
||||||
|
app = SimpleNamespace(
|
||||||
|
persistence_mgr=manager,
|
||||||
|
workspace_service=workspace_service,
|
||||||
|
model_mgr=runtime_reload,
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
provider = _CatalogProvider(_snapshot())
|
||||||
|
service = CloudModelCatalogSyncService(app, provider, INSTANCE_UUID)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.insert(Workspace),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'uuid': WORKSPACE_A,
|
||||||
|
'instance_uuid': INSTANCE_UUID,
|
||||||
|
'name': 'A',
|
||||||
|
'slug': 'a',
|
||||||
|
'source': 'cloud_projection',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': WORKSPACE_B,
|
||||||
|
'instance_uuid': INSTANCE_UUID,
|
||||||
|
'name': 'B',
|
||||||
|
'slug': 'b',
|
||||||
|
'source': 'cloud_projection',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.insert(ModelProvider).values(
|
||||||
|
uuid='custom-provider',
|
||||||
|
workspace_uuid=WORKSPACE_A,
|
||||||
|
name='Custom',
|
||||||
|
requester='openai-chat-completions',
|
||||||
|
base_url='https://custom.example/v1',
|
||||||
|
api_keys=['custom-key'],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.insert(LLMModel).values(
|
||||||
|
uuid='custom-model',
|
||||||
|
workspace_uuid=WORKSPACE_A,
|
||||||
|
name='custom-model',
|
||||||
|
provider_uuid='custom-provider',
|
||||||
|
abilities=['chat'],
|
||||||
|
extra_args={},
|
||||||
|
prefered_ranking=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
first = await service.sync_once()
|
||||||
|
assert first == {'workspaces': 2, 'created': 6, 'updated': 0, 'deleted': 0}
|
||||||
|
assert reload_counter.calls == 1
|
||||||
|
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
||||||
|
assert service.get_workspace_credits(WORKSPACE_B) == 5000
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
providers = (
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.select(
|
||||||
|
ModelProvider.uuid,
|
||||||
|
ModelProvider.workspace_uuid,
|
||||||
|
ModelProvider.api_keys,
|
||||||
|
).where(ModelProvider.requester == 'space-chat-completions')
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert {item.workspace_uuid for item in providers} == {WORKSPACE_A, WORKSPACE_B}
|
||||||
|
assert {item.uuid for item in providers} == {
|
||||||
|
system_provider_uuid(WORKSPACE_A),
|
||||||
|
system_provider_uuid(WORKSPACE_B),
|
||||||
|
}
|
||||||
|
assert {item.workspace_uuid: item.api_keys for item in providers} == {
|
||||||
|
WORKSPACE_A: ['owner-a-key'],
|
||||||
|
WORKSPACE_B: ['owner-b-key'],
|
||||||
|
}
|
||||||
|
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(LLMModel)) == 3
|
||||||
|
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 2
|
||||||
|
|
||||||
|
second = await service.sync_once()
|
||||||
|
assert second == {'workspaces': 2, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
assert reload_counter.calls == 1
|
||||||
|
|
||||||
|
provider.snapshot = _snapshot(
|
||||||
|
key_a='new-owner-key',
|
||||||
|
model_id='gpt-renamed',
|
||||||
|
include_embedding=False,
|
||||||
|
)
|
||||||
|
third = await service.sync_once()
|
||||||
|
assert third == {'workspaces': 2, 'created': 0, 'updated': 3, 'deleted': 2}
|
||||||
|
assert reload_counter.calls == 2
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
provider_a_keys = await connection.scalar(
|
||||||
|
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
||||||
|
)
|
||||||
|
assert provider_a_keys == ['new-owner-key']
|
||||||
|
system_model_names = (
|
||||||
|
(
|
||||||
|
await connection.execute(
|
||||||
|
sqlalchemy.select(LLMModel.name).where(
|
||||||
|
LLMModel.provider_uuid.in_(
|
||||||
|
[system_provider_uuid(WORKSPACE_A), system_provider_uuid(WORKSPACE_B)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert set(system_model_names) == {'gpt-renamed'}
|
||||||
|
assert await connection.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(EmbeddingModel)) == 0
|
||||||
|
assert (
|
||||||
|
await connection.scalar(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count())
|
||||||
|
.select_from(ModelProvider)
|
||||||
|
.where(ModelProvider.uuid == 'custom-provider')
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await connection.scalar(
|
||||||
|
sqlalchemy.select(sqlalchemy.func.count())
|
||||||
|
.select_from(LLMModel)
|
||||||
|
.where(LLMModel.uuid == 'custom-model')
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
provider.snapshot = _snapshot(key_a=None, model_id='gpt-renamed', include_embedding=False)
|
||||||
|
fourth = await service.sync_once()
|
||||||
|
assert fourth == {'workspaces': 2, 'created': 0, 'updated': 1, 'deleted': 0}
|
||||||
|
assert reload_counter.calls == 3
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
provider_a_keys = await connection.scalar(
|
||||||
|
sqlalchemy.select(ModelProvider.api_keys).where(ModelProvider.uuid == system_provider_uuid(WORKSPACE_A))
|
||||||
|
)
|
||||||
|
assert provider_a_keys == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_scoped_ids_are_stable_and_secrets_are_redacted() -> None:
|
||||||
|
assert system_provider_uuid(WORKSPACE_A) == system_provider_uuid(WORKSPACE_A)
|
||||||
|
assert system_provider_uuid(WORKSPACE_A) != system_provider_uuid(WORKSPACE_B)
|
||||||
|
assert system_model_uuid(WORKSPACE_A, 'chat', 'upstream') != system_model_uuid(WORKSPACE_B, 'chat', 'upstream')
|
||||||
|
snapshot = _snapshot()
|
||||||
|
assert 'owner-a-key' not in repr(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_must_cover_every_active_workspace() -> None:
|
||||||
|
snapshot = _snapshot().model_copy(update={'workspaces': _snapshot().workspaces[:1]})
|
||||||
|
app = SimpleNamespace(
|
||||||
|
workspace_service=SimpleNamespace(
|
||||||
|
list_active_execution_bindings=lambda: _async_value(
|
||||||
|
[SimpleNamespace(workspace_uuid=WORKSPACE_A), SimpleNamespace(workspace_uuid=WORKSPACE_B)]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
service = CloudModelCatalogSyncService(app, _CatalogProvider(snapshot), INSTANCE_UUID)
|
||||||
|
with pytest.raises(ValueError, match='missing billing projections for 1 active Workspaces'):
|
||||||
|
await service.sync_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_value(value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncCounter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def __call__(self) -> None:
|
||||||
|
self.calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_partial_workspace_failure_reloads_already_committed_changes() -> None:
|
||||||
|
bindings = [
|
||||||
|
SimpleNamespace(workspace_uuid=WORKSPACE_A),
|
||||||
|
SimpleNamespace(workspace_uuid=WORKSPACE_B),
|
||||||
|
]
|
||||||
|
reload_counter = _AsyncCounter()
|
||||||
|
app = SimpleNamespace(
|
||||||
|
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
||||||
|
model_mgr=SimpleNamespace(load_models_from_db=reload_counter),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def sync_workspace(*_args):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return {'created': 1, 'updated': 0, 'deleted': 0}
|
||||||
|
raise RuntimeError('second Workspace failed')
|
||||||
|
|
||||||
|
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='second Workspace failed'):
|
||||||
|
await service.sync_once()
|
||||||
|
assert service.get_workspace_credits(WORKSPACE_A) == 25000
|
||||||
|
assert service.get_workspace_credits(WORKSPACE_B) is None
|
||||||
|
assert reload_counter.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_runtime_reload_is_retried_after_noop_sync() -> None:
|
||||||
|
bindings = [SimpleNamespace(workspace_uuid=WORKSPACE_A)]
|
||||||
|
|
||||||
|
class _FlakyReload:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def __call__(self) -> None:
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls == 1:
|
||||||
|
raise RuntimeError('reload failed')
|
||||||
|
|
||||||
|
runtime_reload = _FlakyReload()
|
||||||
|
app = SimpleNamespace(
|
||||||
|
workspace_service=SimpleNamespace(list_active_execution_bindings=lambda: _async_value(bindings)),
|
||||||
|
model_mgr=SimpleNamespace(load_models_from_db=runtime_reload),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
service = CloudModelCatalogSyncService(app, _CatalogProvider(_snapshot()), INSTANCE_UUID)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def sync_workspace(*_args):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return {'created': 1, 'updated': 0, 'deleted': 0}
|
||||||
|
return {'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
|
||||||
|
service._sync_workspace = sync_workspace # type: ignore[method-assign]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='reload failed'):
|
||||||
|
await service.sync_once()
|
||||||
|
summary = await service.sync_once()
|
||||||
|
assert summary == {'workspaces': 1, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||||
|
assert runtime_reload.calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_background_sync_log_redacts_exception_message(caplog) -> None:
|
||||||
|
secret = 'owner-secret-api-key'
|
||||||
|
attempted = asyncio.Event()
|
||||||
|
|
||||||
|
class _FailingProvider:
|
||||||
|
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
||||||
|
del instance_uuid
|
||||||
|
attempted.set()
|
||||||
|
raise RuntimeError(f'database parameters include {secret}')
|
||||||
|
|
||||||
|
app = SimpleNamespace(logger=logging.getLogger(__name__))
|
||||||
|
service = CloudModelCatalogSyncService(app, _FailingProvider(), INSTANCE_UUID)
|
||||||
|
service.sync_interval_seconds = 0.001
|
||||||
|
task = asyncio.create_task(service.run())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(attempted.wait(), timeout=1)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
assert secret not in caplog.text
|
||||||
|
assert 'Cloud model catalog synchronization failed (RuntimeError)' in caplog.text
|
||||||
@@ -961,6 +961,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
|||||||
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
||||||
sa.select(
|
sa.select(
|
||||||
sa.func.now(),
|
sa.func.now(),
|
||||||
|
sa.func.date_trunc('hour', sa.column('timestamp')),
|
||||||
sa.func.length(sa.literal('value')),
|
sa.func.length(sa.literal('value')),
|
||||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
||||||
from langbot.pkg.pipeline.controller import Controller
|
from langbot.pkg.pipeline.controller import Controller
|
||||||
|
from langbot.pkg.pipeline.pool import QueryPool
|
||||||
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
||||||
|
|
||||||
|
|
||||||
@@ -143,3 +144,31 @@ async def test_controller_revalidates_generation_before_running_pipeline(
|
|||||||
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
||||||
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
||||||
session._semaphore.release.assert_called_once_with()
|
session._semaphore.release.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_controller_schedules_query_without_removing_it_twice(mock_app, sample_query):
|
||||||
|
query_pool = QueryPool()
|
||||||
|
query_pool.queries.append(sample_query)
|
||||||
|
mock_app.query_pool = query_pool
|
||||||
|
mock_app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace(_semaphore=asyncio.Semaphore(1)))
|
||||||
|
|
||||||
|
scheduler_errors: list[str] = []
|
||||||
|
|
||||||
|
def stop_on_scheduler_error(message):
|
||||||
|
scheduler_errors.append(str(message))
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
def stop_after_scheduling(process_coro, **_kwargs):
|
||||||
|
process_coro.close()
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
mock_app.logger.error.side_effect = stop_on_scheduler_error
|
||||||
|
mock_app.task_mgr.create_task.side_effect = stop_after_scheduling
|
||||||
|
controller = Controller(mock_app)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await controller.consumer()
|
||||||
|
|
||||||
|
assert scheduler_errors == []
|
||||||
|
assert query_pool.queries == []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Regression tests for isolated embed-widget conversations."""
|
"""Regression tests for isolated embed-widget conversations."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextvars
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
@@ -207,6 +208,48 @@ async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
|||||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pipeline_override_survives_detached_listener_task(monkeypatch):
|
||||||
|
manager = WebSocketConnectionManager()
|
||||||
|
connection = await manager.add_connection(
|
||||||
|
websocket=Mock(),
|
||||||
|
scope=SCOPE_A,
|
||||||
|
pipeline_uuid='pipeline-1',
|
||||||
|
session_type='person',
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||||
|
|
||||||
|
class DetachedTaskManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.tasks = []
|
||||||
|
|
||||||
|
def create_task(self, coro, **_kwargs):
|
||||||
|
task = asyncio.create_task(coro, context=contextvars.Context())
|
||||||
|
self.tasks.append(task)
|
||||||
|
return Mock(task=task)
|
||||||
|
|
||||||
|
task_manager = DetachedTaskManager()
|
||||||
|
adapter = WebSocketAdapter.model_construct(
|
||||||
|
ap=Mock(task_mgr=task_manager),
|
||||||
|
logger=_adapter_logger(),
|
||||||
|
)
|
||||||
|
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||||
|
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||||
|
pipeline_overrides = []
|
||||||
|
|
||||||
|
async def listener(_event, callback_adapter):
|
||||||
|
pipeline_overrides.append(callback_adapter.get_pipeline_uuid_override())
|
||||||
|
|
||||||
|
adapter.listeners = {platform_events.FriendMessage: listener}
|
||||||
|
await adapter.handle_websocket_message(
|
||||||
|
connection,
|
||||||
|
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
||||||
|
)
|
||||||
|
await asyncio.gather(*task_manager.tasks)
|
||||||
|
|
||||||
|
assert pipeline_overrides == ['pipeline-1']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
|
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
|
||||||
manager = WebSocketConnectionManager()
|
manager = WebSocketConnectionManager()
|
||||||
|
|||||||
@@ -203,20 +203,21 @@ async def test_last_owner_cannot_be_demoted(collaboration_context):
|
|||||||
second_membership,
|
second_membership,
|
||||||
)
|
)
|
||||||
|
|
||||||
promoted = await service.update_member_role(
|
with pytest.raises(MembershipPermissionError, match='cannot be transferred'):
|
||||||
workspace.uuid,
|
await service.update_member_role(
|
||||||
second.uuid,
|
workspace.uuid,
|
||||||
'owner',
|
second.uuid,
|
||||||
owner_membership,
|
'owner',
|
||||||
)
|
owner_membership,
|
||||||
assert promoted.role == 'owner'
|
)
|
||||||
demoted = await service.update_member_role(
|
|
||||||
workspace.uuid,
|
with pytest.raises(LastOwnerError):
|
||||||
owner_membership.account_uuid,
|
await service.update_member_role(
|
||||||
'admin',
|
workspace.uuid,
|
||||||
owner_membership,
|
owner_membership.account_uuid,
|
||||||
)
|
'admin',
|
||||||
assert demoted.role == 'admin'
|
owner_membership,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_workspace_selector_requires_membership(collaboration_context):
|
async def test_workspace_selector_requires_membership(collaboration_context):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
beginAuthenticatedSession,
|
beginAuthenticatedSession,
|
||||||
beginSupportAdminSession,
|
beginSupportAdminSession,
|
||||||
bootstrapWorkspaceSession,
|
bootstrapWorkspaceSession,
|
||||||
|
clearPendingInvitationToken,
|
||||||
getPendingInvitationToken,
|
getPendingInvitationToken,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -66,6 +67,10 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
const directLaunchFragmentRef = useRef<{
|
||||||
|
workspaceUuid: string | null;
|
||||||
|
launchAssertion: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
'loading' | 'confirm' | 'success' | 'error'
|
'loading' | 'confirm' | 'success' | 'error'
|
||||||
@@ -108,8 +113,31 @@ function SpaceOAuthCallbackContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beginAuthenticatedSession(response.token, response.user);
|
beginAuthenticatedSession(response.token, response.user);
|
||||||
if (getPendingInvitationToken()) {
|
const invitationToken = getPendingInvitationToken();
|
||||||
navigate('/invitations/accept', { replace: true });
|
if (invitationToken) {
|
||||||
|
let invitation;
|
||||||
|
try {
|
||||||
|
invitation =
|
||||||
|
await httpClient.acceptWorkspaceInvitation(invitationToken);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
const path = code
|
||||||
|
? `/invitations/accept?error=${encodeURIComponent(code)}`
|
||||||
|
: '/invitations/accept';
|
||||||
|
navigate(path, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginAuthenticatedSession(invitation.token, response.user);
|
||||||
|
clearPendingInvitationToken();
|
||||||
|
const workspaceResult = await bootstrapWorkspaceSession({
|
||||||
|
preferredWorkspaceUuid: invitation.workspace_uuid,
|
||||||
|
});
|
||||||
|
if (workspaceResult.status === 'unavailable') {
|
||||||
|
navigate('/workspace-unavailable', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/home', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceResult = await bootstrapWorkspaceSession({
|
const workspaceResult = await bootstrapWorkspaceSession({
|
||||||
@@ -220,8 +248,28 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const errorDescription = searchParams.get('error_description');
|
const errorDescription = searchParams.get('error_description');
|
||||||
const mode = searchParams.get('mode');
|
const mode = searchParams.get('mode');
|
||||||
const state = searchParams.get('state');
|
const state = searchParams.get('state');
|
||||||
const workspaceUuid = searchParams.get('workspace_uuid');
|
if (directLaunchFragmentRef.current === null) {
|
||||||
const launchAssertion = searchParams.get('launch_assertion');
|
const fragmentParams = new URLSearchParams(
|
||||||
|
window.location.hash.startsWith('#')
|
||||||
|
? window.location.hash.slice(1)
|
||||||
|
: window.location.hash,
|
||||||
|
);
|
||||||
|
directLaunchFragmentRef.current = {
|
||||||
|
workspaceUuid: fragmentParams.get('workspace_uuid'),
|
||||||
|
launchAssertion: fragmentParams.get('launch_assertion'),
|
||||||
|
};
|
||||||
|
if (window.location.hash) {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}${window.location.search}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workspaceUuid =
|
||||||
|
directLaunchFragmentRef.current.workspaceUuid ??
|
||||||
|
searchParams.get('workspace_uuid');
|
||||||
|
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import type {
|
|||||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||||
|
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||||
|
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||||
|
|
||||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||||
|
|
||||||
@@ -154,6 +156,12 @@ function AddExtensionContent() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||||
|
const { extensions: extensionQuota, extensionsReached } =
|
||||||
|
useWorkspaceQuotaStatus();
|
||||||
|
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
|
||||||
|
resource: t('sidebar.extensions'),
|
||||||
|
max: extensionQuota.max,
|
||||||
|
});
|
||||||
|
|
||||||
// Localized label for an extension type, used in the install dialog.
|
// Localized label for an extension type, used in the install dialog.
|
||||||
const extensionTypeLabel = (type: string) =>
|
const extensionTypeLabel = (type: string) =>
|
||||||
@@ -344,23 +352,28 @@ function AddExtensionContent() {
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
const handleInstallPlugin = useCallback(
|
||||||
setInstallInfo({
|
async (plugin: PluginV4) => {
|
||||||
plugin_author: plugin.author,
|
if (extensionsReached) return;
|
||||||
plugin_name: plugin.name,
|
setInstallInfo({
|
||||||
plugin_version: plugin.latest_version,
|
plugin_author: plugin.author,
|
||||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
plugin_name: plugin.name,
|
||||||
plugin_description: extractI18nObject(plugin.description) || '',
|
plugin_version: plugin.latest_version,
|
||||||
plugin_icon: plugin.icon || '',
|
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||||
});
|
plugin_description: extractI18nObject(plugin.description) || '',
|
||||||
setInstallExtensionType(plugin.type || 'plugin');
|
plugin_icon: plugin.icon || '',
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
});
|
||||||
setInstallError(null);
|
setInstallExtensionType(plugin.type || 'plugin');
|
||||||
setInstallIconFailed(false);
|
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||||
setModalOpen(true);
|
setInstallError(null);
|
||||||
}, []);
|
setInstallIconFailed(false);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
[extensionsReached],
|
||||||
|
);
|
||||||
|
|
||||||
function handleModalConfirm() {
|
function handleModalConfirm() {
|
||||||
|
if (extensionsReached) return;
|
||||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||||
httpClient
|
httpClient
|
||||||
@@ -402,6 +415,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
if (extensionsReached) return;
|
||||||
if (!validateFileType(file)) {
|
if (!validateFileType(file)) {
|
||||||
toast.error(t('addExtension.unsupportedFileType'));
|
toast.error(t('addExtension.unsupportedFileType'));
|
||||||
return;
|
return;
|
||||||
@@ -421,14 +435,15 @@ function AddExtensionContent() {
|
|||||||
setSkillUploadPreviewOpen(true);
|
setSkillUploadPreviewOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, setSelectedTaskId],
|
[extensionsReached, t, setSelectedTaskId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
|
if (extensionsReached) return;
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [extensionsReached]);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -455,12 +470,13 @@ function AddExtensionContent() {
|
|||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
if (extensionsReached) return;
|
||||||
const files = Array.from(event.dataTransfer.files);
|
const files = Array.from(event.dataTransfer.files);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
uploadFile(files[0]);
|
uploadFile(files[0]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadFile],
|
[extensionsReached, uploadFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleMCPCreated(_serverName: string) {
|
function handleMCPCreated(_serverName: string) {
|
||||||
@@ -490,7 +506,8 @@ function AddExtensionContent() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't check, let backend handle it
|
toast.error(t('limitation.quotaCheckFailed'));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -630,9 +647,11 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubConfirm() {
|
async function handleGithubConfirm() {
|
||||||
if (!selectedAsset || !selectedRelease) return;
|
if (!selectedAsset || !selectedRelease) return;
|
||||||
if (!(await checkExtensionsLimit())) return;
|
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||||
|
if (!(await checkExtensionsLimit())) {
|
||||||
|
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||||
httpClient
|
httpClient
|
||||||
.installPluginFromGithub(
|
.installPluginFromGithub(
|
||||||
@@ -664,9 +683,11 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubSkillConfirm() {
|
async function handleGithubSkillConfirm() {
|
||||||
if (!githubSkillInfo) return;
|
if (!githubSkillInfo) return;
|
||||||
if (!(await checkExtensionsLimit())) return;
|
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||||
|
if (!(await checkExtensionsLimit())) {
|
||||||
|
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await httpClient.installSkillFromGithub(
|
await httpClient.installSkillFromGithub(
|
||||||
githubURL.trim(),
|
githubURL.trim(),
|
||||||
@@ -726,17 +747,24 @@ function AddExtensionContent() {
|
|||||||
setPopoverOpen(open);
|
setPopoverOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<WorkspaceQuotaTooltip
|
||||||
<Button
|
quota={extensionQuota}
|
||||||
variant="default"
|
resource={t('sidebar.extensions')}
|
||||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
>
|
||||||
>
|
<PopoverTrigger asChild>
|
||||||
<PlusIcon className="w-4 h-4" />
|
<Button
|
||||||
<span className="whitespace-nowrap">
|
variant="default"
|
||||||
{t('addExtension.manualAdd')}
|
disabled={extensionsReached}
|
||||||
</span>
|
aria-disabled={extensionsReached}
|
||||||
</Button>
|
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
||||||
</PopoverTrigger>
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{t('addExtension.manualAdd')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
forceMount
|
forceMount
|
||||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||||
@@ -745,9 +773,19 @@ function AddExtensionContent() {
|
|||||||
{/* ===== Menu View ===== */}
|
{/* ===== Menu View ===== */}
|
||||||
{popoverView === 'menu' && (
|
{popoverView === 'menu' && (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
|
{extensionsReached && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
{extensionQuotaTooltip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* File upload area */}
|
{/* File upload area */}
|
||||||
<div
|
<div
|
||||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
aria-disabled={extensionsReached}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
extensionsReached
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
} ${
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||||
@@ -777,7 +815,8 @@ function AddExtensionContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
disabled={extensionsReached}
|
||||||
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={() => setPopoverView('mcp')}
|
onClick={() => setPopoverView('mcp')}
|
||||||
>
|
>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
@@ -796,7 +835,8 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
disabled={extensionsReached}
|
||||||
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={() => setPopoverView('github')}
|
onClick={() => setPopoverView('github')}
|
||||||
>
|
>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
@@ -815,7 +855,8 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
disabled={extensionsReached}
|
||||||
|
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(await checkExtensionsLimit())) return;
|
if (!(await checkExtensionsLimit())) return;
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
@@ -882,6 +923,7 @@ function AddExtensionContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="mcp-form"
|
form="mcp-form"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={extensionsReached}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
if (!(await checkExtensionsLimit())) {
|
if (!(await checkExtensionsLimit())) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -946,6 +988,7 @@ function AddExtensionContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubAddressSubmit}
|
onClick={handleGithubAddressSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
|
extensionsReached ||
|
||||||
!githubURL.trim() ||
|
!githubURL.trim() ||
|
||||||
fetchingReleases ||
|
fetchingReleases ||
|
||||||
fetchingSkillPreview
|
fetchingSkillPreview
|
||||||
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className="w-full" onClick={handleGithubConfirm}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGithubConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubSkillConfirm}
|
onClick={handleGithubSkillConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
>
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
|
|||||||
<MarketPage
|
<MarketPage
|
||||||
installPlugin={handleInstallPlugin}
|
installPlugin={handleInstallPlugin}
|
||||||
headerActions={extensionActions}
|
headerActions={extensionActions}
|
||||||
|
installDisabled={extensionsReached}
|
||||||
|
installDisabledTooltip={extensionQuotaTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
|
|||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleModalConfirm}>
|
<WorkspaceQuotaTooltip
|
||||||
{t('common.confirm')}
|
quota={extensionQuota}
|
||||||
</Button>
|
resource={t('sidebar.extensions')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={handleModalConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||||
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
|
|||||||
{pluginUploadPreviewFile && (
|
{pluginUploadPreviewFile && (
|
||||||
<PluginLocalPreviewPanel
|
<PluginLocalPreviewPanel
|
||||||
file={pluginUploadPreviewFile}
|
file={pluginUploadPreviewFile}
|
||||||
|
quota={extensionQuota}
|
||||||
|
quotaResource={t('sidebar.extensions')}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPluginUploadPreviewOpen(false);
|
setPluginUploadPreviewOpen(false);
|
||||||
setPluginUploadPreviewFile(null);
|
setPluginUploadPreviewFile(null);
|
||||||
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
|
|||||||
{skillUploadPreviewFile && (
|
{skillUploadPreviewFile && (
|
||||||
<SkillZipPreviewPanel
|
<SkillZipPreviewPanel
|
||||||
file={skillUploadPreviewFile}
|
file={skillUploadPreviewFile}
|
||||||
|
quota={extensionQuota}
|
||||||
|
quotaResource={t('sidebar.extensions')}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setSkillUploadPreviewOpen(false);
|
setSkillUploadPreviewOpen(false);
|
||||||
setSkillUploadPreviewFile(null);
|
setSkillUploadPreviewFile(null);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function BotForm({
|
|||||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||||
IDynamicFormItemSchema[]
|
IDynamicFormItemSchema[]
|
||||||
>([]);
|
>([]);
|
||||||
const [, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||||
|
|
||||||
@@ -409,271 +409,278 @@ export default function BotForm({
|
|||||||
<form
|
<form
|
||||||
id="bot-form"
|
id="bot-form"
|
||||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||||
className="space-y-6"
|
aria-busy={isLoading}
|
||||||
>
|
>
|
||||||
{/* Card 1: Basic Information */}
|
<fieldset className="space-y-6" disabled={isLoading}>
|
||||||
<Card>
|
{/* Card 1: Basic Information */}
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
|
||||||
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t('bots.botName')}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Card 2: Adapter Configuration */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('bots.adapterConfigDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="adapter"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t('bots.platformAdapter')}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
handleAdapterSelect(value);
|
|
||||||
}}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[240px] overflow-hidden">
|
|
||||||
{field.value ? (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<img
|
|
||||||
src={httpClient.getAdapterIconURL(field.value)}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 shrink-0 rounded"
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const selectedAdapter = adapterNameList.find(
|
|
||||||
(a) => a.value === field.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{selectedAdapter?.label ?? field.value}
|
|
||||||
</span>
|
|
||||||
{selectedAdapter?.legacy && (
|
|
||||||
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
|
||||||
{t('bots.legacyAdapterBadge')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t('bots.selectAdapter')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{groupedAdapters.map((group) => (
|
|
||||||
<SelectGroup
|
|
||||||
key={group.categoryId ?? 'uncategorized'}
|
|
||||||
>
|
|
||||||
{group.categoryId && (
|
|
||||||
<SelectLabel>
|
|
||||||
{getCategoryLabel(t, group.categoryId)}
|
|
||||||
</SelectLabel>
|
|
||||||
)}
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<SelectItem
|
|
||||||
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
|
||||||
value={item.value}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 w-full items-center gap-2">
|
|
||||||
<img
|
|
||||||
src={httpClient.getAdapterIconURL(
|
|
||||||
item.value,
|
|
||||||
)}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 shrink-0 rounded"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
))}
|
|
||||||
{legacyAdapters.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowLegacyAdapters((v) => !v);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowLegacyAdapters((v) => !v);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
|
|
||||||
>
|
|
||||||
{showLegacyAdapters ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{t('bots.legacyAdapters')}
|
|
||||||
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
|
||||||
{legacyAdapters.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{showLegacyAdapters && (
|
|
||||||
<>
|
|
||||||
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{t('bots.legacyAdaptersHint')}
|
|
||||||
</p>
|
|
||||||
<SelectGroup>
|
|
||||||
{legacyAdapters.map((item) => (
|
|
||||||
<SelectItem
|
|
||||||
key={`legacy:${item.value}`}
|
|
||||||
value={item.value}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
|
|
||||||
<img
|
|
||||||
src={httpClient.getAdapterIconURL(
|
|
||||||
item.value,
|
|
||||||
)}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 shrink-0 rounded grayscale"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
|
||||||
{t('bots.legacyAdapterBadge')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{currentAdapter &&
|
|
||||||
(() => {
|
|
||||||
const docUrl = getAdapterDocUrl(
|
|
||||||
adapterHelpLinks[currentAdapter],
|
|
||||||
i18n.language,
|
|
||||||
);
|
|
||||||
return docUrl ? (
|
|
||||||
<a
|
|
||||||
href={docUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t('bots.viewAdapterDocs')}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
|
||||||
<FormDescription>
|
|
||||||
{adapterDescriptionList[currentAdapter]}
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
|
||||||
<DynamicFormComponent
|
|
||||||
itemConfigList={dynamicFormConfigList}
|
|
||||||
initialValues={currentAdapterConfig}
|
|
||||||
onSubmit={(values) => {
|
|
||||||
form.setValue('adapter_config', values, {
|
|
||||||
shouldDirty: !isInitializing.current,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
systemContext={{
|
|
||||||
webhook_url: webhookUrl,
|
|
||||||
extra_webhook_url: extraWebhookUrl,
|
|
||||||
bot_uuid: initBotId || '',
|
|
||||||
adapter_config: form.getValues('adapter_config') || {},
|
|
||||||
outbound_ips: systemInfo.outbound_ips,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Card 3: Event Routing */}
|
|
||||||
{currentAdapter && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('bots.eventRoutingDescription')}
|
{t('bots.basicInfoDescription')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<EventBindingsEditor
|
<FormField
|
||||||
form={form}
|
control={form.control}
|
||||||
botId={initBotId}
|
name="name"
|
||||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
render={({ field }) => (
|
||||||
agentOptions={agentNameList}
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('bots.botName')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
{/* Card 2: Adapter Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('bots.adapterConfigDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="adapter"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('bots.platformAdapter')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
handleAdapterSelect(value);
|
||||||
|
}}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[240px] overflow-hidden">
|
||||||
|
{field.value ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={httpClient.getAdapterIconURL(
|
||||||
|
field.value,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
className="h-5 w-5 shrink-0 rounded"
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const selectedAdapter = adapterNameList.find(
|
||||||
|
(a) => a.value === field.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{selectedAdapter?.label ?? field.value}
|
||||||
|
</span>
|
||||||
|
{selectedAdapter?.legacy && (
|
||||||
|
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
{t('bots.legacyAdapterBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t('bots.selectAdapter')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{groupedAdapters.map((group) => (
|
||||||
|
<SelectGroup
|
||||||
|
key={group.categoryId ?? 'uncategorized'}
|
||||||
|
>
|
||||||
|
{group.categoryId && (
|
||||||
|
<SelectLabel>
|
||||||
|
{getCategoryLabel(t, group.categoryId)}
|
||||||
|
</SelectLabel>
|
||||||
|
)}
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
||||||
|
value={item.value}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 w-full items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={httpClient.getAdapterIconURL(
|
||||||
|
item.value,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
className="h-5 w-5 shrink-0 rounded"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
{legacyAdapters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowLegacyAdapters((v) => !v);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowLegacyAdapters((v) => !v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
|
||||||
|
>
|
||||||
|
{showLegacyAdapters ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t('bots.legacyAdapters')}
|
||||||
|
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
||||||
|
{legacyAdapters.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showLegacyAdapters && (
|
||||||
|
<>
|
||||||
|
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t('bots.legacyAdaptersHint')}
|
||||||
|
</p>
|
||||||
|
<SelectGroup>
|
||||||
|
{legacyAdapters.map((item) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`legacy:${item.value}`}
|
||||||
|
value={item.value}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
|
||||||
|
<img
|
||||||
|
src={httpClient.getAdapterIconURL(
|
||||||
|
item.value,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
className="h-5 w-5 shrink-0 rounded grayscale"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
{t('bots.legacyAdapterBadge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{currentAdapter &&
|
||||||
|
(() => {
|
||||||
|
const docUrl = getAdapterDocUrl(
|
||||||
|
adapterHelpLinks[currentAdapter],
|
||||||
|
i18n.language,
|
||||||
|
);
|
||||||
|
return docUrl ? (
|
||||||
|
<a
|
||||||
|
href={docUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('bots.viewAdapterDocs')}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
{currentAdapter &&
|
||||||
|
adapterDescriptionList[currentAdapter] && (
|
||||||
|
<FormDescription>
|
||||||
|
{adapterDescriptionList[currentAdapter]}
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||||
|
<DynamicFormComponent
|
||||||
|
itemConfigList={dynamicFormConfigList}
|
||||||
|
initialValues={currentAdapterConfig}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
form.setValue('adapter_config', values, {
|
||||||
|
shouldDirty: !isInitializing.current,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
systemContext={{
|
||||||
|
webhook_url: webhookUrl,
|
||||||
|
extra_webhook_url: extraWebhookUrl,
|
||||||
|
bot_uuid: initBotId || '',
|
||||||
|
adapter_config: form.getValues('adapter_config') || {},
|
||||||
|
outbound_ips: systemInfo.outbound_ips,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Card 3: Event Routing */}
|
||||||
|
{currentAdapter && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('bots.eventRoutingDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EventBindingsEditor
|
||||||
|
form={form}
|
||||||
|
botId={initBotId}
|
||||||
|
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||||
|
agentOptions={agentNameList}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
clearUserInfo,
|
clearUserInfo,
|
||||||
getCloudServiceClientSync,
|
getCloudServiceClientSync,
|
||||||
useCurrentWorkspace,
|
useCurrentWorkspace,
|
||||||
|
useWorkspaceBootstrap,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
HardDrive,
|
|
||||||
Server,
|
Server,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@@ -113,6 +113,11 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||||
import { FeedbackPopoverContent } from './FeedbackPopover';
|
import { FeedbackPopoverContent } from './FeedbackPopover';
|
||||||
|
import {
|
||||||
|
type WorkspaceQuotaItem,
|
||||||
|
useWorkspaceQuotaStatus,
|
||||||
|
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||||
|
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||||
|
|
||||||
// Compare two version strings, returns true if v1 > v2
|
// Compare two version strings, returns true if v1 > v2
|
||||||
function compareVersions(v1: string, v2: string): boolean {
|
function compareVersions(v1: string, v2: string): boolean {
|
||||||
@@ -283,6 +288,14 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
|
||||||
|
count: 0,
|
||||||
|
max: -1,
|
||||||
|
reached: false,
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
async function waitForMCPRefreshTask(taskId: number) {
|
async function waitForMCPRefreshTask(taskId: number) {
|
||||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||||
|
|
||||||
@@ -390,6 +403,7 @@ function NavItems({
|
|||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const sidebarData = useSidebarData();
|
const sidebarData = useSidebarData();
|
||||||
|
const quotaStatus = useWorkspaceQuotaStatus();
|
||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
@@ -533,7 +547,7 @@ function NavItems({
|
|||||||
if (config.id === 'add-extension' && !canManageResources) {
|
if (config.id === 'add-extension' && !canManageResources) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={config.id}>
|
<SidebarMenuItem key={config.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
@@ -580,6 +594,18 @@ function NavItems({
|
|||||||
const isBot = categoryId === 'bots';
|
const isBot = categoryId === 'bots';
|
||||||
const isMCP = categoryId === 'mcp';
|
const isMCP = categoryId === 'mcp';
|
||||||
const isAgents = categoryId === 'pipelines';
|
const isAgents = categoryId === 'pipelines';
|
||||||
|
const quota =
|
||||||
|
categoryId === 'bots'
|
||||||
|
? quotaStatus.bots
|
||||||
|
: categoryId === 'pipelines'
|
||||||
|
? quotaStatus.pipelines
|
||||||
|
: categoryId === 'knowledge'
|
||||||
|
? quotaStatus.knowledgeBases
|
||||||
|
: categoryId === 'plugins' ||
|
||||||
|
categoryId === 'mcp' ||
|
||||||
|
categoryId === 'skills'
|
||||||
|
? quotaStatus.extensions
|
||||||
|
: UNLIMITED_QUOTA;
|
||||||
|
|
||||||
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||||
if (item.extensionType === 'mcp') {
|
if (item.extensionType === 'mcp') {
|
||||||
@@ -970,128 +996,144 @@ function NavItems({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1 px-2">
|
<div className="flex items-center justify-between mb-1 px-2">
|
||||||
<span className="text-sm font-medium">{config.name}</span>
|
<span className="text-sm font-medium">{config.name}</span>
|
||||||
{canCreate &&
|
{canCreate && (
|
||||||
(isPlugin ? (
|
<WorkspaceQuotaTooltip
|
||||||
<DropdownMenu>
|
quota={quota}
|
||||||
<DropdownMenuTrigger asChild>
|
resource={config.name}
|
||||||
<button
|
side="right"
|
||||||
type="button"
|
>
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
{isPlugin ? (
|
||||||
>
|
<DropdownMenu>
|
||||||
<Plus className="size-3.5" />
|
<DropdownMenuTrigger asChild>
|
||||||
</button>
|
<button
|
||||||
</DropdownMenuTrigger>
|
type="button"
|
||||||
<DropdownMenuContent align="end">
|
disabled={quota.disabled}
|
||||||
{systemInfo.enable_marketplace && (
|
aria-disabled={quota.disabled}
|
||||||
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{systemInfo.enable_marketplace && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate('/home/add-extension');
|
||||||
|
setPopoverOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[config.id]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Store className="size-4" />
|
||||||
|
{t('plugins.goToMarketplace')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension');
|
navigate('/home/add-extension?manual=1');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Upload className="size-4" />
|
||||||
{t('plugins.goToMarketplace')}
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
navigate('/home/add-extension?manual=1');
|
||||||
navigate('/home/add-extension?manual=1');
|
setPopoverOpen((prev) => ({
|
||||||
setPopoverOpen((prev) => ({
|
...prev,
|
||||||
...prev,
|
[config.id]: false,
|
||||||
[config.id]: false,
|
}));
|
||||||
}));
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Github className="size-4" />
|
||||||
<Upload className="size-4" />
|
{t('plugins.installFromGithub')}
|
||||||
{t('plugins.uploadLocal')}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
</DropdownMenu>
|
||||||
onClick={(e) => {
|
) : isSkill ? (
|
||||||
e.stopPropagation();
|
<DropdownMenu>
|
||||||
navigate('/home/add-extension?manual=1');
|
<DropdownMenuTrigger asChild>
|
||||||
setPopoverOpen((prev) => ({
|
<button
|
||||||
...prev,
|
type="button"
|
||||||
[config.id]: false,
|
disabled={quota.disabled}
|
||||||
}));
|
aria-disabled={quota.disabled}
|
||||||
}}
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
>
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Plus className="size-3.5" />
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenu>
|
<DropdownMenuContent align="end">
|
||||||
) : isSkill ? (
|
<DropdownMenuItem
|
||||||
<DropdownMenu>
|
onClick={(e) => {
|
||||||
<DropdownMenuTrigger asChild>
|
e.stopPropagation();
|
||||||
<button
|
navigate('/home/skills?action=create');
|
||||||
type="button"
|
setPopoverOpen((prev) => ({
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
...prev,
|
||||||
>
|
[config.id]: false,
|
||||||
<Plus className="size-3.5" />
|
}));
|
||||||
</button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
>
|
||||||
<DropdownMenuContent align="end">
|
<FilePlus2 className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.createManually')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/skills?action=create');
|
onClick={(e) => {
|
||||||
setPopoverOpen((prev) => ({
|
e.stopPropagation();
|
||||||
...prev,
|
navigate('/home/add-extension?manual=1');
|
||||||
[config.id]: false,
|
setPopoverOpen((prev) => ({
|
||||||
}));
|
...prev,
|
||||||
}}
|
[config.id]: false,
|
||||||
>
|
}));
|
||||||
<FilePlus2 className="size-4" />
|
}}
|
||||||
{t('skills.createManually')}
|
>
|
||||||
</DropdownMenuItem>
|
<Upload className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.uploadZip')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/add-extension?manual=1');
|
onClick={(e) => {
|
||||||
setPopoverOpen((prev) => ({
|
e.stopPropagation();
|
||||||
...prev,
|
navigate('/home/add-extension?manual=1');
|
||||||
[config.id]: false,
|
setPopoverOpen((prev) => ({
|
||||||
}));
|
...prev,
|
||||||
}}
|
[config.id]: false,
|
||||||
>
|
}));
|
||||||
<Upload className="size-4" />
|
}}
|
||||||
{t('skills.uploadZip')}
|
>
|
||||||
</DropdownMenuItem>
|
<Github className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.importFromGithub')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
</DropdownMenuContent>
|
||||||
navigate('/home/add-extension?manual=1');
|
</DropdownMenu>
|
||||||
setPopoverOpen((prev) => ({
|
) : (
|
||||||
...prev,
|
<button
|
||||||
[config.id]: false,
|
type="button"
|
||||||
}));
|
disabled={quota.disabled}
|
||||||
}}
|
aria-disabled={quota.disabled}
|
||||||
>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
<Github className="size-4" />
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||||
{t('skills.importFromGithub')}
|
onClick={() => {
|
||||||
</DropdownMenuItem>
|
navigate(`${routePrefix}?id=new`);
|
||||||
</DropdownMenuContent>
|
setPopoverOpen((prev) => ({
|
||||||
</DropdownMenu>
|
...prev,
|
||||||
) : (
|
[config.id]: false,
|
||||||
<button
|
}));
|
||||||
type="button"
|
}}
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
>
|
||||||
onClick={() => {
|
<Plus className="size-3.5" />
|
||||||
navigate(`${routePrefix}?id=new`);
|
</button>
|
||||||
setPopoverOpen((prev) => ({
|
)}
|
||||||
...prev,
|
</WorkspaceQuotaTooltip>
|
||||||
[config.id]: false,
|
)}
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||||
{renderEntityList(true)}
|
{renderEntityList(true)}
|
||||||
@@ -1206,103 +1248,119 @@ function NavItems({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||||
{canCreate &&
|
{canCreate && (
|
||||||
(isPlugin ? (
|
<WorkspaceQuotaTooltip
|
||||||
<DropdownMenu>
|
quota={quota}
|
||||||
<DropdownMenuTrigger asChild>
|
resource={config.name}
|
||||||
<button
|
side="right"
|
||||||
type="button"
|
>
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
{isPlugin ? (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DropdownMenu>
|
||||||
>
|
<DropdownMenuTrigger asChild>
|
||||||
<Plus className="size-3.5" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</DropdownMenuTrigger>
|
disabled={quota.disabled}
|
||||||
<DropdownMenuContent align="end">
|
aria-disabled={quota.disabled}
|
||||||
{systemInfo.enable_marketplace && (
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{systemInfo.enable_marketplace && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate('/home/add-extension');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Store className="size-4" />
|
||||||
|
{t('plugins.goToMarketplace')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension');
|
navigate('/home/add-extension?manual=1');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Upload className="size-4" />
|
||||||
{t('plugins.goToMarketplace')}
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
navigate('/home/add-extension?manual=1');
|
||||||
navigate('/home/add-extension?manual=1');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Github className="size-4" />
|
||||||
<Upload className="size-4" />
|
{t('plugins.installFromGithub')}
|
||||||
{t('plugins.uploadLocal')}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
</DropdownMenu>
|
||||||
onClick={(e) => {
|
) : isSkill ? (
|
||||||
e.stopPropagation();
|
<DropdownMenu>
|
||||||
navigate('/home/add-extension?manual=1');
|
<DropdownMenuTrigger asChild>
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Github className="size-4" />
|
disabled={quota.disabled}
|
||||||
{t('plugins.installFromGithub')}
|
aria-disabled={quota.disabled}
|
||||||
</DropdownMenuItem>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
</DropdownMenuContent>
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
</DropdownMenu>
|
onClick={(e) => e.stopPropagation()}
|
||||||
) : isSkill ? (
|
>
|
||||||
<DropdownMenu>
|
<Plus className="size-3.5" />
|
||||||
<DropdownMenuTrigger asChild>
|
</button>
|
||||||
<button
|
</DropdownMenuTrigger>
|
||||||
type="button"
|
<DropdownMenuContent align="end">
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
<DropdownMenuItem
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<Plus className="size-3.5" />
|
navigate('/home/skills?action=create');
|
||||||
</button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
>
|
||||||
<DropdownMenuContent align="end">
|
<FilePlus2 className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.createManually')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/skills?action=create');
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
navigate('/home/add-extension?manual=1');
|
||||||
<FilePlus2 className="size-4" />
|
}}
|
||||||
{t('skills.createManually')}
|
>
|
||||||
</DropdownMenuItem>
|
<Upload className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.uploadZip')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/add-extension?manual=1');
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
navigate('/home/add-extension?manual=1');
|
||||||
<Upload className="size-4" />
|
}}
|
||||||
{t('skills.uploadZip')}
|
>
|
||||||
</DropdownMenuItem>
|
<Github className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.importFromGithub')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
</DropdownMenuContent>
|
||||||
navigate('/home/add-extension?manual=1');
|
</DropdownMenu>
|
||||||
}}
|
) : (
|
||||||
>
|
<button
|
||||||
<Github className="size-4" />
|
type="button"
|
||||||
{t('skills.importFromGithub')}
|
disabled={quota.disabled}
|
||||||
</DropdownMenuItem>
|
aria-disabled={quota.disabled}
|
||||||
</DropdownMenuContent>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
</DropdownMenu>
|
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||||
) : (
|
onClick={(e) => {
|
||||||
<button
|
e.stopPropagation();
|
||||||
type="button"
|
navigate(`${routePrefix}?id=new`);
|
||||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
}}
|
||||||
onClick={(e) => {
|
>
|
||||||
e.stopPropagation();
|
<Plus className="size-3.5" />
|
||||||
navigate(`${routePrefix}?id=new`);
|
</button>
|
||||||
}}
|
)}
|
||||||
>
|
</WorkspaceQuotaTooltip>
|
||||||
<Plus className="size-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1747,6 +1805,13 @@ export default function HomeSidebar({
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const workspaces = useWorkspaceBootstrap();
|
||||||
|
const showWorkspaceSwitcher =
|
||||||
|
workspaces.length > 1 ||
|
||||||
|
currentWorkspace?.workspace.source === 'cloud_projection';
|
||||||
|
const canViewStorageAnalysis =
|
||||||
|
currentWorkspace?.workspace.source !== 'cloud_projection' &&
|
||||||
|
currentWorkspace?.permissions.includes('audit.view');
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] =
|
const [settingsSection, setSettingsSection] =
|
||||||
useState<SettingsSection>('models');
|
useState<SettingsSection>('models');
|
||||||
@@ -2025,9 +2090,11 @@ export default function HomeSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
{showWorkspaceSwitcher && (
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
</div>
|
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation items grouped by section */}
|
{/* Navigation items grouped by section */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -2208,15 +2275,16 @@ export default function HomeSidebar({
|
|||||||
<UsersRound />
|
<UsersRound />
|
||||||
{t('workspace.settings')}
|
{t('workspace.settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{canViewStorageAnalysis && (
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setUserMenuOpen(false);
|
onClick={() => {
|
||||||
openSettings('storageAnalysis');
|
setUserMenuOpen(false);
|
||||||
}}
|
openSettings('storageAnalysis');
|
||||||
>
|
}}
|
||||||
<HardDrive />
|
>
|
||||||
{t('storageAnalysis.title')}
|
{t('storageAnalysis.title')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
@@ -51,9 +52,11 @@ export interface SidebarDataContextValue {
|
|||||||
pipelines: SidebarEntityItem[];
|
pipelines: SidebarEntityItem[];
|
||||||
knowledgeBases: SidebarEntityItem[];
|
knowledgeBases: SidebarEntityItem[];
|
||||||
plugins: SidebarEntityItem[];
|
plugins: SidebarEntityItem[];
|
||||||
|
pluginCount: number;
|
||||||
mcpServers: SidebarEntityItem[];
|
mcpServers: SidebarEntityItem[];
|
||||||
skills: SidebarEntityItem[];
|
skills: SidebarEntityItem[];
|
||||||
pluginPages: PluginPageItem[];
|
pluginPages: PluginPageItem[];
|
||||||
|
quotaDataLoaded: boolean;
|
||||||
refreshBots: () => Promise<void>;
|
refreshBots: () => Promise<void>;
|
||||||
refreshPipelines: () => Promise<void>;
|
refreshPipelines: () => Promise<void>;
|
||||||
refreshKnowledgeBases: () => Promise<void>;
|
refreshKnowledgeBases: () => Promise<void>;
|
||||||
@@ -83,9 +86,36 @@ export function SidebarDataProvider({
|
|||||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||||
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||||
|
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
|
||||||
|
const refreshRequestIds = useRef({
|
||||||
|
bots: 0,
|
||||||
|
pipelines: 0,
|
||||||
|
knowledgeBases: 0,
|
||||||
|
plugins: 0,
|
||||||
|
mcpServers: 0,
|
||||||
|
skills: 0,
|
||||||
|
});
|
||||||
|
const quotaResourceLoaded = useRef({
|
||||||
|
bots: false,
|
||||||
|
pipelines: false,
|
||||||
|
knowledgeBases: false,
|
||||||
|
plugins: false,
|
||||||
|
mcpServers: false,
|
||||||
|
skills: false,
|
||||||
|
});
|
||||||
|
const setQuotaResourceLoaded = useCallback(
|
||||||
|
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
|
||||||
|
quotaResourceLoaded.current[resource] = loaded;
|
||||||
|
setQuotaDataLoaded(
|
||||||
|
Object.values(quotaResourceLoaded.current).every(Boolean),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||||
useState<boolean>(() => {
|
useState<boolean>(() => {
|
||||||
@@ -117,11 +147,14 @@ export function SidebarDataProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshBots = useCallback(async () => {
|
const refreshBots = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.bots;
|
||||||
try {
|
try {
|
||||||
const [resp, adaptersResp] = await Promise.all([
|
const [resp, adaptersResp] = await Promise.all([
|
||||||
httpClient.getBots(),
|
httpClient.getBots(),
|
||||||
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
||||||
]);
|
]);
|
||||||
|
if (requestId !== refreshRequestIds.current.bots) return;
|
||||||
|
setQuotaResourceLoaded('bots', true);
|
||||||
const legacyAdapterNames = new Set(
|
const legacyAdapterNames = new Set(
|
||||||
adaptersResp.adapters
|
adaptersResp.adapters
|
||||||
.filter((adapter) => adapter.spec.legacy)
|
.filter((adapter) => adapter.spec.legacy)
|
||||||
@@ -139,13 +172,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.bots) return;
|
||||||
|
setQuotaResourceLoaded('bots', false);
|
||||||
console.error('Failed to fetch bots for sidebar:', error);
|
console.error('Failed to fetch bots for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshPipelines = useCallback(async () => {
|
const refreshPipelines = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.pipelines;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getAgents();
|
const resp = await httpClient.getAgents();
|
||||||
|
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||||
|
setQuotaResourceLoaded('pipelines', true);
|
||||||
setPipelines(
|
setPipelines(
|
||||||
resp.agents.map((p) => ({
|
resp.agents.map((p) => ({
|
||||||
id: p.uuid || '',
|
id: p.uuid || '',
|
||||||
@@ -157,13 +195,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||||
|
setQuotaResourceLoaded('pipelines', false);
|
||||||
console.error('Failed to fetch agents for sidebar:', error);
|
console.error('Failed to fetch agents for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshKnowledgeBases = useCallback(async () => {
|
const refreshKnowledgeBases = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getKnowledgeBases();
|
const resp = await httpClient.getKnowledgeBases();
|
||||||
|
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||||
|
setQuotaResourceLoaded('knowledgeBases', true);
|
||||||
setKnowledgeBases(
|
setKnowledgeBases(
|
||||||
resp.bases.map((kb) => ({
|
resp.bases.map((kb) => ({
|
||||||
id: kb.uuid || '',
|
id: kb.uuid || '',
|
||||||
@@ -174,11 +217,14 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||||
|
setQuotaResourceLoaded('knowledgeBases', false);
|
||||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshPlugins = useCallback(async () => {
|
const refreshPlugins = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.plugins;
|
||||||
try {
|
try {
|
||||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||||
httpClient.getPlugins(),
|
httpClient.getPlugins(),
|
||||||
@@ -186,6 +232,9 @@ export function SidebarDataProvider({
|
|||||||
.getMarketplacePlugins(1, 100)
|
.getMarketplacePlugins(1, 100)
|
||||||
.catch(() => ({ plugins: [] })),
|
.catch(() => ({ plugins: [] })),
|
||||||
]);
|
]);
|
||||||
|
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||||
|
setQuotaResourceLoaded('plugins', true);
|
||||||
|
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
||||||
|
|
||||||
// Build marketplace version lookup: "author/name" -> latest_version
|
// Build marketplace version lookup: "author/name" -> latest_version
|
||||||
const marketplaceVersions = new Map<string, string>();
|
const marketplaceVersions = new Map<string, string>();
|
||||||
@@ -272,13 +321,18 @@ export function SidebarDataProvider({
|
|||||||
}
|
}
|
||||||
setPluginPages(pages);
|
setPluginPages(pages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||||
|
setQuotaResourceLoaded('plugins', false);
|
||||||
console.error('Failed to fetch plugins for sidebar:', error);
|
console.error('Failed to fetch plugins for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshMCPServers = useCallback(async () => {
|
const refreshMCPServers = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.mcpServers;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getMCPServers();
|
const resp = await httpClient.getMCPServers();
|
||||||
|
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||||
|
setQuotaResourceLoaded('mcpServers', true);
|
||||||
setMCPServers(
|
setMCPServers(
|
||||||
resp.servers.map((server) => ({
|
resp.servers.map((server) => ({
|
||||||
id: server.name, // Keep __ for API calls
|
id: server.name, // Keep __ for API calls
|
||||||
@@ -288,13 +342,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||||
|
setQuotaResourceLoaded('mcpServers', false);
|
||||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshSkills = useCallback(async () => {
|
const refreshSkills = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.skills;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkills();
|
const resp = await httpClient.getSkills();
|
||||||
|
if (requestId !== refreshRequestIds.current.skills) return;
|
||||||
|
setQuotaResourceLoaded('skills', true);
|
||||||
setSkills(
|
setSkills(
|
||||||
resp.skills.map((skill) => ({
|
resp.skills.map((skill) => ({
|
||||||
id: skill.name,
|
id: skill.name,
|
||||||
@@ -304,11 +363,22 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.skills) return;
|
||||||
|
setQuotaResourceLoaded('skills', false);
|
||||||
console.error('Failed to fetch skills for sidebar:', error);
|
console.error('Failed to fetch skills for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
|
quotaResourceLoaded.current = {
|
||||||
|
bots: false,
|
||||||
|
pipelines: false,
|
||||||
|
knowledgeBases: false,
|
||||||
|
plugins: false,
|
||||||
|
mcpServers: false,
|
||||||
|
skills: false,
|
||||||
|
};
|
||||||
|
setQuotaDataLoaded(false);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshBots(),
|
refreshBots(),
|
||||||
refreshPipelines(),
|
refreshPipelines(),
|
||||||
@@ -338,9 +408,11 @@ export function SidebarDataProvider({
|
|||||||
pipelines,
|
pipelines,
|
||||||
knowledgeBases,
|
knowledgeBases,
|
||||||
plugins,
|
plugins,
|
||||||
|
pluginCount,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
skills,
|
skills,
|
||||||
pluginPages,
|
pluginPages,
|
||||||
|
quotaDataLoaded,
|
||||||
refreshBots,
|
refreshBots,
|
||||||
refreshPipelines,
|
refreshPipelines,
|
||||||
refreshKnowledgeBases,
|
refreshKnowledgeBases,
|
||||||
|
|||||||
@@ -218,20 +218,22 @@ export default function ProviderCard({
|
|||||||
<span>
|
<span>
|
||||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
{isWorkspaceOwner && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-5 w-5"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="h-5 w-5"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
window.open(
|
e.stopPropagation();
|
||||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
window.open(
|
||||||
'_blank',
|
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||||
);
|
'_blank',
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
<Plus className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||||
|
|||||||
@@ -133,12 +133,14 @@ export default function SettingsDialog({
|
|||||||
const permissions = currentWorkspace?.permissions ?? [];
|
const permissions = currentWorkspace?.permissions ?? [];
|
||||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||||
const canViewAudit = permissions.includes('audit.view');
|
const canViewAudit = permissions.includes('audit.view');
|
||||||
|
const canViewStorageAnalysis =
|
||||||
|
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
|
||||||
const navItems = allNavItems.filter((item) => {
|
const navItems = allNavItems.filter((item) => {
|
||||||
if (item.id === 'apiIntegration') {
|
if (item.id === 'apiIntegration') {
|
||||||
return canManageApiKeys;
|
return canManageApiKeys;
|
||||||
}
|
}
|
||||||
if (item.id === 'storageAnalysis') {
|
if (item.id === 'storageAnalysis') {
|
||||||
return canViewAudit;
|
return canViewStorageAnalysis;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -146,11 +148,17 @@ export default function SettingsDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forbiddenSection =
|
const forbiddenSection =
|
||||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||||
(section === 'storageAnalysis' && !canViewAudit);
|
(section === 'storageAnalysis' && !canViewStorageAnalysis);
|
||||||
if (open && forbiddenSection) {
|
if (open && forbiddenSection) {
|
||||||
onSectionChange('workspace');
|
onSectionChange('workspace');
|
||||||
}
|
}
|
||||||
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
|
}, [
|
||||||
|
canManageApiKeys,
|
||||||
|
canViewStorageAnalysis,
|
||||||
|
open,
|
||||||
|
section,
|
||||||
|
onSectionChange,
|
||||||
|
]);
|
||||||
|
|
||||||
const activeItem = navItems.find((item) => item.id === section);
|
const activeItem = navItems.find((item) => item.id === section);
|
||||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||||
@@ -256,7 +264,7 @@ export default function SettingsDialog({
|
|||||||
active={open && section === 'apiIntegration'}
|
active={open && section === 'apiIntegration'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'storageAnalysis' && (
|
{section === 'storageAnalysis' && canViewStorageAnalysis && (
|
||||||
<StorageAnalysisPanel
|
<StorageAnalysisPanel
|
||||||
active={open && section === 'storageAnalysis'}
|
active={open && section === 'storageAnalysis'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
||||||
|
|
||||||
|
export function WorkspaceQuotaTooltip({
|
||||||
|
quota,
|
||||||
|
resource,
|
||||||
|
children,
|
||||||
|
side = 'top',
|
||||||
|
}: {
|
||||||
|
quota: WorkspaceQuotaItem;
|
||||||
|
resource: string;
|
||||||
|
children: ReactNode;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!quota.disabled) return children;
|
||||||
|
const message = quota.loading
|
||||||
|
? t('limitation.quotaLoadingTooltip')
|
||||||
|
: t('limitation.createDisabledTooltip', {
|
||||||
|
resource,
|
||||||
|
max: quota.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
tabIndex={0}
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-label={message}
|
||||||
|
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={side} className="max-w-72 text-left">
|
||||||
|
{message}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
|
||||||
|
export interface WorkspaceQuotaItem {
|
||||||
|
count: number;
|
||||||
|
max: number;
|
||||||
|
reached: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceQuotaStatus {
|
||||||
|
bots: WorkspaceQuotaItem;
|
||||||
|
pipelines: WorkspaceQuotaItem;
|
||||||
|
knowledgeBases: WorkspaceQuotaItem;
|
||||||
|
extensions: WorkspaceQuotaItem;
|
||||||
|
botsReached: boolean;
|
||||||
|
pipelinesReached: boolean;
|
||||||
|
knowledgeBasesReached: boolean;
|
||||||
|
extensionsReached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaItem(
|
||||||
|
count: number,
|
||||||
|
max: number | undefined,
|
||||||
|
loaded: boolean,
|
||||||
|
): WorkspaceQuotaItem {
|
||||||
|
const normalizedMax = typeof max === 'number' ? max : -1;
|
||||||
|
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
max: normalizedMax,
|
||||||
|
reached,
|
||||||
|
loading: !loaded,
|
||||||
|
disabled: !loaded || reached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
||||||
|
const {
|
||||||
|
bots,
|
||||||
|
pipelines,
|
||||||
|
knowledgeBases,
|
||||||
|
pluginCount,
|
||||||
|
mcpServers,
|
||||||
|
skills,
|
||||||
|
quotaDataLoaded,
|
||||||
|
} = useSidebarData();
|
||||||
|
const limitation = systemInfo.limitation;
|
||||||
|
|
||||||
|
const botQuota = quotaItem(
|
||||||
|
bots.length,
|
||||||
|
limitation?.max_bots,
|
||||||
|
quotaDataLoaded,
|
||||||
|
);
|
||||||
|
const pipelineQuota = quotaItem(
|
||||||
|
pipelines.length,
|
||||||
|
limitation?.max_pipelines,
|
||||||
|
quotaDataLoaded,
|
||||||
|
);
|
||||||
|
const knowledgeBaseQuota = quotaItem(
|
||||||
|
knowledgeBases.length,
|
||||||
|
limitation?.max_knowledge_bases,
|
||||||
|
quotaDataLoaded,
|
||||||
|
);
|
||||||
|
const extensionQuota = quotaItem(
|
||||||
|
pluginCount + mcpServers.length + skills.length,
|
||||||
|
limitation?.max_extensions,
|
||||||
|
quotaDataLoaded,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bots: botQuota,
|
||||||
|
pipelines: pipelineQuota,
|
||||||
|
knowledgeBases: knowledgeBaseQuota,
|
||||||
|
extensions: extensionQuota,
|
||||||
|
botsReached: botQuota.disabled,
|
||||||
|
pipelinesReached: pipelineQuota.disabled,
|
||||||
|
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
||||||
|
extensionsReached: extensionQuota.disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -79,7 +79,6 @@ export default function WorkspaceSettingsPanel({
|
|||||||
const canInvite = permissions.has('member.invite');
|
const canInvite = permissions.has('member.invite');
|
||||||
const canUpdateMembers = permissions.has('member.update_role');
|
const canUpdateMembers = permissions.has('member.update_role');
|
||||||
const canRemoveMembers = permissions.has('member.remove');
|
const canRemoveMembers = permissions.has('member.remove');
|
||||||
const canTransferOwner = permissions.has('owner.transfer');
|
|
||||||
const cloudPortalURL = workspaceInfo
|
const cloudPortalURL = workspaceInfo
|
||||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
||||||
: '';
|
: '';
|
||||||
@@ -342,11 +341,6 @@ export default function WorkspaceSettingsPanel({
|
|||||||
{t(`workspace.roles.${role}`)}
|
{t(`workspace.roles.${role}`)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{canTransferOwner && (
|
|
||||||
<SelectItem value="owner">
|
|
||||||
{t('workspace.transferOwnership')}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTheme } from '@/components/providers/theme-provider';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
|
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||||
@@ -109,15 +110,15 @@ function PluginPageIframe({
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}) {
|
}) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
|
||||||
const assetUrl = useMemo(() => {
|
author,
|
||||||
const url = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
pluginName,
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
pagePath,
|
||||||
return `${url}${separator}_lb_page_v=${Date.now()}`;
|
);
|
||||||
}, [author, pluginName, pagePath]);
|
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
|
||||||
|
|
||||||
// Send context (theme + language) to iframe
|
// Send context (theme + language) to iframe
|
||||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||||
@@ -203,23 +204,29 @@ function PluginPageIframe({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
{loading && (
|
{assetError ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
{t('plugins.loadFailed')}
|
||||||
|
</div>
|
||||||
|
) : loading || !assetUrl ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!assetError && assetUrl && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={assetUrl}
|
||||||
|
className="flex-1 w-full border-0 rounded-md"
|
||||||
|
style={{ display: loading ? 'none' : 'block' }}
|
||||||
|
onLoad={() => {
|
||||||
|
setLoadedAssetUrl(assetUrl);
|
||||||
|
sendContext();
|
||||||
|
}}
|
||||||
|
sandbox="allow-scripts allow-forms"
|
||||||
|
title={`${author}/${pluginName} - ${pagePath}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
src={assetUrl}
|
|
||||||
className="flex-1 w-full border-0 rounded-md"
|
|
||||||
style={{ display: loading ? 'none' : 'block' }}
|
|
||||||
onLoad={() => {
|
|
||||||
setLoading(false);
|
|
||||||
sendContext();
|
|
||||||
}}
|
|
||||||
sandbox="allow-scripts allow-forms"
|
|
||||||
title={`${author}/${pluginName} - ${pagePath}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||||
|
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||||
|
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||||
|
|
||||||
type PluginLocalPreview = Awaited<
|
type PluginLocalPreview = Awaited<
|
||||||
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
||||||
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
|
|||||||
file: File;
|
file: File;
|
||||||
onInstallStarted?: () => void;
|
onInstallStarted?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
quota?: WorkspaceQuotaItem;
|
||||||
|
quotaResource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
|
|||||||
file,
|
file,
|
||||||
onInstallStarted,
|
onInstallStarted,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
quota,
|
||||||
|
quotaResource = '',
|
||||||
}: PluginLocalPreviewPanelProps) {
|
}: PluginLocalPreviewPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
||||||
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
|
|||||||
}, [loadPreview]);
|
}, [loadPreview]);
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
|
if (quota?.disabled) return;
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{quota ? (
|
||||||
type="button"
|
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||||
onClick={handleInstall}
|
<Button
|
||||||
disabled={!preview || previewing || installing}
|
type="button"
|
||||||
>
|
onClick={handleInstall}
|
||||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
disabled={quota.disabled || !preview || previewing || installing}
|
||||||
</Button>
|
>
|
||||||
|
{installing
|
||||||
|
? t('plugins.installing')
|
||||||
|
: t('plugins.confirmInstall')}
|
||||||
|
</Button>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={!preview || previewing || installing}
|
||||||
|
>
|
||||||
|
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ function loadMarketFilters(): MarketFilters {
|
|||||||
function MarketPageContent({
|
function MarketPageContent({
|
||||||
installPlugin,
|
installPlugin,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -882,6 +886,8 @@ function MarketPageContent({
|
|||||||
lists={recommendationLists}
|
lists={recommendationLists}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -911,6 +917,8 @@ function MarketPageContent({
|
|||||||
cardVO={plugin}
|
cardVO={plugin}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -950,9 +958,13 @@ function MarketPageContent({
|
|||||||
export default function MarketPage({
|
export default function MarketPage({
|
||||||
installPlugin,
|
installPlugin,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
@@ -967,6 +979,8 @@ export default function MarketPage({
|
|||||||
<MarketPageContent
|
<MarketPageContent
|
||||||
installPlugin={installPlugin}
|
installPlugin={installPlugin}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,11 +54,15 @@ function RecommendationListRow({
|
|||||||
list,
|
list,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
isLast,
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
list: RecommendationList;
|
list: RecommendationList;
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -263,6 +267,8 @@ function RecommendationListRow({
|
|||||||
cardVO={pluginToVO(plugin, t)}
|
cardVO={pluginToVO(plugin, t)}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,10 +283,14 @@ export function RecommendationLists({
|
|||||||
lists,
|
lists,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
lists: RecommendationList[];
|
lists: RecommendationList[];
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!lists || lists.length === 0) return null;
|
if (!lists || lists.length === 0) return null;
|
||||||
|
|
||||||
@@ -292,6 +302,8 @@ export function RecommendationLists({
|
|||||||
list={list}
|
list={list}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
isLast={index === lists.length - 1}
|
isLast={index === lists.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+26
-3
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
|
|||||||
cardVO,
|
cardVO,
|
||||||
onInstall,
|
onInstall,
|
||||||
tagNames = {},
|
tagNames = {},
|
||||||
|
installDisabled = false,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginMarketCardVO;
|
cardVO: PluginMarketCardVO;
|
||||||
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
||||||
tagNames?: Record<string, string>;
|
tagNames?: Record<string, string>;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
|
|||||||
|
|
||||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||||
const handleInstallClick = () => {
|
const handleInstallClick = () => {
|
||||||
|
if (installDisabled) return;
|
||||||
onInstall?.(cardVO);
|
onInstall?.(cardVO);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const cardContent = (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role={installDisabled ? 'group' : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
aria-disabled={installDisabled}
|
||||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
aria-label={t('market.installCard', { name: cardVO.label })}
|
||||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
||||||
|
installDisabled
|
||||||
|
? 'cursor-not-allowed opacity-60'
|
||||||
|
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
|
||||||
|
}`}
|
||||||
onClick={handleInstallClick}
|
onClick={handleInstallClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!installDisabled || !installDisabledTooltip) return cardContent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-72 text-left">
|
||||||
|
{installDisabledTooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import type { Skill } from '@/app/infra/entities/api';
|
import type { Skill } from '@/app/infra/entities/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||||
|
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||||
|
|
||||||
interface PreviewSkill extends Skill {
|
interface PreviewSkill extends Skill {
|
||||||
source_path?: string;
|
source_path?: string;
|
||||||
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
|
|||||||
file: File;
|
file: File;
|
||||||
onImported: (skillNames: string[]) => void;
|
onImported: (skillNames: string[]) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
quota?: WorkspaceQuotaItem;
|
||||||
|
quotaResource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
|
|||||||
file,
|
file,
|
||||||
onImported,
|
onImported,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
quota,
|
||||||
|
quotaResource = '',
|
||||||
}: SkillZipPreviewPanelProps) {
|
}: SkillZipPreviewPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||||
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
|
if (quota?.disabled) return;
|
||||||
if (selectedPaths.length === 0) return;
|
if (selectedPaths.length === 0) return;
|
||||||
|
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{quota ? (
|
||||||
type="button"
|
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||||
onClick={handleInstall}
|
<Button
|
||||||
disabled={
|
type="button"
|
||||||
previewing ||
|
onClick={handleInstall}
|
||||||
installing ||
|
disabled={
|
||||||
previewSkills.length === 0 ||
|
quota.disabled ||
|
||||||
selectedPaths.length === 0
|
previewing ||
|
||||||
}
|
installing ||
|
||||||
>
|
previewSkills.length === 0 ||
|
||||||
{installing ? (
|
selectedPaths.length === 0
|
||||||
<>
|
}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
>
|
||||||
{t('skills.installing')}
|
{installing ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<Loader2 className="size-4 animate-spin" />
|
||||||
<>
|
{t('skills.installing')}
|
||||||
<PackageOpen className="size-4" />
|
</>
|
||||||
{t('skills.confirmInstall')}
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
<PackageOpen className="size-4" />
|
||||||
</Button>
|
{t('skills.confirmInstall')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={
|
||||||
|
previewing ||
|
||||||
|
installing ||
|
||||||
|
previewSkills.length === 0 ||
|
||||||
|
selectedPaths.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{t('skills.installing')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PackageOpen className="size-4" />
|
||||||
|
{t('skills.confirmInstall')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -437,6 +437,11 @@ export interface SystemLimitation {
|
|||||||
max_bots: number;
|
max_bots: number;
|
||||||
max_pipelines: number;
|
max_pipelines: number;
|
||||||
max_extensions: number;
|
max_extensions: number;
|
||||||
|
max_knowledge_bases?: number;
|
||||||
|
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||||
|
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||||
|
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
||||||
|
force_box_session_id_template?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WizardProgress {
|
export interface WizardProgress {
|
||||||
|
|||||||
@@ -1252,6 +1252,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
|
|
||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
|
authenticated_invitation_acceptance_enabled?: boolean;
|
||||||
password_login_enabled?: boolean;
|
password_login_enabled?: boolean;
|
||||||
space_login_enabled?: boolean;
|
space_login_enabled?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export default function AcceptInvitationPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [
|
||||||
|
authenticatedInvitationAcceptanceEnabled,
|
||||||
|
setAuthenticatedInvitationAcceptanceEnabled,
|
||||||
|
] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||||
@@ -113,6 +117,9 @@ export default function AcceptInvitationPage() {
|
|||||||
.getAccountInfo()
|
.getAccountInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||||
|
setAuthenticatedInvitationAcceptanceEnabled(
|
||||||
|
info.authenticated_invitation_acceptance_enabled === true,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setPasswordRegistrationEnabled(false));
|
.catch(() => setPasswordRegistrationEnabled(false));
|
||||||
if (!invitationToken) {
|
if (!invitationToken) {
|
||||||
@@ -304,7 +311,18 @@ export default function AcceptInvitationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoginToken ? (
|
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
onClick={() => void finishAcceptance()}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t('workspace.acceptInvitation')}
|
||||||
|
</Button>
|
||||||
|
) : hasLoginToken ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
{t('workspace.authenticatedInvitationNotice')}
|
{t('workspace.authenticatedInvitationNotice')}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,41 +1,63 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
|
||||||
|
type AuthenticatedResourceState = {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
error: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RESOURCE: AuthenticatedResourceState = {
|
||||||
|
key: '',
|
||||||
|
url: '',
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function useAuthenticatedPluginIcon(
|
export function useAuthenticatedPluginIcon(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
): { url: string; error: boolean } {
|
): { url: string; error: boolean } {
|
||||||
const [url, setURL] = useState('');
|
const [resource, setResource] =
|
||||||
const [error, setError] = useState(false);
|
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||||
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||||
|
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
let objectURL = '';
|
let objectURL = '';
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
httpClient
|
httpClient
|
||||||
.getAuthenticatedPluginIconURL(author, name)
|
.getAuthenticatedPluginIconURL(author, name)
|
||||||
.then((nextURL) => {
|
.then((nextURL) => {
|
||||||
objectURL = nextURL;
|
objectURL = nextURL;
|
||||||
if (active) setURL(nextURL);
|
if (active) {
|
||||||
else URL.revokeObjectURL(nextURL);
|
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(nextURL);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) setError(true);
|
if (active) {
|
||||||
|
setResource({ key: resourceKey, url: '', error: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||||
};
|
};
|
||||||
}, [author, enabled, name]);
|
}, [author, enabled, name, resourceKey]);
|
||||||
|
|
||||||
return { url, error };
|
return {
|
||||||
|
url: resource.key === resourceKey ? resource.url : '',
|
||||||
|
error: resource.key === resourceKey && resource.error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthenticatedPluginAsset(
|
export function useAuthenticatedPluginAsset(
|
||||||
@@ -43,29 +65,39 @@ export function useAuthenticatedPluginAsset(
|
|||||||
name: string,
|
name: string,
|
||||||
filepath: string,
|
filepath: string,
|
||||||
): { url: string; error: boolean } {
|
): { url: string; error: boolean } {
|
||||||
const [url, setURL] = useState('');
|
const [resource, setResource] =
|
||||||
const [error, setError] = useState(false);
|
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||||
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||||
|
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
let objectURL = '';
|
let objectURL = '';
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
httpClient
|
httpClient
|
||||||
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||||
.then((nextURL) => {
|
.then((nextURL) => {
|
||||||
objectURL = nextURL;
|
objectURL = nextURL;
|
||||||
if (active) setURL(nextURL);
|
if (active) {
|
||||||
else URL.revokeObjectURL(nextURL);
|
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(nextURL);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) setError(true);
|
if (active) {
|
||||||
|
setResource({ key: resourceKey, url: '', error: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||||
};
|
};
|
||||||
}, [author, name, filepath]);
|
}, [author, name, filepath, resourceKey]);
|
||||||
|
|
||||||
return { url, error };
|
return {
|
||||||
|
url: resource.key === resourceKey ? resource.url : '',
|
||||||
|
error: resource.key === resourceKey && resource.error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1928,6 +1928,12 @@ const enUS = {
|
|||||||
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'Workspace usage is still loading. Please wait before creating a resource.',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'Unable to verify the current workspace quota. Please try again.',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
title: 'Skills',
|
title: 'Skills',
|
||||||
|
|||||||
@@ -1693,6 +1693,12 @@ const esES = {
|
|||||||
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
|
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'Crea un Bot con pasos guiados',
|
sidebarDescription: 'Crea un Bot con pasos guiados',
|
||||||
|
|||||||
@@ -1943,6 +1943,12 @@ const jaJP = {
|
|||||||
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'現在のワークスペース上限を確認できません。もう一度お試しください。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'ガイド付きステップでボットを作成',
|
sidebarDescription: 'ガイド付きステップでボットを作成',
|
||||||
|
|||||||
@@ -1666,6 +1666,12 @@ const ruRU = {
|
|||||||
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'Создать бота с пошаговым руководством',
|
sidebarDescription: 'Создать бота с пошаговым руководством',
|
||||||
|
|||||||
@@ -1633,6 +1633,12 @@ const thTH = {
|
|||||||
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
||||||
|
|||||||
@@ -1659,6 +1659,12 @@ const viVN = {
|
|||||||
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
|
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
|
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
||||||
|
|||||||
@@ -1846,6 +1846,10 @@ const zhHans = {
|
|||||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||||
|
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
|
||||||
|
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
title: '技能',
|
title: '技能',
|
||||||
|
|||||||
@@ -1585,6 +1585,10 @@ const zhHant = {
|
|||||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||||
|
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
|
||||||
|
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: '透過引導步驟建立機器人',
|
sidebarDescription: '透過引導步驟建立機器人',
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
installLangBotApiMocks,
|
||||||
|
makeWorkspaceEntry,
|
||||||
|
} from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
function wrapped(data: unknown) {
|
||||||
|
return JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Cloud never exposes or requests storage analysis', async ({ page }) => {
|
||||||
|
const workspace = makeWorkspaceEntry(
|
||||||
|
'workspace-cloud',
|
||||||
|
'Cloud Workspace',
|
||||||
|
'cloud_projection',
|
||||||
|
);
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
workspaces: [workspace],
|
||||||
|
});
|
||||||
|
await page.route(
|
||||||
|
/\/api\/v1\/workspaces\/workspace-cloud\/(members|invitations)$/,
|
||||||
|
async (route) => {
|
||||||
|
const collection = route.request().url().endsWith('/members')
|
||||||
|
? 'members'
|
||||||
|
: 'invitations';
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({ [collection]: [] }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let storageAnalysisRequests = 0;
|
||||||
|
await page.route('**/api/v1/system/storage-analysis', async (route) => {
|
||||||
|
storageAnalysisRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: /admin@example\.com/i }).click();
|
||||||
|
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home/bots?action=showStorageAnalysis');
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Workspace' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(storageAnalysisRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -170,7 +170,6 @@ export function makeWorkspaceEntry(
|
|||||||
'member.remove',
|
'member.remove',
|
||||||
'member.update_role',
|
'member.update_role',
|
||||||
'member.view',
|
'member.view',
|
||||||
'owner.transfer',
|
|
||||||
'provider_secret.manage',
|
'provider_secret.manage',
|
||||||
'resource.manage',
|
'resource.manage',
|
||||||
'resource.view',
|
'resource.view',
|
||||||
|
|||||||
@@ -165,3 +165,164 @@ test('an authenticated OSS invitation requires logout before registration', asyn
|
|||||||
invitation: 'logout-invitation',
|
invitation: 'logout-invitation',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an authenticated Cloud Account can accept its invitation directly', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
storage: {
|
||||||
|
token: 'invited-account-token',
|
||||||
|
userEmail: 'invited@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: true,
|
||||||
|
password_login_enabled: false,
|
||||||
|
space_login_enabled: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
invitation: {
|
||||||
|
uuid: 'cloud-invitation',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
normalized_email: 'invited@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
uuid: 'workspace-playwright',
|
||||||
|
name: 'Playwright Workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let acceptanceAuthorization = '';
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
token: 'accepted-cloud-account-token',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/invitations/accept#token=cloud-invitation');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Accept Invitation' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Accept Invitation' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||||
|
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: false,
|
||||||
|
storage: {
|
||||||
|
token: 'stale-other-account-token',
|
||||||
|
userEmail: 'other@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'langbot_pending_invitation_token',
|
||||||
|
'matching-invitation',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/user/space/callback', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
token: 'fresh-invited-account-token',
|
||||||
|
user: 'invited@example.com',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/user/info', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
account_uuid: 'invited-account',
|
||||||
|
user: 'invited@example.com',
|
||||||
|
account_type: 'space',
|
||||||
|
has_password: false,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let acceptanceAuthorization = '';
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
token: 'accepted-invited-account-token',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
userEmail: localStorage.getItem('userEmail'),
|
||||||
|
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||||
|
})),
|
||||||
|
).toEqual({
|
||||||
|
token: 'accepted-invited-account-token',
|
||||||
|
userEmail: 'invited@example.com',
|
||||||
|
invitation: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
installLangBotApiMocks,
|
||||||
|
makeWorkspaceEntry,
|
||||||
|
} from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
function wrapped(data: unknown) {
|
||||||
|
return JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = makeWorkspaceEntry(
|
||||||
|
'workspace-cloud',
|
||||||
|
'Cloud Workspace',
|
||||||
|
'cloud_projection',
|
||||||
|
);
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
workspaces: [workspace],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/plugins', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
install_source: 'marketplace',
|
||||||
|
install_info: {},
|
||||||
|
debug: false,
|
||||||
|
manifest: {
|
||||||
|
manifest: {
|
||||||
|
metadata: {
|
||||||
|
author: 'langbot-team',
|
||||||
|
name: 'LangRAG',
|
||||||
|
version: '0.1.9',
|
||||||
|
label: { en_US: 'LangRAG', zh_Hans: 'LangRAG' },
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 'observability',
|
||||||
|
path: 'components/pages/observability.html',
|
||||||
|
label: { en_US: 'Observability', zh_Hans: '观测面板' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let authenticatedAssetRequests = 0;
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
|
||||||
|
async (route) => {
|
||||||
|
authenticatedAssetRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.frameLocator('iframe')
|
||||||
|
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
function wrapped(data: unknown) {
|
||||||
|
return JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfill(
|
||||||
|
route: Parameters<Parameters<import('@playwright/test').Page['route']>[1]>[0],
|
||||||
|
data: unknown,
|
||||||
|
) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('quota-reached create actions are disabled and explain the current limit', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
|
||||||
|
await page.route('**/api/v1/system/info', (route) =>
|
||||||
|
fulfill(route, {
|
||||||
|
debug: false,
|
||||||
|
version: 'quota-e2e',
|
||||||
|
edition: 'community',
|
||||||
|
cloud_service_url: 'https://space.langbot.app',
|
||||||
|
enable_marketplace: true,
|
||||||
|
allow_modify_login_info: true,
|
||||||
|
disable_models_service: false,
|
||||||
|
limitation: {
|
||||||
|
max_bots: 2,
|
||||||
|
max_pipelines: 3,
|
||||||
|
max_extensions: 3,
|
||||||
|
max_knowledge_bases: 2,
|
||||||
|
},
|
||||||
|
outbound_ips: [],
|
||||||
|
wizard_status: 'completed',
|
||||||
|
wizard_progress: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/platform/bots**', (route) =>
|
||||||
|
fulfill(route, {
|
||||||
|
bots: Array.from({ length: 2 }, (_, index) => ({
|
||||||
|
uuid: `bot-${index}`,
|
||||||
|
name: `Bot ${index + 1}`,
|
||||||
|
description: '',
|
||||||
|
adapter: 'aiocqhttp',
|
||||||
|
enable: true,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/pipelines**', (route) =>
|
||||||
|
fulfill(route, {
|
||||||
|
pipelines: Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
uuid: `pipeline-${index}`,
|
||||||
|
name: `Pipeline ${index + 1}`,
|
||||||
|
description: '',
|
||||||
|
emoji: '⚙️',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/knowledge/bases**', (route) =>
|
||||||
|
fulfill(route, {
|
||||||
|
bases: Array.from({ length: 2 }, (_, index) => ({
|
||||||
|
uuid: `kb-${index}`,
|
||||||
|
name: `Knowledge ${index + 1}`,
|
||||||
|
description: '',
|
||||||
|
emoji: '📚',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/plugins**', (route) =>
|
||||||
|
fulfill(route, { plugins: [] }),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/mcp/servers**', (route) =>
|
||||||
|
fulfill(route, {
|
||||||
|
servers: Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
name: `mcp-${index}`,
|
||||||
|
mode: 'http',
|
||||||
|
enable: true,
|
||||||
|
runtime_info: { status: 'connected' },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/skills**', (route) =>
|
||||||
|
fulfill(route, { skills: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
|
||||||
|
const botCreate = page.getByRole('button', {
|
||||||
|
name: 'Create Bots',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const pipelineCreate = page.getByRole('button', {
|
||||||
|
name: 'Create Pipelines',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const knowledgeCreate = page.getByRole('button', {
|
||||||
|
name: 'Create Knowledge',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const addExtension = page.getByRole('button', {
|
||||||
|
name: 'Add Extension',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(botCreate).toBeDisabled();
|
||||||
|
await expect(pipelineCreate).toBeDisabled();
|
||||||
|
await expect(knowledgeCreate).toBeDisabled();
|
||||||
|
await expect(addExtension).toBeEnabled();
|
||||||
|
|
||||||
|
const botQuotaTrigger = botCreate.locator('..');
|
||||||
|
await botQuotaTrigger.hover();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await botQuotaTrigger.focus();
|
||||||
|
await expect(botQuotaTrigger).toBeFocused();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await addExtension.click();
|
||||||
|
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||||
|
const manualAdd = page.getByRole('button', { name: 'Manual Add' });
|
||||||
|
await expect(manualAdd).toBeDisabled();
|
||||||
|
await manualAdd.locator('..').hover();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly',
|
|||||||
assert.match(source, /ownerSpaceBound/);
|
assert.match(source, /ownerSpaceBound/);
|
||||||
assert.match(source, /models\.ownerMustBindSpace/);
|
assert.match(source, /models\.ownerMustBindSpace/);
|
||||||
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
||||||
|
assert.match(source, /isWorkspaceOwner && \(\s*<Button/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace member controls never offer ownership transfer', () => {
|
||||||
|
const source = read(
|
||||||
|
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(source, /canTransferOwner/);
|
||||||
|
assert.doesNotMatch(source, /workspace\.transferOwnership/);
|
||||||
|
assert.doesNotMatch(source, /<SelectItem value="owner">/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(currentDirectory, '../..');
|
||||||
|
|
||||||
|
function readSource(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeSidebarSource = readSource(
|
||||||
|
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||||
|
);
|
||||||
|
const botFormSource = readSource(
|
||||||
|
'src/app/home/bots/components/bot-form/BotForm.tsx',
|
||||||
|
);
|
||||||
|
const kbFormSource = readSource(
|
||||||
|
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
|
||||||
|
);
|
||||||
|
const settingsDialogSource = readSource(
|
||||||
|
'src/app/home/components/settings-dialog/SettingsDialog.tsx',
|
||||||
|
);
|
||||||
|
const pluginPageSource = readSource('src/app/home/plugin-pages/page.tsx');
|
||||||
|
const authenticatedPluginResourceSource = readSource(
|
||||||
|
'src/hooks/useAuthenticatedPluginResource.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('hides the entire workspace switcher slot for a singleton local workspace', () => {
|
||||||
|
assert.match(homeSidebarSource, /useWorkspaceBootstrap/);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>\s*<WorkspaceSwitcher/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
|
||||||
|
assert.match(
|
||||||
|
botFormSource,
|
||||||
|
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
|
||||||
|
);
|
||||||
|
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not expose storage analysis in Cloud settings or via a deep link', () => {
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?openSettings\('storageAnalysis'\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/const canViewStorageAnalysis\s*=\s*currentWorkspace\?\.workspace\.source\s*!==\s*'cloud_projection'\s*&&\s*canViewAudit/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/item\.id === 'storageAnalysis'[\s\S]*?return canViewStorageAnalysis/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/section === 'storageAnalysis' && !canViewStorageAnalysis/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/section === 'storageAnalysis' &&\s*canViewStorageAnalysis &&\s*\(\s*<StorageAnalysisPanel/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads plugin pages through the authenticated Workspace-scoped asset route', () => {
|
||||||
|
assert.match(pluginPageSource, /useAuthenticatedPluginAsset/);
|
||||||
|
assert.match(
|
||||||
|
pluginPageSource,
|
||||||
|
/useAuthenticatedPluginAsset\(\s*author,\s*pluginName,\s*pagePath,?\s*\)/,
|
||||||
|
);
|
||||||
|
assert.match(pluginPageSource, /src=\{assetUrl\}/);
|
||||||
|
assert.doesNotMatch(pluginPageSource, /getPluginAssetURL\(/);
|
||||||
|
assert.match(pluginPageSource, /plugins\.loadFailed/);
|
||||||
|
assert.match(pluginPageSource, /loadedAssetUrl !== assetUrl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revokes and reloads authenticated plugin resources when the Workspace changes', () => {
|
||||||
|
assert.match(authenticatedPluginResourceSource, /useCurrentWorkspace/);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/const workspaceUuid = currentWorkspace\?\.workspace\.uuid;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/\[author, name, filepath, resourceKey\]/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/resource\.key === resourceKey \? resource\.url : ''/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const quotaPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts',
|
||||||
|
);
|
||||||
|
const sidebarPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||||
|
);
|
||||||
|
const tooltipPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx',
|
||||||
|
);
|
||||||
|
const baseTooltipPath = path.join(root, 'src/components/ui/tooltip.tsx');
|
||||||
|
const addExtensionPath = path.join(root, 'src/app/home/add-extension/page.tsx');
|
||||||
|
const marketPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
|
||||||
|
);
|
||||||
|
const marketCardPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
|
||||||
|
);
|
||||||
|
const recommendationPath = path.join(
|
||||||
|
root,
|
||||||
|
'src/app/home/plugins/components/plugin-market/RecommendationLists.tsx',
|
||||||
|
);
|
||||||
|
const zhPath = path.join(root, 'src/i18n/locales/zh-Hans.ts');
|
||||||
|
|
||||||
|
test('workspace quota hook exposes reached states for every creatable resource', () => {
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(quotaPath),
|
||||||
|
true,
|
||||||
|
'workspace quota hook is missing',
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(quotaPath, 'utf8');
|
||||||
|
for (const token of [
|
||||||
|
'botsReached',
|
||||||
|
'pipelinesReached',
|
||||||
|
'knowledgeBasesReached',
|
||||||
|
'extensionsReached',
|
||||||
|
'max_bots',
|
||||||
|
'max_pipelines',
|
||||||
|
'max_knowledge_bases',
|
||||||
|
'max_extensions',
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(token));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar quota-disables create controls and renders a tooltip', () => {
|
||||||
|
const source = fs.readFileSync(sidebarPath, 'utf8');
|
||||||
|
const tooltip = fs.readFileSync(tooltipPath, 'utf8');
|
||||||
|
const baseTooltip = fs.readFileSync(baseTooltipPath, 'utf8');
|
||||||
|
assert.match(source, /useWorkspaceQuotaStatus/);
|
||||||
|
assert.match(source, /quota\.disabled/);
|
||||||
|
assert.match(source, /disabled=\{quota\.disabled\}/);
|
||||||
|
assert.match(source, /WorkspaceQuotaTooltip/);
|
||||||
|
assert.match(tooltip, /TooltipContent/);
|
||||||
|
assert.match(tooltip, /limitation\.createDisabledTooltip/);
|
||||||
|
assert.match(tooltip, /limitation\.quotaLoadingTooltip/);
|
||||||
|
assert.match(tooltip, /tabIndex=\{0\}/);
|
||||||
|
assert.match(tooltip, /max-w-72 text-left/);
|
||||||
|
assert.doesNotMatch(tooltip, /text-center/);
|
||||||
|
assert.doesNotMatch(baseTooltip, /text-balance/);
|
||||||
|
assert.match(source, /config\.id === 'add-extension'/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/config\.id === 'add-extension'\s*\?\s*quotaStatus\.extensions/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add-extension page disables all install entry points at the quota', () => {
|
||||||
|
const page = fs.readFileSync(addExtensionPath, 'utf8');
|
||||||
|
const market = fs.readFileSync(marketPath, 'utf8');
|
||||||
|
const card = fs.readFileSync(marketCardPath, 'utf8');
|
||||||
|
const recommendations = fs.readFileSync(recommendationPath, 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /extensionsReached/);
|
||||||
|
assert.match(page, /installDisabled=\{extensionsReached\}/);
|
||||||
|
assert.match(page, /disabled=\{extensionsReached/);
|
||||||
|
assert.match(page, /limitation\.createDisabledTooltip/);
|
||||||
|
assert.match(market, /installDisabled/);
|
||||||
|
assert.match(card, /installDisabled/);
|
||||||
|
assert.match(card, /disabled=\{installDisabled\}/);
|
||||||
|
assert.match(card, /TooltipContent/);
|
||||||
|
assert.match(card, /max-w-72 text-left/);
|
||||||
|
assert.doesNotMatch(card, /max-w-72 text-center/);
|
||||||
|
assert.match(recommendations, /installDisabled=\{installDisabled\}/);
|
||||||
|
assert.match(
|
||||||
|
recommendations,
|
||||||
|
/installDisabledTooltip=\{installDisabledTooltip\}/,
|
||||||
|
);
|
||||||
|
assert.match(page, /quota=\{extensionQuota\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extension confirmation checks fail closed and enter an in-flight state first', () => {
|
||||||
|
const page = fs.readFileSync(addExtensionPath, 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /limitation\.quotaCheckFailed/);
|
||||||
|
assert.doesNotMatch(page, /If we can't check, let backend handle it/);
|
||||||
|
assert.match(
|
||||||
|
page,
|
||||||
|
/setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
page,
|
||||||
|
/setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota tooltip copy is localized in Simplified Chinese', () => {
|
||||||
|
const source = fs.readFileSync(zhPath, 'utf8');
|
||||||
|
assert.match(source, /createDisabledTooltip/);
|
||||||
|
assert.match(source, /已达到.*上限/);
|
||||||
|
assert.match(source, /删除.*后再/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('direct launch assertion is fragment-only and removed before exchange', () => {
|
||||||
|
assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/);
|
||||||
|
const readIndex = source.indexOf("fragmentParams.get('launch_assertion')");
|
||||||
|
const clearIndex = source.indexOf('window.history.replaceState');
|
||||||
|
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
|
||||||
|
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
|
||||||
|
assert.ok(
|
||||||
|
clearIndex > readIndex,
|
||||||
|
'URL fragment is not cleared after copying the assertion',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
exchangeIndex > clearIndex,
|
||||||
|
'assertion exchange starts before the fragment is cleared',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -50,15 +50,22 @@ test('places WorkspaceSwitcher between the sidebar header and Home navigation',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
|
test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
workspaceSwitcherSource,
|
workspaceSwitcherSource,
|
||||||
/if \(!currentWorkspace\) return null;/,
|
/if \(!currentWorkspace\) return null;/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
|
assert.match(
|
||||||
assert.doesNotMatch(
|
|
||||||
homeSidebarSource,
|
homeSidebarSource,
|
||||||
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
|
/const workspaces = useWorkspaceBootstrap\(\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>[\s\S]*?<WorkspaceSwitcher/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user