Phase 1 complete

This commit is contained in:
GW_MC
2026-02-24 10:34:55 +00:00
parent 630b60d7e2
commit 8714d6bd22
112 changed files with 11063 additions and 73 deletions

View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.20.0"
}
}

View File

@@ -0,0 +1,24 @@
module.exports = {
dreamchat: {
input: {
target: '../openapi/openapi.json',
},
output: {
mode: 'tags-split',
target: './src/api/generated',
schemas: './src/api/generated/model',
client: 'fetch',
baseUrl: 'http://localhost:3000',
mock: false,
override: {
fetch: {
includeHttpResponseReturnType: false,
},
mutator: {
path: './src/api/mutator/custom-fetch.ts',
name: 'customFetch',
},
},
},
},
};

View File

@@ -8,7 +8,8 @@
"preview": "vite preview",
"test": "vitest",
"lint": "eslint . --ext ts,tsx",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"api:generate": "orval"
},
"dependencies": {
"@dreamchat/shared": "workspace:*",
@@ -20,10 +21,13 @@
"zustand": "^4.5.0"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.29.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.0",
"openapi-typescript": "^7.13.0",
"orval": "^8.4.2",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",

View File

@@ -1,13 +1,132 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { Login } from './pages/Login';
import { CharacterList } from './pages/CharacterList';
import { CharacterForm } from './pages/CharacterForm';
import { ConversationList } from './pages/ConversationList';
import { Chat } from './pages/Chat';
// OAuth Callback Handler - processes tokens from URL before routing
function OAuthCallbackHandler({ children }: { children: React.ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { setTokens } = useAuthStore();
useEffect(() => {
const accessToken = searchParams.get('accessToken');
const refreshToken = searchParams.get('refreshToken');
const errorMsg = searchParams.get('error');
if (errorMsg) {
// Redirect to login with error
const decodedError = decodeURIComponent(errorMsg);
setSearchParams({}, { replace: true });
navigate(`/login?error=${encodeURIComponent(decodedError)}`, { replace: true });
return;
}
if (accessToken && refreshToken) {
// Store tokens
setTokens(accessToken, refreshToken);
// Clear tokens from URL but keep the path
setSearchParams({}, { replace: true });
}
}, [searchParams, setSearchParams, navigate, setTokens]);
return <>{children}</>;
}
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
return !isAuthenticated ? <>{children}</> : <Navigate to="/characters" replace />;
}
function App() {
const { init } = useAuthStore();
useEffect(() => {
init();
}, [init]);
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-primary mb-4">DreamChat</h1>
<p className="text-muted-foreground">
Character simulation and interactive storytelling platform
</p>
</div>
</div>
<BrowserRouter>
<OAuthCallbackHandler>
<Routes>
<Route path="/" element={<Navigate to="/characters" replace />} />
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path="/characters"
element={
<PrivateRoute>
<CharacterList />
</PrivateRoute>
}
/>
<Route
path="/characters/new"
element={
<PrivateRoute>
<CharacterForm />
</PrivateRoute>
}
/>
<Route
path="/characters/:id"
element={
<PrivateRoute>
<CharacterForm />
</PrivateRoute>
}
/>
<Route
path="/conversations"
element={
<PrivateRoute>
<ConversationList />
</PrivateRoute>
}
/>
<Route
path="/conversations/:conversationId"
element={
<PrivateRoute>
<Chat />
</PrivateRoute>
}
/>
<Route
path="/chat/:characterId"
element={
<PrivateRoute>
<Chat />
</PrivateRoute>
}
/>
</Routes>
</OAuthCallbackHandler>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,173 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type {
AuthControllerKeycloakCallbackParams,
AuthControllerKeycloakLoginParams,
AuthResponseDto,
KeycloakConfigDto,
KeycloakLoginUrlDto,
LoginDto,
RefreshTokenDto
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
/**
* @summary Login with email and password
*/
export const getAuthControllerLoginUrl = () => {
return `http://localhost:3000/api/auth/login`
}
export const authControllerLogin = async (loginDto: LoginDto, options?: RequestInit): Promise<AuthResponseDto> => {
return customFetch<AuthResponseDto>(getAuthControllerLoginUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
loginDto,)
}
);}
/**
* @summary Refresh access token
*/
export const getAuthControllerRefreshTokensUrl = () => {
return `http://localhost:3000/api/auth/refresh`
}
export const authControllerRefreshTokens = async (refreshTokenDto: RefreshTokenDto, options?: RequestInit): Promise<AuthResponseDto> => {
return customFetch<AuthResponseDto>(getAuthControllerRefreshTokensUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
refreshTokenDto,)
}
);}
/**
* @summary Get Keycloak configuration for frontend
*/
export const getAuthControllerGetKeycloakConfigUrl = () => {
return `http://localhost:3000/api/auth/keycloak/config`
}
export const authControllerGetKeycloakConfig = async ( options?: RequestInit): Promise<KeycloakConfigDto> => {
return customFetch<KeycloakConfigDto>(getAuthControllerGetKeycloakConfigUrl(),
{
...options,
method: 'GET'
}
);}
/**
* @summary Get Keycloak login URL (initiates OAuth flow)
*/
export const getAuthControllerKeycloakLoginUrl = (params?: AuthControllerKeycloakLoginParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `http://localhost:3000/api/auth/keycloak/login?${stringifiedParams}` : `http://localhost:3000/api/auth/keycloak/login`
}
export const authControllerKeycloakLogin = async (params?: AuthControllerKeycloakLoginParams, options?: RequestInit): Promise<KeycloakLoginUrlDto> => {
return customFetch<KeycloakLoginUrlDto>(getAuthControllerKeycloakLoginUrl(params),
{
...options,
method: 'GET'
}
);}
/**
* @summary Keycloak OAuth callback endpoint
*/
export const getAuthControllerKeycloakCallbackUrl = (params: AuthControllerKeycloakCallbackParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `http://localhost:3000/api/auth/keycloak/callback?${stringifiedParams}` : `http://localhost:3000/api/auth/keycloak/callback`
}
export const authControllerKeycloakCallback = async (params: AuthControllerKeycloakCallbackParams, options?: RequestInit): Promise<unknown> => {
return customFetch<unknown>(getAuthControllerKeycloakCallbackUrl(params),
{
...options,
method: 'GET'
}
);}
/**
* @summary Login with Keycloak bearer token (Authorization: Bearer <keycloak-jwt>)
*/
export const getAuthControllerKeycloakBearerLoginUrl = () => {
return `http://localhost:3000/api/auth/keycloak`
}
export const authControllerKeycloakBearerLogin = async ( options?: RequestInit): Promise<AuthResponseDto> => {
return customFetch<AuthResponseDto>(getAuthControllerKeycloakBearerLoginUrl(),
{
...options,
method: 'POST'
}
);}

