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

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>
);
}

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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';

View File

@@ -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[]) {

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: ',
},

View File

@@ -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: '初期化に失敗しました:',
},

View File

@@ -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: '初始化失败:',
},

View File

@@ -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: '初始化失敗:',
},