mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: add Space integration for user authentication and model management with OAuth support
This commit is contained in:
52
src/langbot/pkg/api/http/controller/groups/space.py
Normal file
52
src/langbot/pkg/api/http/controller/groups/space.py
Normal file
@@ -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'])
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.fail(1, 'Invalid username or password')
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
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)
|
||||
async def _(user_email: str) -> str:
|
||||
# Check if password change is allowed
|
||||
allow_change_password = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_change_password', True
|
||||
)
|
||||
allow_change_password = self.ap.instance_config.data.get('system', {}).get('allow_change_password', True)
|
||||
if not allow_change_password:
|
||||
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.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,
|
||||
}
|
||||
)
|
||||
|
||||
247
src/langbot/pkg/api/http/service/space_models.py
Normal file
247
src/langbot/pkg/api/http/service/space_models.py
Normal file
@@ -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}
|
||||
@@ -4,6 +4,8 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import aiohttp
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
@@ -16,6 +18,15 @@ class UserService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
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:
|
||||
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)
|
||||
|
||||
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:
|
||||
@@ -39,6 +50,15 @@ class UserService:
|
||||
result_list = result.all()
|
||||
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:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
@@ -51,6 +71,10 @@ class UserService:
|
||||
|
||||
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.verify(user_obj.password, password)
|
||||
@@ -90,6 +114,10 @@ class UserService:
|
||||
if user_obj is None:
|
||||
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)
|
||||
|
||||
hashed_password = ph.hash(new_password)
|
||||
@@ -97,3 +125,139 @@ class UserService:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
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
|
||||
|
||||
@@ -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 webhook as webhook_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 ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -132,6 +133,8 @@ class Application:
|
||||
|
||||
webhook_service: webhook_service.WebhookService = None
|
||||
|
||||
space_models_service: space_models_service.SpaceModelsService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -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 webhook as webhook_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 ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -135,6 +136,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
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:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
@@ -16,6 +16,10 @@ class LLMModel(Base):
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
abilities = 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())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
@@ -37,6 +41,10 @@ class EmbeddingModel(Base):
|
||||
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
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())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -9,6 +9,16 @@ class User(Base):
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
|
||||
user = 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())
|
||||
updated_at = sqlalchemy.Column(
|
||||
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
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
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"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -71,3 +71,10 @@ plugin:
|
||||
enable_marketplace: true
|
||||
cloud_service_url: 'https://space.langbot.app'
|
||||
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'
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"next": "~15.5.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.56.3",
|
||||
@@ -82,6 +83,7 @@
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.7",
|
||||
"@types/react-dom": "~19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
||||
9530
web/pnpm-lock.yaml
generated
9530
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
122
web/src/app/auth/space/callback/page.tsx
Normal file
122
web/src/app/auth/space/callback/page.tsx
Normal 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';
|
||||
|
||||
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 LLMCard from './component/llm-card/LLMCard';
|
||||
import LLMForm from './component/llm-form/LLMForm';
|
||||
@@ -21,80 +31,224 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { EmbeddingCardVO } from './component/embedding-card/EmbeddingCardVO';
|
||||
import EmbeddingCard from './component/embedding-card/EmbeddingCard';
|
||||
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 {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'providers' | 'space' | 'local';
|
||||
|
||||
export default function ModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ModelsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('providers');
|
||||
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 [isEditForm, setIsEditForm] = useState(false);
|
||||
const [nowSelectedLLM, setNowSelectedLLM] = useState<LLMCardVO | null>(null);
|
||||
const [embeddingCardList, setEmbeddingCardList] = useState<EmbeddingCardVO[]>(
|
||||
[],
|
||||
);
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState<boolean>(false);
|
||||
const [isEditEmbeddingForm, setIsEditEmbeddingForm] = useState(false);
|
||||
const [nowSelectedEmbedding, setNowSelectedEmbedding] =
|
||||
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(() => {
|
||||
if (open) {
|
||||
getLLMModelList();
|
||||
getEmbeddingModelList();
|
||||
loadUserInfo();
|
||||
loadRequesterLists();
|
||||
loadAllModels();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function getLLMModelList() {
|
||||
const requesterNameListResp = await httpClient.getProviderRequesters('llm');
|
||||
const requesterNameList = requesterNameListResp.requesters.map((item) => {
|
||||
return {
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
};
|
||||
});
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
} catch {
|
||||
// Default to local if user info cannot be fetched
|
||||
setAccountType('local');
|
||||
}
|
||||
}
|
||||
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
|
||||
return new LLMCardVO({
|
||||
async function loadRequesterLists() {
|
||||
try {
|
||||
const llmRequesters = await httpClient.getProviderRequesters('llm');
|
||||
setLLMRequesterNameList(
|
||||
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,
|
||||
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
|
||||
name: model.name,
|
||||
providerLabel:
|
||||
requesterNameList.find((item) => item.value === model.requester)
|
||||
?.label || model.requester.substring(0, 10),
|
||||
baseURL: model.requester_config?.base_url,
|
||||
abilities: model.abilities || [],
|
||||
embeddingRequesterNameList.find(
|
||||
(item) => item.value === model.requester,
|
||||
)?.label || model.requester.substring(0, 10),
|
||||
baseURL: model.requester_config?.base_url || '',
|
||||
});
|
||||
});
|
||||
setCardList(llmModelList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('get LLM model list error', err);
|
||||
toast.error(t('models.getModelListError') + err.message);
|
||||
});
|
||||
|
||||
if (model.source === 'space') {
|
||||
spaceModels.push(cardVO);
|
||||
} else {
|
||||
localModels.push(cardVO);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
setNowSelectedLLM(cardVO);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleCreateModelClick() {
|
||||
setIsEditForm(false);
|
||||
setNowSelectedLLM(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
function selectEmbedding(cardVO: EmbeddingCardVO) {
|
||||
|
||||
function selectEmbedding(cardVO: EmbeddingCardVO, isSpaceModel: boolean) {
|
||||
if (isSpaceModel) {
|
||||
toast.info(t('models.spaceModelReadOnly'));
|
||||
return;
|
||||
}
|
||||
setIsEditEmbeddingForm(true);
|
||||
setNowSelectedEmbedding(cardVO);
|
||||
setEmbeddingModalOpen(true);
|
||||
@@ -105,80 +259,113 @@ export default function ModelsDialog({
|
||||
setNowSelectedEmbedding(null);
|
||||
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
|
||||
.getProviderEmbeddingModels()
|
||||
.then((resp) => {
|
||||
const embeddingModelList: EmbeddingCardVO[] = resp.models.map(
|
||||
(model: {
|
||||
uuid: string;
|
||||
requester: string;
|
||||
name: string;
|
||||
requester_config?: { base_url?: string };
|
||||
}) => {
|
||||
return new EmbeddingCardVO({
|
||||
id: model.uuid,
|
||||
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
|
||||
name: model.name,
|
||||
providerLabel:
|
||||
requesterNameList.find((item) => item.value === model.requester)
|
||||
?.label || model.requester.substring(0, 10),
|
||||
baseURL: model.requester_config?.base_url || '',
|
||||
});
|
||||
},
|
||||
);
|
||||
setEmbeddingCardList(embeddingModelList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('get Embedding model list error', err);
|
||||
toast.error(t('embedding.getModelListError') + err.message);
|
||||
});
|
||||
function renderProviderCards() {
|
||||
const isSpaceDisabled = accountType === 'local';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
|
||||
{/* Space Provider Card */}
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-lg ${
|
||||
isSpaceDisabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => !isSpaceDisabled && setViewMode('space')}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Cloud className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>Space</CardTitle>
|
||||
{isSpaceDisabled && (
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{isSpaceDisabled
|
||||
? 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 (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && (modalOpen || embeddingModalOpen)) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
function renderModelList(
|
||||
llmList: LLMCardVO[],
|
||||
embeddingList: EmbeddingCardVO[],
|
||||
isSpaceModel: boolean = false,
|
||||
) {
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
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">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-row justify-between items-center mb-2">
|
||||
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger value="llm" className="px-6 py-4 cursor-pointer">
|
||||
<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
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full flex-1 flex flex-col overflow-hidden px-6 pb-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center mb-2 mt-4">
|
||||
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger value="llm" className="px-6 py-4 cursor-pointer">
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
{isSpaceModel ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSyncSpaceModels}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-1 ${isSyncing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('models.syncModels')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={
|
||||
@@ -192,58 +379,118 @@ export default function ModelsDialog({
|
||||
? t('models.createModel')
|
||||
: t('embedding.createModel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center">
|
||||
<Info className="h-4 w-4 mr-1.5 text-muted-foreground" />
|
||||
{activeTab === 'llm' ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center">
|
||||
{t('llm.description')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground flex items-center">
|
||||
{t('embedding.description')}
|
||||
</p>
|
||||
<div className="mb-3 flex items-center">
|
||||
<Info className="h-4 w-4 mr-1.5 text-muted-foreground" />
|
||||
{activeTab === 'llm' ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center">
|
||||
{t('llm.description')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground flex items-center">
|
||||
{t('embedding.description')}
|
||||
</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>
|
||||
</DialogHeader>
|
||||
|
||||
<TabsContent value="llm" className="flex-1 overflow-auto mt-0">
|
||||
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
|
||||
{cardList.map((cardVO) => {
|
||||
return (
|
||||
<div
|
||||
key={cardVO.id}
|
||||
onClick={() => {
|
||||
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>
|
||||
<div className="flex-1 overflow-auto px-6 pb-6 mt-4">
|
||||
{viewMode === 'providers' && renderProviderCards()}
|
||||
{viewMode === 'space' &&
|
||||
renderModelList(spaceLLMList, spaceEmbeddingList, true)}
|
||||
{viewMode === 'local' &&
|
||||
renderModelList(localLLMList, localEmbeddingList, false)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -259,14 +506,14 @@ export default function ModelsDialog({
|
||||
initLLMId={nowSelectedLLM?.id}
|
||||
onFormSubmit={() => {
|
||||
setModalOpen(false);
|
||||
getLLMModelList();
|
||||
loadAllModels();
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
onLLMDeleted={() => {
|
||||
setModalOpen(false);
|
||||
getLLMModelList();
|
||||
loadAllModels();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -286,14 +533,14 @@ export default function ModelsDialog({
|
||||
initEmbeddingId={nowSelectedEmbedding?.id}
|
||||
onFormSubmit={() => {
|
||||
setEmbeddingModalOpen(false);
|
||||
getEmbeddingModelList();
|
||||
loadAllModels();
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setEmbeddingModalOpen(false);
|
||||
}}
|
||||
onEmbeddingDeleted={() => {
|
||||
setEmbeddingModalOpen(false);
|
||||
getEmbeddingModelList();
|
||||
loadAllModels();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 }) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ICreateEmbeddingField } from '@/app/home/models/component/ICreateEmbeddingField';
|
||||
import { ICreateEmbeddingField } from '../ICreateEmbeddingField';
|
||||
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 { EmbeddingModel } from '@/app/infra/entities/api';
|
||||
import { UUID } from 'uuidjs';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
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 { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity';
|
||||
import { IChooseRequesterEntity } from '../ChooseRequesterEntity';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { LLMModel } from '@/app/infra/entities/api';
|
||||
import { UUID } from 'uuidjs';
|
||||
|
||||
@@ -688,4 +688,89 @@ export class BackendClient extends BaseHttpClient {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
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 { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -39,6 +39,7 @@ const formSchema = (t: (key: string) => string) =>
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -75,6 +76,7 @@ export default function Login() {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
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 (
|
||||
<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">
|
||||
@@ -113,7 +135,66 @@ export default function Login() {
|
||||
{t('common.continueToLogin')}
|
||||
</CardDescription>
|
||||
</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 onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
@@ -168,8 +249,12 @@ export default function Login() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full mt-4 cursor-pointer">
|
||||
{t('common.login')}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('common.loginLocal')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
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 { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -38,6 +38,7 @@ const formSchema = (t: (key: string) => string) =>
|
||||
export default function Register() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
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 (
|
||||
<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">
|
||||
@@ -100,7 +121,66 @@ export default function Register() {
|
||||
{t('register.adminAccountNote')}
|
||||
</CardDescription>
|
||||
</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 onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
@@ -146,8 +226,12 @@ export default function Register() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full mt-4 cursor-pointer">
|
||||
{t('register.register')}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerLocal')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -47,6 +47,30 @@ const enUS = {
|
||||
test: 'Test',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
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',
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
@@ -152,6 +176,16 @@ const enUS = {
|
||||
testSuccess: 'Test successful',
|
||||
testError: 'Test failed, please check your model configuration',
|
||||
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: {
|
||||
title: 'Bots',
|
||||
@@ -640,6 +674,9 @@ const enUS = {
|
||||
adminAccountNote:
|
||||
'The email and password you fill in will be used as the initial administrator account',
|
||||
register: 'Register',
|
||||
initWithSpace: 'Initialize with Space',
|
||||
spaceRecommended: 'Recommended: Sync models and credits from Space',
|
||||
registerLocal: 'Register local account',
|
||||
initSuccess: 'Initialization successful, please login',
|
||||
initFailed: 'Initialization failed: ',
|
||||
},
|
||||
|
||||
@@ -48,6 +48,31 @@ const jaJP = {
|
||||
test: 'テスト',
|
||||
forgotPassword: 'パスワードを忘れた?',
|
||||
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: 'テーマ',
|
||||
changePassword: 'パスワードを変更',
|
||||
currentPassword: '現在のパスワード',
|
||||
@@ -154,6 +179,19 @@ const jaJP = {
|
||||
selectModel: 'モデルを選択してください',
|
||||
testSuccess: 'テストに成功しました',
|
||||
testError: 'テストに失敗しました。モデル設定を確認してください',
|
||||
llmModels: 'LLM モデル',
|
||||
localProvider: 'ローカル',
|
||||
localProviderDescription: 'ローカルで設定・管理されているモデル',
|
||||
spaceProviderDescription: 'Space アカウントから同期されたモデル',
|
||||
spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用',
|
||||
syncModels: '同期',
|
||||
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
|
||||
syncError: '同期に失敗しました:',
|
||||
spaceModelReadOnly: 'Space モデルは読み取り専用です',
|
||||
noSpaceModels:
|
||||
'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',
|
||||
noLocalModels:
|
||||
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
|
||||
},
|
||||
bots: {
|
||||
title: 'ボット',
|
||||
@@ -645,6 +683,9 @@ const jaJP = {
|
||||
adminAccountNote:
|
||||
'入力したメールアドレスとパスワードが初期管理者アカウントになります',
|
||||
register: '登録',
|
||||
initWithSpace: 'Space で初期化',
|
||||
spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期',
|
||||
registerLocal: 'ローカルアカウントを登録',
|
||||
initSuccess: '初期化に成功しました。ログインしてください',
|
||||
initFailed: '初期化に失敗しました:',
|
||||
},
|
||||
|
||||
@@ -47,6 +47,27 @@ const zhHans = {
|
||||
test: '测试',
|
||||
forgotPassword: '忘记密码?',
|
||||
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: '主题',
|
||||
changePassword: '修改密码',
|
||||
currentPassword: '当前密码',
|
||||
@@ -149,6 +170,16 @@ const zhHans = {
|
||||
testSuccess: '测试成功',
|
||||
testError: '测试失败,请检查模型配置',
|
||||
llmModels: '对话模型',
|
||||
localProvider: '本地',
|
||||
localProviderDescription: '在本地配置和管理的模型',
|
||||
spaceProviderDescription: '从您的 Space 账户同步的模型',
|
||||
spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型',
|
||||
syncModels: '同步',
|
||||
syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个',
|
||||
syncError: '同步失败:',
|
||||
spaceModelReadOnly: 'Space 模型为只读',
|
||||
noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',
|
||||
noLocalModels: '暂无本地模型。点击创建按钮添加模型。',
|
||||
},
|
||||
bots: {
|
||||
title: '机器人',
|
||||
@@ -616,6 +647,9 @@ const zhHans = {
|
||||
description: '这是您首次启动 LangBot',
|
||||
adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号',
|
||||
register: '注册',
|
||||
initWithSpace: '通过 Space 初始化',
|
||||
spaceRecommended: '推荐:从 Space 同步模型和点数',
|
||||
registerLocal: '注册本地账号',
|
||||
initSuccess: '初始化成功 请登录',
|
||||
initFailed: '初始化失败:',
|
||||
},
|
||||
|
||||
@@ -47,6 +47,27 @@ const zhHant = {
|
||||
test: '測試',
|
||||
forgotPassword: '忘記密碼?',
|
||||
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: '主題',
|
||||
changePassword: '修改密碼',
|
||||
currentPassword: '當前密碼',
|
||||
@@ -149,6 +170,16 @@ const zhHant = {
|
||||
testSuccess: '測試成功',
|
||||
testError: '測試失敗,請檢查模型設定',
|
||||
llmModels: '對話模型',
|
||||
localProvider: '本地',
|
||||
localProviderDescription: '在本地設定和管理的模型',
|
||||
spaceProviderDescription: '從您的 Space 帳戶同步的模型',
|
||||
spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型',
|
||||
syncModels: '同步',
|
||||
syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個',
|
||||
syncError: '同步失敗:',
|
||||
spaceModelReadOnly: 'Space 模型為唯讀',
|
||||
noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',
|
||||
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
@@ -614,6 +645,9 @@ const zhHant = {
|
||||
description: '這是您首次啟動 LangBot',
|
||||
adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號',
|
||||
register: '註冊',
|
||||
initWithSpace: '透過 Space 初始化',
|
||||
spaceRecommended: '推薦:從 Space 同步模型和點數',
|
||||
registerLocal: '註冊本地帳號',
|
||||
initSuccess: '初始化成功 請登入',
|
||||
initFailed: '初始化失敗:',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user