View File

@@ -0,0 +1,133 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type {
CharacterResponseDto,
CreateCharacterDto,
UpdateCharacterDto
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
/**
* @summary Create a new character
*/
export const getCharacterControllerCreateUrl = () => {
return `http://localhost:3000/api/characters`
}
export const characterControllerCreate = async (createCharacterDto: CreateCharacterDto, options?: RequestInit): Promise<CharacterResponseDto> => {
return customFetch<CharacterResponseDto>(getCharacterControllerCreateUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
createCharacterDto,)
}
);}
/**
* @summary Get all characters for current user
*/
export const getCharacterControllerFindAllUrl = () => {
return `http://localhost:3000/api/characters`
}
export const characterControllerFindAll = async ( options?: RequestInit): Promise<CharacterResponseDto[]> => {
return customFetch<CharacterResponseDto[]>(getCharacterControllerFindAllUrl(),
{
...options,
method: 'GET'
}
);}
/**
* @summary Get character by ID
*/
export const getCharacterControllerFindOneUrl = (id: string,) => {
return `http://localhost:3000/api/characters/${id}`
}
export const characterControllerFindOne = async (id: string, options?: RequestInit): Promise<CharacterResponseDto> => {
return customFetch<CharacterResponseDto>(getCharacterControllerFindOneUrl(id),
{
...options,
method: 'GET'
}
);}
/**
* @summary Update character
*/
export const getCharacterControllerUpdateUrl = (id: string,) => {
return `http://localhost:3000/api/characters/${id}`
}
export const characterControllerUpdate = async (id: string,
updateCharacterDto: UpdateCharacterDto, options?: RequestInit): Promise<CharacterResponseDto> => {
return customFetch<CharacterResponseDto>(getCharacterControllerUpdateUrl(id),
{
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
updateCharacterDto,)
}
);}
/**
* @summary Delete character
*/
export const getCharacterControllerDeleteUrl = (id: string,) => {
return `http://localhost:3000/api/characters/${id}`
}
export const characterControllerDelete = async (id: string, options?: RequestInit): Promise<void> => {
return customFetch<void>(getCharacterControllerDeleteUrl(id),
{
...options,
method: 'DELETE'
}
);}

View File

@@ -0,0 +1,135 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type {
ConversationResponseDto,
ConversationWithMessagesResponseDto,
CreateConversationDto,
SendMessageDto,
SendMessageResponseDto
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
/**
* @summary Create a new conversation
*/
export const getChatControllerCreateConversationUrl = () => {
return `http://localhost:3000/api/conversations`
}
export const chatControllerCreateConversation = async (createConversationDto: CreateConversationDto, options?: RequestInit): Promise<ConversationResponseDto> => {
return customFetch<ConversationResponseDto>(getChatControllerCreateConversationUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
createConversationDto,)
}
);}
/**
* @summary Get all conversations for current user
*/
export const getChatControllerGetConversationsUrl = () => {
return `http://localhost:3000/api/conversations`
}
export const chatControllerGetConversations = async ( options?: RequestInit): Promise<ConversationResponseDto[]> => {
return customFetch<ConversationResponseDto[]>(getChatControllerGetConversationsUrl(),
{
...options,
method: 'GET'
}
);}
/**
* @summary Get conversation by ID with messages
*/
export const getChatControllerGetConversationUrl = (id: string,) => {
return `http://localhost:3000/api/conversations/${id}`
}
export const chatControllerGetConversation = async (id: string, options?: RequestInit): Promise<ConversationWithMessagesResponseDto> => {
return customFetch<ConversationWithMessagesResponseDto>(getChatControllerGetConversationUrl(id),
{
...options,
method: 'GET'
}
);}
/**
* @summary Delete conversation
*/
export const getChatControllerDeleteConversationUrl = (id: string,) => {
return `http://localhost:3000/api/conversations/${id}`
}
export const chatControllerDeleteConversation = async (id: string, options?: RequestInit): Promise<void> => {
return customFetch<void>(getChatControllerDeleteConversationUrl(id),
{
...options,
method: 'DELETE'
}
);}
/**
* @summary Send a message in a conversation
*/
export const getChatControllerSendMessageUrl = (id: string,) => {
return `http://localhost:3000/api/conversations/${id}/messages`
}
export const chatControllerSendMessage = async (id: string,
sendMessageDto: SendMessageDto, options?: RequestInit): Promise<SendMessageResponseDto> => {
return customFetch<SendMessageResponseDto>(getChatControllerSendMessageUrl(id),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
sendMessageDto,)
}
);}

View File

