feat: add Space integration for user authentication and model management with OAuth support

This commit is contained in:
Junyan Qin
2025-12-26 00:35:47 +08:00
parent 7479545339
commit 8caab43b00
27 changed files with 5214 additions and 6156 deletions
@@ -0,0 +1,52 @@
import quart
from .. import group
DEFAULT_SPACE_URL = 'https://space.langbot.app'
@group.group_class('space', '/api/v1/space')
class SpaceRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/models/sync', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Sync models from Space MaaS to local database"""
json_data = await quart.request.json or {}
space_url = json_data.get('space_url', DEFAULT_SPACE_URL)
try:
stats = await self.ap.space_models_service.sync_models_from_space(user_email, space_url)
return self.success(data=stats)
except ValueError as e:
return self.fail(1, str(e))
except Exception as e:
return self.fail(2, f'Failed to sync models: {str(e)}')
@self.route('/models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get all synced Space models"""
if quart.request.method == 'GET':
try:
models = await self.ap.space_models_service.get_space_models()
return self.success(data=models)
except Exception as e:
return self.fail(1, f'Failed to get Space models: {str(e)}')
elif quart.request.method == 'DELETE':
try:
stats = await self.ap.space_models_service.delete_space_models()
return self.success(data=stats)
except Exception as e:
return self.fail(1, f'Failed to delete Space models: {str(e)}')
@self.route('/models/available', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get available models from Space (preview before sync)"""
try:
space_url = quart.request.args.get('space_url', DEFAULT_SPACE_URL)
models_data = await self.ap.space_models_service.fetch_space_models(space_url)
return self.success(data=models_data)
except ValueError as e:
return self.fail(1, str(e))
except Exception as e:
return self.fail(2, f'Failed to fetch available models: {str(e)}')
@@ -33,6 +33,8 @@ class UserRouterGroup(group.RouterGroup):
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
except argon2.exceptions.VerifyMismatchError: except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password') return self.fail(1, 'Invalid username or password')
except ValueError as e:
return self.fail(1, str(e))
return self.success(data={'token': token}) return self.success(data={'token': token})
@@ -71,9 +73,7 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str: async def _(user_email: str) -> str:
# Check if password change is allowed # Check if password change is allowed
allow_change_password = self.ap.instance_config.data.get('system', {}).get( allow_change_password = self.ap.instance_config.data.get('system', {}).get('allow_change_password', True)
'allow_change_password', True
)
if not allow_change_password: if not allow_change_password:
return self.http_status(403, -1, 'Password change is disabled') return self.http_status(403, -1, 'Password change is disabled')
@@ -90,3 +90,67 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, str(e)) return self.http_status(400, -1, str(e))
return self.success(data={'user': user_email}) return self.success(data={'user': user_email})
# Space OAuth endpoints (redirect flow)
@self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
try:
authorize_url = self.ap.user_service.get_space_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except Exception as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Missing authorization code')
try:
# Exchange code for tokens
token_data = await self.ap.user_service.exchange_space_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(access_token, refresh_token)
return self.success(
data={
'token': jwt_token,
'user': user_obj.user,
}
)
except ValueError as e:
return self.fail(1, str(e))
except Exception as e:
return self.fail(2, f'OAuth callback failed: {str(e)}')
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get current user information including account type"""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
return self.success(
data={
'user': user_obj.user,
'account_type': user_obj.account_type,
}
)
@@ -0,0 +1,247 @@
from __future__ import annotations
import typing
import uuid as uuid_lib
import aiohttp
import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import user as persistence_user
DEFAULT_SPACE_URL = 'http://localhost:8383'
# Space's base URL for model API requests (used for requester_config)
SPACE_API_BASE_URL = 'http://localhost:8383'
class SpaceModelsService:
"""Service for syncing models from Space MaaS"""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_space_user_info(self, user_email: str) -> persistence_user.User | None:
"""Get Space user info for sync operations"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_user.User).where(persistence_user.User.user == user_email)
)
result_list = result.all()
return result_list[0] if result_list else None
async def fetch_space_models(self, space_url: str = DEFAULT_SPACE_URL) -> typing.Dict:
"""Fetch available models from Space API"""
async with aiohttp.ClientSession() as session:
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
if response.status != 200:
raise ValueError(f'Failed to fetch models from Space: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to fetch models from Space: {data.get("msg")}')
return data.get('data', {})
async def sync_models_from_space(
self, user_email: str, space_url: str = DEFAULT_SPACE_URL
) -> typing.Dict[str, typing.Any]:
"""
Sync models from Space to local database.
Returns statistics about the sync operation.
"""
# Get user info for API key
user_obj = await self.get_space_user_info(user_email)
if user_obj is None:
raise ValueError('User not found')
if user_obj.account_type != 'space':
raise ValueError('User is not a Space account')
if not user_obj.space_api_key:
raise ValueError('User does not have a Space API key configured')
# Fetch models from Space
models_data = await self.fetch_space_models(space_url)
space_models = models_data.get('models', [])
# Get existing Space models in local database
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
)
existing_space_models = {m.space_model_id: m for m in result.all()}
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.source == 'space'
)
)
existing_space_embedding_models = {m.space_model_id: m for m in result.all()}
stats = {'created_llm': 0, 'updated_llm': 0, 'created_embedding': 0, 'updated_embedding': 0, 'skipped': 0}
for model in space_models:
model_id = model.get('model_id')
category = model.get('category', '')
if not model_id:
stats['skipped'] += 1
continue
if category == 'embedding':
# Handle embedding model
await self._sync_embedding_model(model, user_obj.space_api_key, existing_space_embedding_models, stats)
else:
# Handle LLM model (chat, completion, etc.)
await self._sync_llm_model(model, user_obj.space_api_key, existing_space_models, stats)
return stats
async def _sync_llm_model(
self,
model: typing.Dict,
api_key: str,
existing_models: typing.Dict[str, persistence_model.LLMModel],
stats: typing.Dict,
) -> None:
"""Sync a single LLM model from Space"""
model_id = model.get('model_id')
display_name = model.get('display_name', {})
name = display_name.get('zh_Hans', display_name.get('en_US', model_id))
description_obj = model.get('description', {})
description = description_obj.get('zh_Hans', description_obj.get('en_US', '')) if description_obj else ''
# Infer abilities from model capabilities
abilities = []
supported_endpoints = model.get('supported_endpoints', [])
if 'vision' in str(supported_endpoints).lower() or 'vision' in model_id.lower():
abilities.append('vision')
if 'function' in str(supported_endpoints).lower() or 'tool' in str(supported_endpoints).lower():
abilities.append('function_call')
model_data = {
'name': name,
'description': description[:255] if description else 'Model from Space MaaS',
'requester': 'openai-chat-completions', # Space uses OpenAI-compatible API
'requester_config': {
'base-url': SPACE_API_BASE_URL,
'args': {},
'timeout': 120,
},
'api_keys': [api_key],
'abilities': abilities,
'extra_args': {'model': model_id},
'source': 'space',
'space_model_id': model_id,
}
if model_id in existing_models:
# Update existing model
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.LLMModel)
.where(persistence_model.LLMModel.space_model_id == model_id)
.values(**model_data)
)
stats['updated_llm'] += 1
else:
# Create new model
model_data['uuid'] = str(uuid_lib.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)
)
stats['created_llm'] += 1
async def _sync_embedding_model(
self,
model: typing.Dict,
api_key: str,
existing_models: typing.Dict[str, persistence_model.EmbeddingModel],
stats: typing.Dict,
) -> None:
"""Sync a single embedding model from Space"""
model_id = model.get('model_id')
display_name = model.get('display_name', {})
name = display_name.get('zh_Hans', display_name.get('en_US', model_id))
description_obj = model.get('description', {})
description = description_obj.get('zh_Hans', description_obj.get('en_US', '')) if description_obj else ''
model_data = {
'name': name,
'description': description[:255] if description else 'Embedding model from Space MaaS',
'requester': 'openai-embedding', # Space uses OpenAI-compatible API
'requester_config': {
'base-url': SPACE_API_BASE_URL,
'args': {},
'timeout': 120,
},
'api_keys': [api_key],
'extra_args': {'model': model_id},
'source': 'space',
'space_model_id': model_id,
}
if model_id in existing_models:
# Update existing model
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.space_model_id == model_id)
.values(**model_data)
)
stats['updated_embedding'] += 1
else:
# Create new model
model_data['uuid'] = str(uuid_lib.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
)
stats['created_embedding'] += 1
async def get_space_models(self) -> typing.Dict[str, typing.List]:
"""Get all synced Space models"""
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
)
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.source == 'space'
)
)
return {
'llm_models': [
self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in llm_result.all()
],
'embedding_models': [
self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m)
for m in embedding_result.all()
],
}
async def delete_space_models(self) -> typing.Dict[str, int]:
"""Delete all synced Space models"""
# Remove from model manager first
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
)
for model in llm_result.all():
await self.ap.model_mgr.remove_llm_model(model.uuid)
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.source == 'space'
)
)
for model in embedding_result.all():
await self.ap.model_mgr.remove_embedding_model(model.uuid)
# Delete from database
llm_delete = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
)
embedding_delete = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.source == 'space'
)
)
return {'deleted_llm': llm_delete.rowcount, 'deleted_embedding': embedding_delete.rowcount}
+165 -1
View File
@@ -4,6 +4,8 @@ import sqlalchemy
import argon2 import argon2
import jwt import jwt
import datetime import datetime
import aiohttp
import typing
from ....core import app from ....core import app
from ....entity.persistence import user from ....entity.persistence import user
@@ -16,6 +18,15 @@ class UserService:
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap self.ap = ap
def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file"""
space_config = self.ap.instance_config.data.get('space', {})
return {
'url': space_config.get('url', 'https://space.langbot.app'),
'api_url': space_config.get('api_url', 'https://api.langbot.app'),
'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),
}
async def is_initialized(self) -> bool: async def is_initialized(self) -> bool:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
@@ -28,7 +39,7 @@ class UserService:
hashed_password = ph.hash(password) hashed_password = ph.hash(password)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password) sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
) )
async def get_user_by_email(self, user_email: str) -> user.User | None: async def get_user_by_email(self, user_email: str) -> user.User | None:
@@ -39,6 +50,15 @@ class UserService:
result_list = result.all() result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
"""Get user by Space account UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
)
result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def authenticate(self, user_email: str, password: str) -> str | None: async def authenticate(self, user_email: str, password: str) -> str | None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email) sqlalchemy.select(user.User).where(user.User.user == user_email)
@@ -51,6 +71,10 @@ class UserService:
user_obj = result_list[0] user_obj = result_list[0]
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher() ph = argon2.PasswordHasher()
ph.verify(user_obj.password, password) ph.verify(user_obj.password, password)
@@ -90,6 +114,10 @@ class UserService:
if user_obj is None: if user_obj is None:
raise ValueError('User not found') raise ValueError('User not found')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password) ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password) hashed_password = ph.hash(new_password)
@@ -97,3 +125,139 @@ class UserService:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
) )
# Space OAuth methods (redirect flow)
def get_space_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
"""Get the Space OAuth authorization URL for redirect"""
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
# Build the authorization URL with redirect_uri
params = f'redirect_uri={redirect_uri}'
if state:
params += f'&state={state}'
return f'{authorize_url}?{params}'
async def exchange_space_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code},
) as response:
if response.status != 200:
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
return data.get('data', {})
async def get_space_user_info(self, access_token: str) -> typing.Dict:
"""Get user info from Space using access token"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.get(
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
) as response:
if response.status != 200:
raise ValueError(f'Failed to get user info: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {})
async def refresh_space_token(self, refresh_token: str) -> typing.Dict:
"""Refresh Space access token"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
) as response:
if response.status != 200:
raise ValueError(f'Failed to refresh token: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
return data.get('data', {})
async def create_or_update_space_user(
self,
space_account_uuid: str,
email: str,
access_token: str,
refresh_token: str,
api_key: str,
) -> user.User:
"""Create or update a Space user account"""
# Check if user with this Space UUID already exists
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_user:
# Update existing user's tokens
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.space_account_uuid == space_account_uuid)
.values(
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists as local account
existing_email_user = await self.get_user_by_email(email)
if existing_email_user and existing_email_user.account_type == 'local':
raise ValueError('A local account with this email already exists. Please use a different email.')
# Create new Space user
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=email,
password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_space_account_uuid(space_account_uuid)
async def authenticate_space_user(self, access_token: str, refresh_token: str) -> typing.Tuple[str, user.User]:
"""Authenticate with Space and return JWT token"""
# Get user info from Space
user_info = await self.get_space_user_info(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
email = account.get('email')
if not space_account_uuid or not email:
raise ValueError('Invalid Space user info')
# Create or update Space user in local database
user_obj = await self.create_or_update_space_user(
space_account_uuid=space_account_uuid,
email=email,
access_token=access_token,
refresh_token=refresh_token,
api_key=api_key,
)
# Generate JWT token
jwt_token = await self.generate_jwt_token(email)
return jwt_token, user_obj
+3
View File
@@ -27,6 +27,7 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service from ..api.http.service import webhook as webhook_service
from ..api.http.service import external_kb as external_kb_service from ..api.http.service import external_kb as external_kb_service
from ..api.http.service import space_models as space_models_service
from ..discover import engine as discover_engine from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr from ..storage import mgr as storagemgr
from ..utils import logcache from ..utils import logcache
@@ -132,6 +133,8 @@ class Application:
webhook_service: webhook_service.WebhookService = None webhook_service: webhook_service.WebhookService = None
space_models_service: space_models_service.SpaceModelsService = None
def __init__(self): def __init__(self):
pass pass
+4
View File
@@ -24,6 +24,7 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service from ...api.http.service import webhook as webhook_service
from ...api.http.service import external_kb as external_kb_service from ...api.http.service import external_kb as external_kb_service
from ...api.http.service import space_models as space_models_service
from ...discover import engine as discover_engine from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr from ...storage import mgr as storagemgr
from ...utils import logcache from ...utils import logcache
@@ -135,6 +136,9 @@ class BuildAppStage(stage.BootingStage):
webhook_service_inst = webhook_service.WebhookService(ap) webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst ap.webhook_service = webhook_service_inst
space_models_service_inst = space_models_service.SpaceModelsService(ap)
ap.space_models_service = space_models_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3) await asyncio.sleep(3)
await plugin_connector_inst.initialize() await plugin_connector_inst.initialize()
@@ -16,6 +16,10 @@ class LLMModel(Base):
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Source tracking for Space integration: 'local' or 'space'
source = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
# Space model ID for synced models (used to track and update synced models)
space_model_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,
@@ -37,6 +41,10 @@ class EmbeddingModel(Base):
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Source tracking for Space integration: 'local' or 'space'
source = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
# Space model ID for synced models (used to track and update synced models)
space_model_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,
@@ -9,6 +9,16 @@ class User(Base):
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
# Account type: 'local' (default) or 'space'
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
# Space account fields (nullable, only used when account_type='space')
space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
sqlalchemy.DateTime, sqlalchemy.DateTime,
@@ -0,0 +1,82 @@
import sqlalchemy
from .. import migration
@migration.migration_class(14)
class DBMigrateSpaceAccountSupport(migration.DBMigration):
"""Add Space account support fields to users table"""
async def upgrade(self):
"""Upgrade"""
# Get all column names from the users table
columns = []
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
)
all_result = result.fetchall()
columns = [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))
all_result = result.fetchall()
columns = [row[1] for row in all_result]
# Add account_type column
if 'account_type' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
# Add space_account_uuid column
if 'space_account_uuid' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
# Add space_access_token column
if 'space_access_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
# Add space_refresh_token column
if 'space_refresh_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
# Add space_api_key column
if 'space_api_key' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
async def downgrade(self):
"""Downgrade"""
pass
@@ -0,0 +1,78 @@
import sqlalchemy
from .. import migration
@migration.migration_class(15)
class DBMigrateModelSourceTracking(migration.DBMigration):
"""Add source tracking fields to models tables for Space integration"""
async def upgrade(self):
"""Upgrade"""
# Add source column to llm_models table
llm_columns = await self._get_columns('llm_models')
if 'source' not in llm_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE llm_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE llm_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL")
)
if 'space_model_id' not in llm_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN space_model_id VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN space_model_id VARCHAR(255)')
)
# Add source column to embedding_models table
embedding_columns = await self._get_columns('embedding_models')
if 'source' not in embedding_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
"ALTER TABLE embedding_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL"
)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
"ALTER TABLE embedding_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL"
)
)
if 'space_model_id' not in embedding_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN space_model_id VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN space_model_id VARCHAR(255)')
)
async def _get_columns(self, table_name: str) -> list:
"""Get column names for a table"""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
)
)
all_result = result.fetchall()
return [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
all_result = result.fetchall()
return [row[1] for row in all_result]
async def downgrade(self):
"""Downgrade"""
pass
+1 -1
View File
@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' semantic_version = f'v{langbot.__version__}'
required_database_version = 13 required_database_version = 15
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False
+7
View File
@@ -71,3 +71,10 @@ plugin:
enable_marketplace: true enable_marketplace: true
cloud_service_url: 'https://space.langbot.app' cloud_service_url: 'https://space.langbot.app'
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'
# Space API URL for model requests (MaaS)
api_url: 'https://api.langbot.app'
# OAuth authorization page URL (user will be redirected here)
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
+2
View File
@@ -54,6 +54,7 @@
"next": "~15.5.9", "next": "~15.5.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
@@ -82,6 +83,7 @@
"@types/mdast": "^4.0.4", "@types/mdast": "^4.0.4",
"@types/ms": "^2.1.0", "@types/ms": "^2.1.0",
"@types/node": "^20", "@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.7", "@types/react": "~19.2.7",
"@types/react-dom": "~19.2.3", "@types/react-dom": "~19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
+3549 -5981
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import langbotIcon from '@/app/assets/langbot-logo.webp';
export default function SpaceOAuthCallback() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading',
);
const [errorMessage, setErrorMessage] = useState<string>('');
const handleOAuthCallback = useCallback(
async (code: string) => {
try {
const response = await httpClient.exchangeSpaceOAuthCode(code);
// Store token and user info
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
// Redirect to home after a brief delay to show success state
setTimeout(() => {
router.push('/home');
}, 1000);
} catch {
setStatus('error');
setErrorMessage(t('common.spaceLoginFailed'));
}
},
[router, t],
);
useEffect(() => {
const code = searchParams.get('code');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (error) {
setStatus('error');
setErrorMessage(
errorDescription || error || t('common.spaceLoginFailed'),
);
return;
}
if (!code) {
setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode'));
return;
}
// Exchange code for token
handleOAuthCallback(code);
}, [searchParams, handleOAuthCallback, t]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader className="text-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={langbotIcon.src}
alt="LangBot"
className="w-16 h-16 mb-4 mx-auto"
/>
<CardTitle className="text-xl">
{status === 'loading' && t('common.spaceLoginProcessing')}
{status === 'success' && t('common.spaceLoginSuccess')}
{status === 'error' && t('common.spaceLoginError')}
</CardTitle>
<CardDescription>
{status === 'loading' &&
t('common.spaceLoginProcessingDescription')}
{status === 'success' && t('common.spaceLoginSuccessDescription')}
{status === 'error' && errorMessage}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{status === 'loading' && (
<Loader2 className="h-12 w-12 animate-spin text-primary" />
)}
{status === 'success' && (
<CheckCircle2 className="h-12 w-12 text-green-500" />
)}
{status === 'error' && (
<>
<AlertCircle className="h-12 w-12 text-red-500" />
<Button
onClick={() => router.push('/login')}
className="w-full mt-4"
>
{t('common.backToLogin')}
</Button>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,7 +1,17 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, MessageSquareText, Cpu, Info } from 'lucide-react'; import {
Plus,
MessageSquareText,
Cpu,
Info,
RefreshCw,
ChevronLeft,
Cloud,
HardDrive,
Lock,
} from 'lucide-react';
import { LLMCardVO } from './component/llm-card/LLMCardVO'; import { LLMCardVO } from './component/llm-card/LLMCardVO';
import LLMCard from './component/llm-card/LLMCard'; import LLMCard from './component/llm-card/LLMCard';
import LLMForm from './component/llm-form/LLMForm'; import LLMForm from './component/llm-form/LLMForm';
@@ -21,80 +31,224 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { EmbeddingCardVO } from './component/embedding-card/EmbeddingCardVO'; import { EmbeddingCardVO } from './component/embedding-card/EmbeddingCardVO';
import EmbeddingCard from './component/embedding-card/EmbeddingCard'; import EmbeddingCard from './component/embedding-card/EmbeddingCard';
import EmbeddingForm from './component/embedding-form/EmbeddingForm'; import EmbeddingForm from './component/embedding-form/EmbeddingForm';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface ModelsDialogProps { interface ModelsDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
type ViewMode = 'providers' | 'space' | 'local';
export default function ModelsDialog({ export default function ModelsDialog({
open, open,
onOpenChange, onOpenChange,
}: ModelsDialogProps) { }: ModelsDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('providers');
const [activeTab, setActiveTab] = useState<string>('llm'); const [activeTab, setActiveTab] = useState<string>('llm');
const [cardList, setCardList] = useState<LLMCardVO[]>([]);
// User account type
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
// Local models
const [localLLMList, setLocalLLMList] = useState<LLMCardVO[]>([]);
const [localEmbeddingList, setLocalEmbeddingList] = useState<
EmbeddingCardVO[]
>([]);
// Space models
const [spaceLLMList, setSpaceLLMList] = useState<LLMCardVO[]>([]);
const [spaceEmbeddingList, setSpaceEmbeddingList] = useState<
EmbeddingCardVO[]
>([]);
// Sync state
const [isSyncing, setIsSyncing] = useState(false);
// Form modals
const [modalOpen, setModalOpen] = useState<boolean>(false); const [modalOpen, setModalOpen] = useState<boolean>(false);
const [isEditForm, setIsEditForm] = useState(false); const [isEditForm, setIsEditForm] = useState(false);
const [nowSelectedLLM, setNowSelectedLLM] = useState<LLMCardVO | null>(null); const [nowSelectedLLM, setNowSelectedLLM] = useState<LLMCardVO | null>(null);
const [embeddingCardList, setEmbeddingCardList] = useState<EmbeddingCardVO[]>(
[],
);
const [embeddingModalOpen, setEmbeddingModalOpen] = useState<boolean>(false); const [embeddingModalOpen, setEmbeddingModalOpen] = useState<boolean>(false);
const [isEditEmbeddingForm, setIsEditEmbeddingForm] = useState(false); const [isEditEmbeddingForm, setIsEditEmbeddingForm] = useState(false);
const [nowSelectedEmbedding, setNowSelectedEmbedding] = const [nowSelectedEmbedding, setNowSelectedEmbedding] =
useState<EmbeddingCardVO | null>(null); useState<EmbeddingCardVO | null>(null);
// Requester name lists for display
const [llmRequesterNameList, setLLMRequesterNameList] = useState<
{ label: string; value: string }[]
>([]);
const [embeddingRequesterNameList, setEmbeddingRequesterNameList] = useState<
{ label: string; value: string }[]
>([]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
getLLMModelList(); loadUserInfo();
getEmbeddingModelList(); loadRequesterLists();
loadAllModels();
} }
}, [open]); }, [open]);
async function getLLMModelList() { async function loadUserInfo() {
const requesterNameListResp = await httpClient.getProviderRequesters('llm'); try {
const requesterNameList = requesterNameListResp.requesters.map((item) => { const userInfo = await httpClient.getUserInfo();
return { setAccountType(userInfo.account_type);
label: extractI18nObject(item.label), } catch {
value: item.name, // Default to local if user info cannot be fetched
}; setAccountType('local');
}); }
}
httpClient async function loadRequesterLists() {
.getProviderLLMModels() try {
.then((resp) => { const llmRequesters = await httpClient.getProviderRequesters('llm');
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => { setLLMRequesterNameList(
return new LLMCardVO({ llmRequesters.requesters.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
})),
);
const embeddingRequesters =
await httpClient.getProviderRequesters('text-embedding');
setEmbeddingRequesterNameList(
embeddingRequesters.requesters.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
})),
);
} catch (err) {
console.error('Failed to load requester lists', err);
}
}
async function loadAllModels() {
await Promise.all([loadLLMModels(), loadEmbeddingModels()]);
}
async function loadLLMModels() {
try {
const resp = await httpClient.getProviderLLMModels();
const localModels: LLMCardVO[] = [];
const spaceModels: LLMCardVO[] = [];
resp.models.forEach((model: LLMModel & { source?: string }) => {
const cardVO = new LLMCardVO({
id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
name: model.name,
providerLabel:
llmRequesterNameList.find((item) => item.value === model.requester)
?.label || model.requester.substring(0, 10),
baseURL: model.requester_config?.base_url,
abilities: model.abilities || [],
});
if (model.source === 'space') {
spaceModels.push(cardVO);
} else {
localModels.push(cardVO);
}
});
setLocalLLMList(localModels);
setSpaceLLMList(spaceModels);
} catch (err) {
console.error('Failed to load LLM models', err);
toast.error(t('models.getModelListError') + (err as Error).message);
}
}
async function loadEmbeddingModels() {
try {
const resp = await httpClient.getProviderEmbeddingModels();
const localModels: EmbeddingCardVO[] = [];
const spaceModels: EmbeddingCardVO[] = [];
resp.models.forEach(
(model: {
uuid: string;
requester: string;
name: string;
requester_config?: { base_url?: string };
source?: string;
}) => {
const cardVO = new EmbeddingCardVO({
id: model.uuid, id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester), iconURL: httpClient.getProviderRequesterIconURL(model.requester),
name: model.name, name: model.name,
providerLabel: providerLabel:
requesterNameList.find((item) => item.value === model.requester) embeddingRequesterNameList.find(
?.label || model.requester.substring(0, 10), (item) => item.value === model.requester,
baseURL: model.requester_config?.base_url, )?.label || model.requester.substring(0, 10),
abilities: model.abilities || [], baseURL: model.requester_config?.base_url || '',
}); });
});
setCardList(llmModelList); if (model.source === 'space') {
}) spaceModels.push(cardVO);
.catch((err) => { } else {
console.error('get LLM model list error', err); localModels.push(cardVO);
toast.error(t('models.getModelListError') + err.message); }
}); },
);
setLocalEmbeddingList(localModels);
setSpaceEmbeddingList(spaceModels);
} catch (err) {
console.error('Failed to load embedding models', err);
toast.error(t('embedding.getModelListError') + (err as Error).message);
}
} }
function selectLLM(cardVO: LLMCardVO) { async function handleSyncSpaceModels() {
setIsSyncing(true);
try {
const stats = await httpClient.syncSpaceModels();
toast.success(
t('models.syncSuccess', {
created: stats.created_llm + stats.created_embedding,
updated: stats.updated_llm + stats.updated_embedding,
}),
);
await loadAllModels();
} catch (err) {
toast.error(t('models.syncError') + (err as Error).message);
} finally {
setIsSyncing(false);
}
}
function selectLLM(cardVO: LLMCardVO, isSpaceModel: boolean) {
if (isSpaceModel) {
// Space models are read-only, just show info
toast.info(t('models.spaceModelReadOnly'));
return;
}
setIsEditForm(true); setIsEditForm(true);
setNowSelectedLLM(cardVO); setNowSelectedLLM(cardVO);
setModalOpen(true); setModalOpen(true);
} }
function handleCreateModelClick() { function handleCreateModelClick() {
setIsEditForm(false); setIsEditForm(false);
setNowSelectedLLM(null); setNowSelectedLLM(null);
setModalOpen(true); setModalOpen(true);
} }
function selectEmbedding(cardVO: EmbeddingCardVO) {
function selectEmbedding(cardVO: EmbeddingCardVO, isSpaceModel: boolean) {
if (isSpaceModel) {
toast.info(t('models.spaceModelReadOnly'));
return;
}
setIsEditEmbeddingForm(true); setIsEditEmbeddingForm(true);
setNowSelectedEmbedding(cardVO); setNowSelectedEmbedding(cardVO);
setEmbeddingModalOpen(true); setEmbeddingModalOpen(true);
@@ -105,80 +259,113 @@ export default function ModelsDialog({
setNowSelectedEmbedding(null); setNowSelectedEmbedding(null);
setEmbeddingModalOpen(true); setEmbeddingModalOpen(true);
} }
async function getEmbeddingModelList() {
const requesterNameListResp =
await httpClient.getProviderRequesters('text-embedding');
const requesterNameList = requesterNameListResp.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
};
});
httpClient function renderProviderCards() {
.getProviderEmbeddingModels() const isSpaceDisabled = accountType === 'local';
.then((resp) => {
const embeddingModelList: EmbeddingCardVO[] = resp.models.map( return (
(model: { <div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
uuid: string; {/* Space Provider Card */}
requester: string; <Card
name: string; className={`cursor-pointer transition-all hover:shadow-lg ${
requester_config?: { base_url?: string }; isSpaceDisabled ? 'opacity-50 cursor-not-allowed' : ''
}) => { }`}
return new EmbeddingCardVO({ onClick={() => !isSpaceDisabled && setViewMode('space')}
id: model.uuid, >
iconURL: httpClient.getProviderRequesterIconURL(model.requester), <CardHeader className="flex flex-row items-center gap-4">
name: model.name, <div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
providerLabel: <Cloud className="h-8 w-8 text-blue-600 dark:text-blue-400" />
requesterNameList.find((item) => item.value === model.requester) </div>
?.label || model.requester.substring(0, 10), <div className="flex-1">
baseURL: model.requester_config?.base_url || '', <div className="flex items-center gap-2">
}); <CardTitle>Space</CardTitle>
}, {isSpaceDisabled && (
); <Lock className="h-4 w-4 text-muted-foreground" />
setEmbeddingCardList(embeddingModelList); )}
}) </div>
.catch((err) => { <CardDescription>
console.error('get Embedding model list error', err); {isSpaceDisabled
toast.error(t('embedding.getModelListError') + err.message); ? t('models.spaceDisabledForLocalAccount')
}); : t('models.spaceProviderDescription')}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<Badge variant="secondary">{spaceLLMList.length} LLM</Badge>
<Badge variant="secondary">
{spaceEmbeddingList.length} Embedding
</Badge>
</div>
</CardContent>
</Card>
{/* Local Provider Card */}
<Card
className="cursor-pointer transition-all hover:shadow-lg"
onClick={() => setViewMode('local')}
>
<CardHeader className="flex flex-row items-center gap-4">
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
<HardDrive className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<CardTitle>{t('models.localProvider')}</CardTitle>
<CardDescription>
{t('models.localProviderDescription')}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<Badge variant="secondary">{localLLMList.length} LLM</Badge>
<Badge variant="secondary">
{localEmbeddingList.length} Embedding
</Badge>
</div>
</CardContent>
</Card>
</div>
);
} }
return ( function renderModelList(
<> llmList: LLMCardVO[],
<Dialog embeddingList: EmbeddingCardVO[],
open={open} isSpaceModel: boolean = false,
onOpenChange={(newOpen) => { ) {
if (!newOpen && (modalOpen || embeddingModalOpen)) { return (
return; <Tabs
} value={activeTab}
onOpenChange(newOpen); onValueChange={setActiveTab}
}} className="w-full flex-1 flex flex-col overflow-hidden"
> >
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex flex-col"> <div className="flex flex-row justify-between items-center mb-2">
<DialogHeader className="px-6 pt-6 pb-0"> <TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<DialogTitle>{t('models.title')}</DialogTitle> <TabsTrigger value="llm" className="px-6 py-4 cursor-pointer">
</DialogHeader> <MessageSquareText className="h-4 w-4 mr-1.5" />
{t('llm.llmModels')}
</TabsTrigger>
<TabsTrigger value="embedding" className="px-6 py-4 cursor-pointer">
<Cpu className="h-4 w-4 mr-1.5" />
{t('embedding.embeddingModels')}
</TabsTrigger>
</TabsList>
<Tabs <div className="flex gap-2">
value={activeTab} {isSpaceModel ? (
onValueChange={setActiveTab} <Button
className="w-full flex-1 flex flex-col overflow-hidden px-6 pb-6" size="sm"
> variant="outline"
<div className="flex flex-row justify-between items-center mb-2 mt-4"> onClick={handleSyncSpaceModels}
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]"> disabled={isSyncing}
<TabsTrigger value="llm" className="px-6 py-4 cursor-pointer"> >
<MessageSquareText className="h-4 w-4 mr-1.5" /> <RefreshCw
{t('llm.llmModels')} className={`h-4 w-4 mr-1 ${isSyncing ? 'animate-spin' : ''}`}
</TabsTrigger> />
<TabsTrigger {t('models.syncModels')}
value="embedding" </Button>
className="px-6 py-4 cursor-pointer" ) : (
>
<Cpu className="h-4 w-4 mr-1.5" />
{t('embedding.embeddingModels')}
</TabsTrigger>
</TabsList>
<Button <Button
size="sm" size="sm"
onClick={ onClick={
@@ -192,58 +379,118 @@ export default function ModelsDialog({
? t('models.createModel') ? t('models.createModel')
: t('embedding.createModel')} : t('embedding.createModel')}
</Button> </Button>
</div> )}
</div>
</div>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
<Info className="h-4 w-4 mr-1.5 text-muted-foreground" /> <Info className="h-4 w-4 mr-1.5 text-muted-foreground" />
{activeTab === 'llm' ? ( {activeTab === 'llm' ? (
<p className="text-sm text-muted-foreground flex items-center"> <p className="text-sm text-muted-foreground flex items-center">
{t('llm.description')} {t('llm.description')}
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground flex items-center"> <p className="text-sm text-muted-foreground flex items-center">
{t('embedding.description')} {t('embedding.description')}
</p> </p>
)}
</div>
<TabsContent value="llm" className="flex-1 overflow-auto mt-0">
{llmList.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{isSpaceModel
? t('models.noSpaceModels')
: t('models.noLocalModels')}
</div>
) : (
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
{llmList.map((cardVO) => (
<div
key={cardVO.id}
onClick={() => selectLLM(cardVO, isSpaceModel)}
className={isSpaceModel ? 'cursor-default' : 'cursor-pointer'}
>
<LLMCard cardVO={cardVO} />
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="embedding" className="flex-1 overflow-auto mt-0">
{embeddingList.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{isSpaceModel
? t('models.noSpaceModels')
: t('models.noLocalModels')}
</div>
) : (
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
{embeddingList.map((cardVO) => (
<div
key={cardVO.id}
onClick={() => selectEmbedding(cardVO, isSpaceModel)}
className={isSpaceModel ? 'cursor-default' : 'cursor-pointer'}
>
<EmbeddingCard cardVO={cardVO} />
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
);
}
function getDialogTitle() {
switch (viewMode) {
case 'space':
return 'Space ' + t('models.title');
case 'local':
return t('models.localProvider') + ' ' + t('models.title');
default:
return t('models.title');
}
}
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && (modalOpen || embeddingModalOpen)) {
return;
}
if (!newOpen) {
setViewMode('providers');
}
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex flex-col">
<DialogHeader className="px-6 pt-6 pb-0">
<div className="flex items-center gap-2">
{viewMode !== 'providers' && (
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode('providers')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
)} )}
<DialogTitle>{getDialogTitle()}</DialogTitle>
</div> </div>
</DialogHeader>
<TabsContent value="llm" className="flex-1 overflow-auto mt-0"> <div className="flex-1 overflow-auto px-6 pb-6 mt-4">
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4"> {viewMode === 'providers' && renderProviderCards()}
{cardList.map((cardVO) => { {viewMode === 'space' &&
return ( renderModelList(spaceLLMList, spaceEmbeddingList, true)}
<div {viewMode === 'local' &&
key={cardVO.id} renderModelList(localLLMList, localEmbeddingList, false)}
onClick={() => { </div>
selectLLM(cardVO);
}}
>
<LLMCard cardVO={cardVO}></LLMCard>
</div>
);
})}
</div>
</TabsContent>
<TabsContent
value="embedding"
className="flex-1 overflow-auto mt-0"
>
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
{embeddingCardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectEmbedding(cardVO);
}}
>
<EmbeddingCard cardVO={cardVO}></EmbeddingCard>
</div>
);
})}
</div>
</TabsContent>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -259,14 +506,14 @@ export default function ModelsDialog({
initLLMId={nowSelectedLLM?.id} initLLMId={nowSelectedLLM?.id}
onFormSubmit={() => { onFormSubmit={() => {
setModalOpen(false); setModalOpen(false);
getLLMModelList(); loadAllModels();
}} }}
onFormCancel={() => { onFormCancel={() => {
setModalOpen(false); setModalOpen(false);
}} }}
onLLMDeleted={() => { onLLMDeleted={() => {
setModalOpen(false); setModalOpen(false);
getLLMModelList(); loadAllModels();
}} }}
/> />
</DialogContent> </DialogContent>
@@ -286,14 +533,14 @@ export default function ModelsDialog({
initEmbeddingId={nowSelectedEmbedding?.id} initEmbeddingId={nowSelectedEmbedding?.id}
onFormSubmit={() => { onFormSubmit={() => {
setEmbeddingModalOpen(false); setEmbeddingModalOpen(false);
getEmbeddingModelList(); loadAllModels();
}} }}
onFormCancel={() => { onFormCancel={() => {
setEmbeddingModalOpen(false); setEmbeddingModalOpen(false);
}} }}
onEmbeddingDeleted={() => { onEmbeddingDeleted={() => {
setEmbeddingModalOpen(false); setEmbeddingModalOpen(false);
getEmbeddingModelList(); loadAllModels();
}} }}
/> />
</DialogContent> </DialogContent>
@@ -1,5 +1,5 @@
import styles from './EmbeddingCard.module.css'; import styles from './EmbeddingCard.module.css';
import { EmbeddingCardVO } from '@/app/home/models/component/embedding-card/EmbeddingCardVO'; import { EmbeddingCardVO } from './EmbeddingCardVO';
export default function EmbeddingCard({ cardVO }: { cardVO: EmbeddingCardVO }) { export default function EmbeddingCard({ cardVO }: { cardVO: EmbeddingCardVO }) {
return ( return (
@@ -1,6 +1,6 @@
import { ICreateEmbeddingField } from '@/app/home/models/component/ICreateEmbeddingField'; import { ICreateEmbeddingField } from '../ICreateEmbeddingField';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity'; import { IChooseRequesterEntity } from '../ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { EmbeddingModel } from '@/app/infra/entities/api'; import { EmbeddingModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs'; import { UUID } from 'uuidjs';
@@ -1,5 +1,5 @@
import styles from './LLMCard.module.css'; import styles from './LLMCard.module.css';
import { LLMCardVO } from '@/app/home/models/component/llm-card/LLMCardVO'; import { LLMCardVO } from './LLMCardVO';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function AbilityBadges(abilities: string[]) { function AbilityBadges(abilities: string[]) {
@@ -1,6 +1,6 @@
import { ICreateLLMField } from '@/app/home/models/component/ICreateLLMField'; import { ICreateLLMField } from '../ICreateLLMField';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity'; import { IChooseRequesterEntity } from '../ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api'; import { LLMModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs'; import { UUID } from 'uuidjs';
+85
View File
@@ -688,4 +688,89 @@ export class BackendClient extends BaseHttpClient {
new_password: newPassword, new_password: newPassword,
}); });
} }
public getUserInfo(): Promise<{
user: string;
account_type: 'local' | 'space';
}> {
return this.get('/api/v1/user/info');
}
// ============ Space OAuth API (Redirect Flow) ============
public getSpaceAuthorizeUrl(
redirectUri: string,
state?: string,
): Promise<{
authorize_url: string;
}> {
const params: Record<string, string> = { redirect_uri: redirectUri };
if (state) {
params.state = state;
}
return this.get('/api/v1/user/space/authorize-url', params);
}
public exchangeSpaceOAuthCode(code: string): Promise<{
token: string;
user: string;
}> {
return this.post('/api/v1/user/space/callback', { code });
}
// ============ Space Models Sync API ============
public syncSpaceModels(spaceUrl?: string): Promise<{
created_llm: number;
updated_llm: number;
created_embedding: number;
updated_embedding: number;
skipped: number;
}> {
return this.post('/api/v1/space/models/sync', { space_url: spaceUrl });
}
public getSpaceModels(): Promise<{
llm_models: Array<{
uuid: string;
name: string;
description: string;
requester: string;
space_model_id: string;
source: string;
}>;
embedding_models: Array<{
uuid: string;
name: string;
description: string;
requester: string;
space_model_id: string;
source: string;
}>;
}> {
return this.get('/api/v1/space/models');
}
public deleteSpaceModels(): Promise<{
deleted_llm: number;
deleted_embedding: number;
}> {
return this.delete('/api/v1/space/models');
}
public getAvailableSpaceModels(spaceUrl?: string): Promise<{
models: Array<{
model_id: string;
display_name: { [key: string]: string };
description: { [key: string]: string };
category: string;
provider: string;
}>;
vendors: Array<{
id: number;
name: string;
}>;
total: number;
}> {
const params = spaceUrl ? { space_url: spaceUrl } : {};
return this.get('/api/v1/space/models/available', params);
}
} }
+90 -5
View File
@@ -20,10 +20,10 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Mail, Lock } from 'lucide-react'; import { Mail, Lock, Loader2 } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp'; import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -39,6 +39,7 @@ const formSchema = (t: (key: string) => string) =>
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [spaceLoading, setSpaceLoading] = useState(false);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({ const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)), resolver: zodResolver(formSchema(t)),
@@ -75,6 +76,7 @@ export default function Login() {
}) })
.catch(() => {}); .catch(() => {});
} }
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) { function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
handleLogin(values.email, values.password); handleLogin(values.email, values.password);
} }
@@ -93,6 +95,26 @@ export default function Login() {
}); });
} }
// Space OAuth redirect handler
const handleSpaceLoginClick = async () => {
setSpaceLoading(true);
try {
// Build the redirect URI to the OAuth callback page
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback`;
// Get the authorization URL from backend
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
// Redirect to Space authorization page
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceLoading(false);
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10"> <Card className="w-[375px] shadow-lg dark:shadow-white/10">
@@ -113,7 +135,66 @@ export default function Login() {
{t('common.continueToLogin')} {t('common.continueToLogin')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-6">
{/* Space Login - Recommended */}
<div className="space-y-3">
<Button
type="button"
className="w-full cursor-pointer"
onClick={handleSpaceLoginClick}
disabled={spaceLoading}
>
{spaceLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<svg
className="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{t('common.loginWithSpace')}
</Button>
<p className="text-xs text-center text-muted-foreground">
{t('common.spaceLoginRecommended')}
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
{/* Local Account Login */}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
@@ -168,8 +249,12 @@ export default function Login() {
)} )}
/> />
<Button type="submit" className="w-full mt-4 cursor-pointer"> <Button
{t('common.login')} type="submit"
variant="outline"
className="w-full cursor-pointer"
>
{t('common.loginLocal')}
</Button> </Button>
</form> </form>
</Form> </Form>
+89 -5
View File
@@ -20,10 +20,10 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Mail, Lock } from 'lucide-react'; import { Mail, Lock, Loader2 } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp'; import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -38,6 +38,7 @@ const formSchema = (t: (key: string) => string) =>
export default function Register() { export default function Register() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [spaceLoading, setSpaceLoading] = useState(false);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({ const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)), resolver: zodResolver(formSchema(t)),
@@ -78,6 +79,26 @@ export default function Register() {
}); });
} }
// Space OAuth redirect handler
const handleSpaceLoginClick = async () => {
setSpaceLoading(true);
try {
// Build the redirect URI to the OAuth callback page
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback`;
// Get the authorization URL from backend
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
// Redirect to Space authorization page
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceLoading(false);
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10"> <Card className="w-[375px] shadow-lg dark:shadow-white/10">
@@ -100,7 +121,66 @@ export default function Register() {
{t('register.adminAccountNote')} {t('register.adminAccountNote')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-6">
{/* Space Login - Recommended */}
<div className="space-y-3">
<Button
type="button"
className="w-full cursor-pointer"
onClick={handleSpaceLoginClick}
disabled={spaceLoading}
>
{spaceLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<svg
className="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{t('register.initWithSpace')}
</Button>
<p className="text-xs text-center text-muted-foreground">
{t('register.spaceRecommended')}
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
{/* Local Account Registration */}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
@@ -146,8 +226,12 @@ export default function Register() {
)} )}
/> />
<Button type="submit" className="w-full mt-4 cursor-pointer"> <Button
{t('register.register')} type="submit"
variant="outline"
className="w-full cursor-pointer"
>
{t('register.registerLocal')}
</Button> </Button>
</form> </form>
</Form> </Form>
+37
View File
@@ -47,6 +47,30 @@ const enUS = {
test: 'Test', test: 'Test',
forgotPassword: 'Forgot Password?', forgotPassword: 'Forgot Password?',
loading: 'Loading...', loading: 'Loading...',
or: 'or',
loginWithSpace: 'Login with Space',
spaceLoginRecommended: 'Recommended: Sync models and credits from Space',
loginLocal: 'Login with local account',
spaceLoginTitle: 'Login with Space',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
spaceLoginUserCode: 'Your code',
spaceLoginExpires: 'Code expires in {{seconds}} seconds',
spaceLoginWaiting: 'Waiting for authorization...',
spaceLoginSuccess: 'Authorization successful',
spaceLoginFailed: 'Space login failed',
spaceLoginExpired: 'Authorization code expired, please try again',
spaceLoginCancel: 'Cancel',
spaceLoginVisitLink: 'Visit link',
spaceLoginProcessing: 'Logging in with Space',
spaceLoginProcessingDescription:
'Please wait while we complete your login...',
spaceLoginSuccessDescription: 'Redirecting to LangBot...',
spaceLoginError: 'Login Failed',
spaceLoginNoCode: 'Missing authorization code',
backToLogin: 'Back to Login',
spaceAccountCannotChangePassword:
'Space accounts cannot change password here',
theme: 'Theme', theme: 'Theme',
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
@@ -152,6 +176,16 @@ const enUS = {
testSuccess: 'Test successful', testSuccess: 'Test successful',
testError: 'Test failed, please check your model configuration', testError: 'Test failed, please check your model configuration',
llmModels: 'LLM Models', llmModels: 'LLM Models',
localProvider: 'Local',
localProviderDescription: 'Models configured and managed locally',
spaceProviderDescription: 'Models synced from your Space account',
spaceDisabledForLocalAccount: 'Login with Space to use cloud models',
syncModels: 'Sync',
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
syncError: 'Sync failed: ',
spaceModelReadOnly: 'Space models are read-only',
noSpaceModels: 'No Space models. Click Sync to fetch models from Space.',
noLocalModels: 'No local models. Click Create to add a model.',
}, },
bots: { bots: {
title: 'Bots', title: 'Bots',
@@ -640,6 +674,9 @@ const enUS = {
adminAccountNote: adminAccountNote:
'The email and password you fill in will be used as the initial administrator account', 'The email and password you fill in will be used as the initial administrator account',
register: 'Register', register: 'Register',
initWithSpace: 'Initialize with Space',
spaceRecommended: 'Recommended: Sync models and credits from Space',
registerLocal: 'Register local account',
initSuccess: 'Initialization successful, please login', initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ', initFailed: 'Initialization failed: ',
}, },
+41
View File
@@ -48,6 +48,31 @@ const jaJP = {
test: 'テスト', test: 'テスト',
forgotPassword: 'パスワードを忘れた?', forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...', loading: '読み込み中...',
or: 'または',
loginWithSpace: 'Space でログイン',
spaceLoginRecommended: 'おすすめ:Space からモデルとクレジットを同期',
loginLocal: 'ローカルアカウントでログイン',
spaceLoginTitle: 'Space でログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
spaceLoginUserCode: '認証コード',
spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',
spaceLoginWaiting: '認証を待っています...',
spaceLoginSuccess: '認証に成功しました',
spaceLoginFailed: 'Space ログインに失敗しました',
spaceLoginExpired:
'認証コードの有効期限が切れました。もう一度お試しください',
spaceLoginCancel: 'キャンセル',
spaceLoginVisitLink: 'リンクにアクセス',
spaceLoginProcessing: 'Space でログイン中',
spaceLoginProcessingDescription:
'ログインを完了しています。しばらくお待ちください...',
spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',
spaceLoginError: 'ログインに失敗しました',
spaceLoginNoCode: '認証コードがありません',
backToLogin: 'ログインに戻る',
spaceAccountCannotChangePassword:
'Space アカウントはここでパスワードを変更できません',
theme: 'テーマ', theme: 'テーマ',
changePassword: 'パスワードを変更', changePassword: 'パスワードを変更',
currentPassword: '現在のパスワード', currentPassword: '現在のパスワード',
@@ -154,6 +179,19 @@ const jaJP = {
selectModel: 'モデルを選択してください', selectModel: 'モデルを選択してください',
testSuccess: 'テストに成功しました', testSuccess: 'テストに成功しました',
testError: 'テストに失敗しました。モデル設定を確認してください', testError: 'テストに失敗しました。モデル設定を確認してください',
llmModels: 'LLM モデル',
localProvider: 'ローカル',
localProviderDescription: 'ローカルで設定・管理されているモデル',
spaceProviderDescription: 'Space アカウントから同期されたモデル',
spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用',
syncModels: '同期',
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
syncError: '同期に失敗しました:',
spaceModelReadOnly: 'Space モデルは読み取り専用です',
noSpaceModels:
'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',
noLocalModels:
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
}, },
bots: { bots: {
title: 'ボット', title: 'ボット',
@@ -645,6 +683,9 @@ const jaJP = {
adminAccountNote: adminAccountNote:
'入力したメールアドレスとパスワードが初期管理者アカウントになります', '入力したメールアドレスとパスワードが初期管理者アカウントになります',
register: '登録', register: '登録',
initWithSpace: 'Space で初期化',
spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期',
registerLocal: 'ローカルアカウントを登録',
initSuccess: '初期化に成功しました。ログインしてください', initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:', initFailed: '初期化に失敗しました:',
}, },
+34
View File
@@ -47,6 +47,27 @@ const zhHans = {
test: '测试', test: '测试',
forgotPassword: '忘记密码?', forgotPassword: '忘记密码?',
loading: '加载中...', loading: '加载中...',
or: '或',
loginWithSpace: '通过 Space 登录',
spaceLoginRecommended: '推荐:从 Space 同步模型和点数',
loginLocal: '使用本地账号登录',
spaceLoginTitle: '通过 Space 登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期',
spaceLoginWaiting: '等待授权中...',
spaceLoginSuccess: '授权成功',
spaceLoginFailed: 'Space 登录失败',
spaceLoginExpired: '验证码已过期,请重试',
spaceLoginCancel: '取消',
spaceLoginVisitLink: '访问链接',
spaceLoginProcessing: '正在通过 Space 登录',
spaceLoginProcessingDescription: '请稍候,正在完成登录...',
spaceLoginSuccessDescription: '正在跳转到 LangBot...',
spaceLoginError: '登录失败',
spaceLoginNoCode: '缺少授权码',
backToLogin: '返回登录',
spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码',
theme: '主题', theme: '主题',
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
@@ -149,6 +170,16 @@ const zhHans = {
testSuccess: '测试成功', testSuccess: '测试成功',
testError: '测试失败,请检查模型配置', testError: '测试失败,请检查模型配置',
llmModels: '对话模型', llmModels: '对话模型',
localProvider: '本地',
localProviderDescription: '在本地配置和管理的模型',
spaceProviderDescription: '从您的 Space 账户同步的模型',
spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型',
syncModels: '同步',
syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个',
syncError: '同步失败:',
spaceModelReadOnly: 'Space 模型为只读',
noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',
noLocalModels: '暂无本地模型。点击创建按钮添加模型。',
}, },
bots: { bots: {
title: '机器人', title: '机器人',
@@ -616,6 +647,9 @@ const zhHans = {
description: '这是您首次启动 LangBot', description: '这是您首次启动 LangBot',
adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号', adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号',
register: '注册', register: '注册',
initWithSpace: '通过 Space 初始化',
spaceRecommended: '推荐:从 Space 同步模型和点数',
registerLocal: '注册本地账号',
initSuccess: '初始化成功 请登录', initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:', initFailed: '初始化失败:',
}, },
+34
View File
@@ -47,6 +47,27 @@ const zhHant = {
test: '測試', test: '測試',
forgotPassword: '忘記密碼?', forgotPassword: '忘記密碼?',
loading: '載入中...', loading: '載入中...',
or: '或',
loginWithSpace: '透過 Space 登入',
spaceLoginRecommended: '推薦:從 Space 同步模型和點數',
loginLocal: '使用本地帳號登入',
spaceLoginTitle: '透過 Space 登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼',
spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期',
spaceLoginWaiting: '等待授權中...',
spaceLoginSuccess: '授權成功',
spaceLoginFailed: 'Space 登入失敗',
spaceLoginExpired: '驗證碼已過期,請重試',
spaceLoginCancel: '取消',
spaceLoginVisitLink: '訪問連結',
spaceLoginProcessing: '正在透過 Space 登入',
spaceLoginProcessingDescription: '請稍候,正在完成登入...',
spaceLoginSuccessDescription: '正在跳轉到 LangBot...',
spaceLoginError: '登入失敗',
spaceLoginNoCode: '缺少授權碼',
backToLogin: '返回登入',
spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼',
theme: '主題', theme: '主題',
changePassword: '修改密碼', changePassword: '修改密碼',
currentPassword: '當前密碼', currentPassword: '當前密碼',
@@ -149,6 +170,16 @@ const zhHant = {
testSuccess: '測試成功', testSuccess: '測試成功',
testError: '測試失敗,請檢查模型設定', testError: '測試失敗,請檢查模型設定',
llmModels: '對話模型', llmModels: '對話模型',
localProvider: '本地',
localProviderDescription: '在本地設定和管理的模型',
spaceProviderDescription: '從您的 Space 帳戶同步的模型',
spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型',
syncModels: '同步',
syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個',
syncError: '同步失敗:',
spaceModelReadOnly: 'Space 模型為唯讀',
noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
}, },
bots: { bots: {
title: '機器人', title: '機器人',
@@ -614,6 +645,9 @@ const zhHant = {
description: '這是您首次啟動 LangBot', description: '這是您首次啟動 LangBot',
adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號', adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號',
register: '註冊', register: '註冊',
initWithSpace: '透過 Space 初始化',
spaceRecommended: '推薦:從 Space 同步模型和點數',
registerLocal: '註冊本地帳號',
initSuccess: '初始化成功 請登入', initSuccess: '初始化成功 請登入',
initFailed: '初始化失敗:', initFailed: '初始化失敗:',
}, },