@@ -0,0 +1,111 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type {
ImportControllerUploadFileBody
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
/**
* @summary Upload a file for character knowledge
*/
export const getImportControllerUploadFileUrl = (characterId: string,) => {
return `http://localhost:3000/api/import/characters/${characterId}/files`
}
export const importControllerUploadFile = async (characterId: string,
importControllerUploadFileBody: ImportControllerUploadFileBody, options?: RequestInit): Promise<void> => {
const formData = new FormData();
if(importControllerUploadFileBody.file !== undefined) {
formData.append(`file`, importControllerUploadFileBody.file);
}
return customFetch<void>(getImportControllerUploadFileUrl(characterId),
{
...options,
method: 'POST'
,
body:
formData,
}
);}
/**
* @summary Get knowledge processing status
*/
export const getImportControllerGetKnowledgeStatusUrl = (knowledgeId: string,) => {
return `http://localhost:3000/api/import/knowledge/${knowledgeId}/status`
}
export const importControllerGetKnowledgeStatus = async (knowledgeId: string, options?: RequestInit): Promise<void> => {
return customFetch<void>(getImportControllerGetKnowledgeStatusUrl(knowledgeId),
{
...options,
method: 'GET'
}
);}
/**
* @summary Get all knowledge for a character
*/
export const getImportControllerGetCharacterKnowledgeUrl = (characterId: string,) => {
return `http://localhost:3000/api/import/characters/${characterId}/knowledge`
}
export const importControllerGetCharacterKnowledge = async (characterId: string, options?: RequestInit): Promise<void> => {
return customFetch<void>(getImportControllerGetCharacterKnowledgeUrl(characterId),
{
...options,
method: 'GET'
}
);}
/**
* @summary Delete knowledge
*/
export const getImportControllerDeleteKnowledgeUrl = (knowledgeId: string,) => {
return `http://localhost:3000/api/import/knowledge/${knowledgeId}`
}
export const importControllerDeleteKnowledge = async (knowledgeId: string, options?: RequestInit): Promise<void> => {
return customFetch<void>(getImportControllerDeleteKnowledgeUrl(knowledgeId),
{
...options,
method: 'DELETE'
}
);}

View File

@@ -0,0 +1,26 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export type AuthControllerKeycloakCallbackParams = {
/**
* Authorization code from Keycloak
*/
code: string;
/**
* Error message if authentication failed
*/
error?: string;
/**
* Error description
*/
error_description?: string;
/**
* State parameter for CSRF validation
*/
state: string;
};

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export type AuthControllerKeycloakLoginParams = {
/**
* Frontend path to redirect after login
*/
redirectTo?: string;
};

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { UserDto } from './userDto';
export interface AuthResponseDto {
/** JWT access token */
accessToken: string;
/** JWT refresh token */
refreshToken: string;
/** User information */
user: UserDto;
}

View File

@@ -0,0 +1,32 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { CharacterResponseDtoAttributes } from './characterResponseDtoAttributes';
import type { CharacterResponseDtoConfig } from './characterResponseDtoConfig';
export interface CharacterResponseDto {
/** Character ID */
id: string;
/** Character name */
name: string;
/** Avatar URL */
avatarUrl?: string;
/** Personality prompt */
personalityPrompt: string;
/** Custom attributes */
attributes: CharacterResponseDtoAttributes;
/** Character configuration */
config: CharacterResponseDtoConfig;
/** Whether character is public */
isPublic: boolean;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** User ID */
userId: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Custom attributes
*/
export type CharacterResponseDtoAttributes = { [key: string]: unknown };

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Character configuration
*/
export type CharacterResponseDtoConfig = { [key: string]: unknown };

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface CharacterSummaryDto {
/** Character ID */
id: string;
/** Character name */
name: string;
/** Avatar URL */
avatarUrl?: string;
}

View File

@@ -0,0 +1,27 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { CharacterSummaryDto } from './characterSummaryDto';
export interface ConversationResponseDto {
/** Conversation ID */
id: string;
/** Conversation title */
title?: string;
/** Character ID */
characterId: string;
/** Number of messages */
messageCount: number;
/** Total tokens used */
totalTokens: number;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** Character info */
character?: CharacterSummaryDto;
}

View File

@@ -0,0 +1,30 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { CharacterSummaryDto } from './characterSummaryDto';
import type { MessageResponseDto } from './messageResponseDto';
export interface ConversationWithMessagesResponseDto {
/** Conversation ID */
id: string;
/** Conversation title */
title?: string;
/** Character ID */
characterId: string;
/** Number of messages */
messageCount: number;
/** Total tokens used */
totalTokens: number;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** Character info */
character?: CharacterSummaryDto;
/** Messages in conversation */
messages: MessageResponseDto[];
}

View File

@@ -0,0 +1,24 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { CreateCharacterDtoAttributes } from './createCharacterDtoAttributes';
import type { CreateCharacterDtoConfig } from './createCharacterDtoConfig';
export interface CreateCharacterDto {
/** Character name */
name: string;
/** Avatar URL */
avatarUrl?: string;
/** Personality prompt that guides AI responses */
personalityPrompt: string;
/** Custom attributes (JSON) */
attributes?: CreateCharacterDtoAttributes;
/** Character configuration (JSON) */
config?: CreateCharacterDtoConfig;
/** Whether the character is publicly visible */
isPublic?: boolean;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Custom attributes (JSON)
*/
export type CreateCharacterDtoAttributes = { [key: string]: unknown };

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Character configuration (JSON)
*/
export type CreateCharacterDtoConfig = { [key: string]: unknown };

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface CreateConversationDto {
/** Character ID to chat with */
characterId: string;
/** Conversation title */
title?: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export type ImportControllerUploadFileBody = {
/** File to upload (.txt, .md) */
file?: Blob;
};

View File

@@ -0,0 +1,37 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export * from './authControllerKeycloakCallbackParams';
export * from './authControllerKeycloakLoginParams';
export * from './authResponseDto';
export * from './characterResponseDto';
export * from './characterResponseDtoAttributes';
export * from './characterResponseDtoConfig';
export * from './characterSummaryDto';
export * from './conversationResponseDto';
export * from './conversationWithMessagesResponseDto';
export * from './createCharacterDto';
export * from './createCharacterDtoAttributes';
export * from './createCharacterDtoConfig';
export * from './createConversationDto';
export * from './importControllerUploadFileBody';
export * from './keycloakConfigDto';
export * from './keycloakLoginUrlDto';
export * from './loginDto';
export * from './messageResponseDto';
export * from './messageResponseDtoRole';
export * from './refreshTokenDto';
export * from './sendMessageDto';
export * from './sendMessageResponseDto';
export * from './updateCharacterDto';
export * from './updateCharacterDtoAttributes';
export * from './updateCharacterDtoConfig';
export * from './updatePasswordDto';
export * from './updateUserDto';
export * from './userDto';
export * from './userDtoRole';

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface KeycloakConfigDto {
/** Whether Keycloak authentication is enabled */
enabled: boolean;
/** Keycloak realm URL */
url?: string;
/** Keycloak realm name */
realm?: string;
/** Keycloak client ID */
clientId?: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface KeycloakLoginUrlDto {
/** Keycloak login URL to redirect the user to */
loginUrl: string;
/** State parameter for CSRF protection */
state: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface LoginDto {
/** User email address */
email: string;
/** User password */
password: string;
}

View File

@@ -0,0 +1,23 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { MessageResponseDtoRole } from './messageResponseDtoRole';
export interface MessageResponseDto {
/** Message ID */
id: string;
/** Message role */
role: MessageResponseDtoRole;
/** Message content */
content: string;
/** Tokens used */
tokensUsed?: number;
/** Model used */
model?: string;
/** Creation date */
createdAt: string;
}

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Message role
*/
export type MessageResponseDtoRole = typeof MessageResponseDtoRole[keyof typeof MessageResponseDtoRole];
export const MessageResponseDtoRole = {
user: 'user',
assistant: 'assistant',
system: 'system',
} as const;

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface RefreshTokenDto {
/** Refresh token */
refreshToken: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface SendMessageDto {
/** Message content */
content: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { MessageResponseDto } from './messageResponseDto';
export interface SendMessageResponseDto {
/** User message */
userMessage: MessageResponseDto;
/** Assistant response */
assistantMessage: MessageResponseDto;
}

View File

@@ -0,0 +1,24 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { UpdateCharacterDtoAttributes } from './updateCharacterDtoAttributes';
import type { UpdateCharacterDtoConfig } from './updateCharacterDtoConfig';
export interface UpdateCharacterDto {
/** Character name */
name?: string;
/** Avatar URL */
avatarUrl?: string;
/** Personality prompt */
personalityPrompt?: string;
/** Custom attributes (JSON) */
attributes?: UpdateCharacterDtoAttributes;
/** Character configuration (JSON) */
config?: UpdateCharacterDtoConfig;
/** Whether the character is publicly visible */
isPublic?: boolean;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Custom attributes (JSON)
*/
export type UpdateCharacterDtoAttributes = { [key: string]: unknown };

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Character configuration (JSON)
*/
export type UpdateCharacterDtoConfig = { [key: string]: unknown };

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface UpdatePasswordDto {
/** Current password */
currentPassword?: string;
/** New password */
newPassword?: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
export interface UpdateUserDto {
/** New email address */
email?: string;
/** New username */
username?: string;
}

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type { UserDtoRole } from './userDtoRole';
export interface UserDto {
/** User ID */
id: string;
/** User email */
email: string;
/** User username */
username: string;
/** User role */
role: UserDtoRole;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* User role
*/
export type UserDtoRole = typeof UserDtoRole[keyof typeof UserDtoRole];
export const UserDtoRole = {
USER: 'USER',
ADMIN: 'ADMIN',
} as const;

View File

@@ -0,0 +1,108 @@
/**
* Generated by orval v8.4.2 🍺
* Do not edit manually.
* DreamChat API
* The DreamChat API documentation
* OpenAPI spec version: 1.0.0
*/
import type {
UpdatePasswordDto,
UpdateUserDto
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
/**
* @summary Get current user profile
*/
export const getUserControllerGetProfileUrl = () => {
return `http://localhost:3000/api/users/me`
}
export const userControllerGetProfile = async ( options?: RequestInit): Promise<void> => {
return customFetch<void>(getUserControllerGetProfileUrl(),
{
...options,
method: 'GET'
}
);}
/**
* @summary Update current user profile
*/
export const getUserControllerUpdateProfileUrl = () => {
return `http://localhost:3000/api/users/me`
}
export const userControllerUpdateProfile = async (updateUserDto: UpdateUserDto, options?: RequestInit): Promise<void> => {
return customFetch<void>(getUserControllerUpdateProfileUrl(),
{
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
updateUserDto,)
}
);}
/**
* @summary Delete user account
*/
export const getUserControllerDeleteAccountUrl = () => {
return `http://localhost:3000/api/users/me`
}
export const userControllerDeleteAccount = async ( options?: RequestInit): Promise<void> => {
return customFetch<void>(getUserControllerDeleteAccountUrl(),
{
...options,
method: 'DELETE'
}
);}
/**
* @summary Update user password
*/
export const getUserControllerUpdatePasswordUrl = () => {
return `http://localhost:3000/api/users/me/password`
}
export const userControllerUpdatePassword = async (updatePasswordDto: UpdatePasswordDto, options?: RequestInit): Promise<void> => {
return customFetch<void>(getUserControllerUpdatePasswordUrl(),
{
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
updatePasswordDto,)
}
);}

View File

@@ -0,0 +1,44 @@
const getBaseUrl = () => {
return (import.meta.env as unknown as ImportMetaEnv).VITE_API_URL || 'http://localhost:3000/api';
};
const getToken = (): string | null => {
return localStorage.getItem('accessToken');
};
export const customFetch = async <T>(
url: string,
options: RequestInit = {}
): Promise<T> => {
// If URL is already absolute (starts with http), use it as-is
// Otherwise, prepend the base URL
const fullUrl = url.startsWith('http') ? url : `${getBaseUrl()}${url}`;
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
// Handle empty responses
if (response.status === 204) {
return undefined as T;
}
return response.json();
};

View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useCharacterStore } from '../stores/characterStore';
export function CharacterForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEditing = id !== 'new';
const {
currentCharacter,
isLoading,
error,
getCharacter,
createCharacter,
updateCharacter,
clearError,
setCurrentCharacter
} = useCharacterStore();
const [name, setName] = useState('');
const [personalityPrompt, setPersonalityPrompt] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [attributes, setAttributes] = useState('{}');
useEffect(() => {
if (isEditing && id) {
getCharacter(id);
}
return () => {
setCurrentCharacter(null);
};
}, [isEditing, id, getCharacter, setCurrentCharacter]);
useEffect(() => {
if (currentCharacter && isEditing) {
setName(currentCharacter.name);
setPersonalityPrompt(currentCharacter.personalityPrompt);
setAvatarUrl(currentCharacter.avatarUrl || '');
setIsPublic(currentCharacter.isPublic);
setAttributes(JSON.stringify(currentCharacter.attributes, null, 2));
}
}, [currentCharacter, isEditing]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
let parsedAttributes = {};
try {
parsedAttributes = JSON.parse(attributes);
} catch {
alert('Invalid JSON in attributes field');
return;
}
const data = {
name,
personalityPrompt,
avatarUrl: avatarUrl || undefined,
isPublic,
attributes: parsedAttributes,
};
try {
if (isEditing && id) {
await updateCharacter(id, data);
} else {
await createCharacter(data);
}
navigate('/characters');
} catch {
// Error is handled by the store
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link to="/characters" className="text-gray-600 hover:text-gray-900">
Back to Characters
</Link>
</div>
</header>
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{isEditing ? 'Edit Character' : 'Create New Character'}
</h1>
{error && (
<div className="mb-4 bg-red-50 text-red-700 p-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
</label>
<input
type="text"
id="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., Alice the Explorer"
/>
</div>
<div>
<label htmlFor="avatarUrl" className="block text-sm font-medium text-gray-700">
Avatar URL
</label>
<input
type="url"
id="avatarUrl"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="https://example.com/avatar.jpg"
/>
</div>
<div>
<label htmlFor="personalityPrompt" className="block text-sm font-medium text-gray-700">
Personality Prompt *
</label>
<textarea
id="personalityPrompt"
required
rows={6}
value={personalityPrompt}
onChange={(e) => setPersonalityPrompt(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Describe your character's personality, background, and how they should respond..."
/>
<p className="mt-1 text-sm text-gray-500">
This prompt guides how the AI will respond as this character.
</p>
</div>
<div>
<label htmlFor="attributes" className="block text-sm font-medium text-gray-700">
Attributes (JSON)
</label>
<textarea
id="attributes"
rows={4}
value={attributes}
onChange={(e) => setAttributes(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 font-mono text-sm"
/>
<p className="mt-1 text-sm text-gray-500">
Custom attributes for your character (JSON format)
</p>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="isPublic" className="ml-2 block text-sm text-gray-900">
Make this character public
</label>
</div>
<div className="flex space-x-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isLoading ? 'Saving...' : (isEditing ? 'Update Character' : 'Create Character')}
</button>
<Link
to="/characters"
className="flex-1 flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</Link>
</div>
</form>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCharacterStore } from '../stores/characterStore';
import { useAuthStore } from '../stores/authStore';
export function CharacterList() {
const navigate = useNavigate();
const { logout } = useAuthStore();
const { characters, isLoading, error, fetchCharacters, deleteCharacter, clearError } = useCharacterStore();
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this character?')) return;
try {
await deleteCharacter(id);
} catch {
// Error is handled by the store
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">DreamChat</h1>
<div className="flex items-center space-x-4">
<Link
to="/conversations"
className="text-gray-600 hover:text-gray-900"
>
Conversations
</Link>
<button
onClick={handleLogout}
className="text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">My Characters</h2>
<Link
to="/characters/new"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Create Character
</Link>
</div>
{error && (
<div className="mb-4 bg-red-50 text-red-700 p-3 rounded">
{error}
<button onClick={clearError} className="ml-2 text-sm underline">Dismiss</button>
</div>
)}
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : characters.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg shadow">
<p className="text-gray-500">No characters yet. Create your first character to get started!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{characters.map((character) => (
<div key={character.id} className="bg-white rounded-lg shadow overflow-hidden">
<div className="p-6">
<div className="flex items-center space-x-4">
{character.avatarUrl ? (
<img
src={character.avatarUrl}
alt={character.name}
className="h-12 w-12 rounded-full object-cover"
/>
) : (
<div className="h-12 w-12 rounded-full bg-indigo-100 flex items-center justify-center">
<span className="text-indigo-600 font-medium text-lg">
{character.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h3 className="text-lg font-medium text-gray-900">{character.name}</h3>
<span className="text-sm text-gray-500">
{character.isPublic ? 'Public' : 'Private'}
</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-600 line-clamp-3">
{character.personalityPrompt}
</p>
<div className="mt-6 flex space-x-3">
<Link
to={`/characters/${character.id}`}
className="flex-1 text-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Edit
</Link>
<Link
to={`/chat/${character.id}`}
className="flex-1 text-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Chat
</Link>
<button
onClick={() => handleDelete(character.id)}
className="px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useChatStore } from '../stores/chatStore';
import { useCharacterStore } from '../stores/characterStore';
import { useAuthStore } from '../stores/authStore';
import type { Message } from '../types';
import { io, Socket } from 'socket.io-client';
const WS_URL = (import.meta.env as unknown as ImportMetaEnv).VITE_WS_URL || 'http://localhost:3000';
export function Chat() {
const { characterId, conversationId } = useParams<{ characterId?: string; conversationId?: string }>();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { currentCharacter, getCharacter } = useCharacterStore();
const {
currentConversation,
isStreaming,
error,
createConversation,
getConversation,
addMessage,
setStreaming,
clearError
} = useChatStore();
const [message, setMessage] = useState('');
const [streamingContent, setStreamingContent] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<Socket | null>(null);
// Initialize socket connection
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
const socket = io(`${WS_URL}/chat`, {
auth: { token: `Bearer ${token}` },
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('Connected to chat server');
});
socket.on('message_chunk', (data: { conversationId: string; chunk: { content: string } }) => {
if (data.conversationId === conversationId) {
setStreamingContent((prev) => prev + data.chunk.content);
}
});
socket.on('message_complete', () => {
setStreaming(false);
setStreamingContent('');
if (conversationId) {
getConversation(conversationId);
}
});
socket.on('message', (data: { message: { assistantMessage?: Message } }) => {
if (data.message.assistantMessage) {
addMessage(data.message.assistantMessage);
}
});
socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message);
setStreaming(false);
});
return () => {
socket.disconnect();
};
}, [conversationId, addMessage, getConversation, setStreaming]);
// Join conversation room
useEffect(() => {
if (socketRef.current && conversationId) {
socketRef.current.emit('join_conversation', { conversationId });
return () => {
socketRef.current?.emit('leave_conversation', { conversationId });
};
}
}, [conversationId]);
// Load character and conversation
useEffect(() => {
if (characterId) {
getCharacter(characterId);
// Create new conversation
createConversation({ characterId }).then((conv) => {
navigate(`/conversations/${conv.id}`, { replace: true });
});
} else if (conversationId) {
getConversation(conversationId);
}
}, [characterId, conversationId, getCharacter, createConversation, getConversation, navigate]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [currentConversation?.messages, streamingContent]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !conversationId || isStreaming) return;
const content = message.trim();
setMessage('');
setStreamingContent('');
setStreaming(true);
// Send via socket for streaming
socketRef.current?.emit('send_message', {
conversationId,
content,
});
};
const handleLogout = () => {
logout();
navigate('/login');
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const messages = currentConversation?.messages || [];
return (
<div className="h-screen flex flex-col bg-gray-50">
<header className="bg-white shadow flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<div className="flex items-center space-x-4">
<Link to="/conversations" className="text-gray-600 hover:text-gray-900">
Back
</Link>
<div>
<h1 className="text-lg font-semibold text-gray-900">
{currentConversation?.title || 'Chat'}
</h1>
{currentCharacter && (
<p className="text-sm text-gray-500">
with {currentCharacter.name}
</p>
)}
</div>
</div>
<button
onClick={handleLogout}
className="text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
</header>
<main className="flex-1 overflow-hidden flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-4">
{error && (
<div className="mb-4 bg-red-50 text-red-700 p-3 rounded flex-shrink-0">
{error}
<button onClick={clearError} className="ml-2 text-sm underline">Dismiss</button>
</div>
)}
<div className="flex-1 overflow-y-auto bg-white rounded-lg shadow p-4 space-y-4">
{messages.length === 0 && !isStreaming && (
<div className="text-center text-gray-500 py-8">
<p>Start a conversation with {currentCharacter?.name || 'your character'}</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-lg px-4 py-2 ${
msg.role === 'user'
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
<span className={`text-xs ${msg.role === 'user' ? 'text-indigo-200' : 'text-gray-500'} block mt-1`}>
{formatTime(msg.createdAt)}
</span>
</div>
</div>
))}
{isStreaming && streamingContent && (
<div className="flex justify-start">
<div className="max-w-[70%] rounded-lg px-4 py-2 bg-gray-100 text-gray-900">
<p className="whitespace-pre-wrap">{streamingContent}</p>
<span className="text-xs text-gray-500 block mt-1">typing...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="mt-4 flex-shrink-0 flex space-x-4">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isStreaming}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-50"
/>
<button
type="submit"
disabled={!message.trim() || isStreaming}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStreaming ? 'Sending...' : 'Send'}
</button>
</form>
</main>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
export function ConversationList() {
const navigate = useNavigate();
const { logout } = useAuthStore();
const { conversations, isLoading, error, fetchConversations, deleteConversation, clearError } = useChatStore();
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this conversation?')) return;
try {
await deleteConversation(id);
} catch {
// Error is handled by the store
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">DreamChat</h1>
<div className="flex items-center space-x-4">
<Link
to="/characters"
className="text-gray-600 hover:text-gray-900"
>
Characters
</Link>
<button
onClick={handleLogout}
className="text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">My Conversations</h2>
<Link
to="/characters"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
New Conversation
</Link>
</div>
{error && (
<div className="mb-4 bg-red-50 text-red-700 p-3 rounded">
{error}
<button onClick={clearError} className="ml-2 text-sm underline">Dismiss</button>
</div>
)}
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : conversations.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg shadow">
<p className="text-gray-500">No conversations yet.</p>
<Link to="/characters" className="text-indigo-600 hover:text-indigo-500 mt-2 inline-block">
Start a conversation with a character
</Link>
</div>
) : (
<div className="space-y-4">
{conversations.map((conversation) => (
<div key={conversation.id} className="bg-white rounded-lg shadow overflow-hidden">
<div className="p-6 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-medium text-gray-900">
{conversation.title || 'Untitled Conversation'}
</h3>
{conversation.character && (
<span className="text-sm text-gray-500">
with {conversation.character.name}
</span>
)}
</div>
<p className="mt-1 text-sm text-gray-500">
{conversation.messageCount} messages Last updated {formatDate(conversation.updatedAt)}
</p>
</div>
<div className="flex items-center space-x-3">
<Link
to={`/conversations/${conversation.id}`}
className="px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Continue
</Link>
<button
onClick={() => handleDelete(conversation.id)}
className="px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { authControllerGetKeycloakConfig, authControllerKeycloakLogin } from '../api/generated/auth/auth';
import type { KeycloakConfigDto } from '../api/generated/model';
interface OAuthError {
message: string;
isGroupError: boolean;
isRoleError: boolean;
}
function parseOAuthError(errorMsg: string): OAuthError {
const isGroupError = errorMsg.includes("required group");
const isRoleError = errorMsg.includes("required role");
return {
message: errorMsg,
isGroupError,
isRoleError,
};
}
export function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login, isLoading, error, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [keycloakConfig, setKeycloakConfig] = useState<KeycloakConfigDto | null>(null);
const [keycloakError, setKeycloakError] = useState<OAuthError | null>(null);
// Handle OAuth errors from URL (passed by OAuthCallbackHandler)
useEffect(() => {
const errorMsg = searchParams.get('error');
if (errorMsg) {
const decodedError = decodeURIComponent(errorMsg);
setKeycloakError(parseOAuthError(decodedError));
}
}, [searchParams]);
// Check if Keycloak is enabled
useEffect(() => {
const checkKeycloak = async () => {
try {
const config = await authControllerGetKeycloakConfig();
// Only show Keycloak button if enabled and properly configured
if (config.enabled && config.url && config.realm) {
setKeycloakConfig(config);
} else {
setKeycloakConfig(null);
}
} catch (error) {
console.error('Keycloak config error:', error);
setKeycloakConfig(null);
}
};
checkKeycloak();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setKeycloakError(null);
try {
await login({ email, password });
navigate('/characters');
} catch {
// Error is handled by the store
}
};
const handleKeycloakLogin = async () => {
setKeycloakError(null);
try {
const response = await authControllerKeycloakLogin({ redirectTo: '/characters' });
// Redirect to Keycloak login page
window.location.href = response.loginUrl;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to initiate Keycloak login';
setKeycloakError(parseOAuthError(msg));
}
};
// Get user-friendly error message
const getErrorDisplay = (): { title: string; message: string } | null => {
if (keycloakError) {
if (keycloakError.isGroupError) {
return {
title: 'Access Denied',
message: 'You do not have the required group membership to access this application. Please contact your administrator.',
};
}
if (keycloakError.isRoleError) {
return {
title: 'Access Denied',
message: 'You do not have the required role to access this application. Please contact your administrator.',
};
}
return {
title: 'Authentication Failed',
message: keycloakError.message,
};
}
if (error) {
return {
title: 'Login Failed',
message: error,
};
}
return null;
};
const errorDisplay = getErrorDisplay();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h1 className="text-3xl font-bold text-center text-gray-900">DreamChat</h1>
<h2 className="mt-6 text-2xl font-bold text-center text-gray-900">Sign in to your account</h2>
</div>
{errorDisplay && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">
<h3 className="font-semibold text-red-800 mb-1">{errorDisplay.title}</h3>
<p className="text-sm">{errorDisplay.message}</p>
{(keycloakError?.isGroupError || keycloakError?.isRoleError) && (
<p className="text-xs text-red-600 mt-2">
Technical details: {keycloakError.message}
</p>
)}
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{/* Keycloak Login Button */}
{keycloakConfig && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<button
onClick={handleKeycloakLogin}
className="mt-4 w-full flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg
className="h-5 w-5 mr-2"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
fill="currentColor"
/>
</svg>
Sign in with Keycloak
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authControllerLogin } from '../api/generated/auth/auth';
import type { LoginDto } from '../api/generated/model';
interface User {
id: string;
email: string;
username: string;
role: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (credentials: LoginDto) => Promise<void>;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
clearError: () => void;
init: () => void;
}
// Parse JWT token to get user info
const parseJwt = (token: string): { sub: string; email: string; role: string } | null => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
};
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
init: () => {
const token = localStorage.getItem('accessToken');
if (token) {
const payload = parseJwt(token);
if (payload) {
set({
user: {
id: payload.sub,
email: payload.email,
username: payload.email.split('@')[0],
role: payload.role,
},
isAuthenticated: true,
});
} else {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
},
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const response = await authControllerLogin(credentials);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('refreshToken', response.refreshToken);
set({
user: response.user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Login failed',
isLoading: false,
});
throw error;
}
},
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
const payload = parseJwt(accessToken);
if (payload) {
set({
user: {
id: payload.sub,
email: payload.email,
username: payload.email.split('@')[0],
role: payload.role,
},
isAuthenticated: true,
});
}
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({
user: null,
isAuthenticated: false,
error: null,
});
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
},
),
);

View File

@@ -0,0 +1,120 @@
import { create } from 'zustand';
import {
characterControllerCreate,
characterControllerFindAll,
characterControllerFindOne,
characterControllerUpdate,
characterControllerDelete,
} from '../api/generated/characters/characters';
import type { CreateCharacterDto, UpdateCharacterDto } from '../api/generated/model';
import type { Character } from '../types';
interface CharacterState {
characters: Character[];
currentCharacter: Character | null;
isLoading: boolean;
error: string | null;
// Actions
fetchCharacters: () => Promise<void>;
getCharacter: (id: string) => Promise<Character>;
createCharacter: (data: CreateCharacterDto) => Promise<Character>;
updateCharacter: (id: string, data: UpdateCharacterDto) => Promise<Character>;
deleteCharacter: (id: string) => Promise<void>;
setCurrentCharacter: (character: Character | null) => void;
clearError: () => void;
}
export const useCharacterStore = create<CharacterState>()((set) => ({
characters: [],
currentCharacter: null,
isLoading: false,
error: null,
fetchCharacters: async () => {
set({ isLoading: true, error: null });
try {
// The generated API returns void, we need to cast the response
const characters = await characterControllerFindAll() as unknown as Character[];
set({ characters, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch characters',
isLoading: false,
});
}
},
getCharacter: async (id) => {
set({ isLoading: true, error: null });
try {
const character = await characterControllerFindOne(id) as unknown as Character;
set({ currentCharacter: character, isLoading: false });
return character;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch character',
isLoading: false,
});
throw error;
}
},
createCharacter: async (data) => {
set({ isLoading: true, error: null });
try {
const character = await characterControllerCreate(data) as unknown as Character;
set((state) => ({
characters: [character, ...state.characters],
isLoading: false,
}));
return character;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create character',
isLoading: false,
});
throw error;
}
},
updateCharacter: async (id, data) => {
set({ isLoading: true, error: null });
try {
const character = await characterControllerUpdate(id, data) as unknown as Character;
set((state) => ({
characters: state.characters.map((c) => (c.id === id ? character : c)),
currentCharacter: state.currentCharacter?.id === id ? character : state.currentCharacter,
isLoading: false,
}));
return character;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to update character',
isLoading: false,
});
throw error;
}
},
deleteCharacter: async (id) => {
set({ isLoading: true, error: null });
try {
await characterControllerDelete(id);
set((state) => ({
characters: state.characters.filter((c) => c.id !== id),
currentCharacter: state.currentCharacter?.id === id ? null : state.currentCharacter,
isLoading: false,
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete character',
isLoading: false,
});
throw error;
}
},
setCurrentCharacter: (character) => set({ currentCharacter: character }),
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,144 @@
import { create } from 'zustand';
import {
chatControllerCreateConversation,
chatControllerGetConversations,
chatControllerGetConversation,
chatControllerDeleteConversation,
chatControllerSendMessage,
} from '../api/generated/conversations/conversations';
import type { CreateConversationDto, SendMessageDto } from '../api/generated/model';
import type { Conversation, ConversationWithMessages, Message } from '../types';
interface ChatState {
conversations: Conversation[];
currentConversation: ConversationWithMessages | null;
isLoading: boolean;
isStreaming: boolean;
error: string | null;
// Actions
fetchConversations: () => Promise<void>;
getConversation: (id: string) => Promise<void>;
createConversation: (data: CreateConversationDto) => Promise<Conversation>;
deleteConversation: (id: string) => Promise<void>;
sendMessage: (conversationId: string, data: SendMessageDto) => Promise<void>;
addMessage: (message: Message) => void;
setCurrentConversation: (conversation: ConversationWithMessages | null) => void;
setStreaming: (isStreaming: boolean) => void;
clearError: () => void;
}
export const useChatStore = create<ChatState>()((set) => ({
conversations: [],
currentConversation: null,
isLoading: false,
isStreaming: false,
error: null,
fetchConversations: async () => {
set({ isLoading: true, error: null });
try {
const conversations = await chatControllerGetConversations() as unknown as Conversation[];
set({ conversations, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch conversations',
isLoading: false,
});
}
},
getConversation: async (id) => {
set({ isLoading: true, error: null });
try {
const conversation = await chatControllerGetConversation(id) as unknown as ConversationWithMessages;
set({ currentConversation: conversation, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch conversation',
isLoading: false,
});
}
},
createConversation: async (data) => {
set({ isLoading: true, error: null });
try {
const conversation = await chatControllerCreateConversation(data) as unknown as Conversation;
set((state) => ({
conversations: [conversation, ...state.conversations],
isLoading: false,
}));
return conversation;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create conversation',
isLoading: false,
});
throw error;
}
},
deleteConversation: async (id) => {
set({ isLoading: true, error: null });
try {
await chatControllerDeleteConversation(id);
set((state) => ({
conversations: state.conversations.filter((c) => c.id !== id),
currentConversation: state.currentConversation?.id === id ? null : state.currentConversation,
isLoading: false,
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete conversation',
isLoading: false,
});
}
},
sendMessage: async (conversationId, data) => {
set({ isLoading: true, error: null });
try {
const result = await chatControllerSendMessage(conversationId, data);
set((state) => {
if (!state.currentConversation) return state;
const newMessages: Message[] = [
result.userMessage as Message,
result.assistantMessage as Message,
];
return {
currentConversation: {
...state.currentConversation,
messages: [...state.currentConversation.messages, ...newMessages],
},
isLoading: false,
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to send message',
isLoading: false,
});
}
},
addMessage: (message) => {
set((state) => {
if (!state.currentConversation) return state;
return {
currentConversation: {
...state.currentConversation,
messages: [...state.currentConversation.messages, message],
},
};
});
},
setCurrentConversation: (conversation) => set({ currentConversation: conversation }),
setStreaming: (isStreaming) => set({ isStreaming }),
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,55 @@
// Character type (not generated because Prisma types aren't in OpenAPI)
export interface Character {
id: string;
name: string;
avatarUrl: string | null;
personalityPrompt: string;
attributes: Record<string, any>;
config: Record<string, any>;
isPublic: boolean;
createdAt: string;
updatedAt: string;
userId: string;
}
// Conversation with messages
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
tokensUsed: number | null;
model: string | null;
createdAt: string;
}
export interface Conversation {
id: string;
title: string | null;
characterId: string;
messageCount: number;
totalTokens: number;
createdAt: string;
updatedAt: string;
character?: {
id: string;
name: string;
avatarUrl: string | null;
};
}
export interface ConversationWithMessages extends Conversation {
messages: Message[];
}
// Re-export generated types
export type {
LoginDto,
AuthResponseDto,
UserDto,
CreateCharacterDto,
UpdateCharacterDto,
CreateConversationDto,
SendMessageDto,
SendMessageResponseDto,
MessageResponseDto,
} from '../api/generated/model';

10
apps/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_WS_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}