2
This commit is contained in:
parent
9eaef58312
commit
ccb52987a0
@ -57,4 +57,28 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/conversations/:id/messages/:messageId/retry',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await WorkspaceService.retryMessage(
|
||||
req.params.id,
|
||||
req.params.messageId,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/conversations/:id/messages/:messageId/regenerate',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await WorkspaceService.regenerateMessage(
|
||||
req.params.id,
|
||||
req.params.messageId,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const db = require('../db/models');
|
||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
|
||||
const { Op } = db.Sequelize;
|
||||
@ -6,6 +7,7 @@ const { Op } = db.Sequelize;
|
||||
const DEFAULT_CONVERSATION_TITLE = 'New conversation';
|
||||
const MAX_MESSAGE_LENGTH = 8000;
|
||||
const MAX_TITLE_LENGTH = 120;
|
||||
const MAX_CONTEXT_MESSAGES = 12;
|
||||
|
||||
function normalizeText(value) {
|
||||
if (typeof value !== 'string') {
|
||||
@ -62,103 +64,329 @@ function estimateTokens(content) {
|
||||
return Math.max(1, Math.ceil(normalized.length / 4));
|
||||
}
|
||||
|
||||
function buildDraftParagraph(message, agentName) {
|
||||
const excerpt = cleanMarkdownPreview(message).slice(0, 220) || 'your request';
|
||||
function buildAssistantFailureMessage(errorMessage) {
|
||||
const normalized = cleanMarkdownPreview(errorMessage).slice(0, 400) || 'Unknown AI error.';
|
||||
|
||||
return `I captured the main goal from **${excerpt}** and prepared a structured draft in the **${agentName}** style. This workspace is ready for a real model later, so the current reply is a product-shell response that keeps the chat flow working end to end.`;
|
||||
}
|
||||
|
||||
function buildCodeExample() {
|
||||
return [
|
||||
'```ts',
|
||||
'type Result = {',
|
||||
' summary: string;',
|
||||
' nextStep: string;',
|
||||
'};',
|
||||
'### Response failed',
|
||||
'',
|
||||
'export function respond(prompt: string): Result {',
|
||||
' return {',
|
||||
" summary: `Working on: ${prompt.trim()}`,",
|
||||
" nextStep: 'Refine the prompt or ask for implementation details.',",
|
||||
' };',
|
||||
'}',
|
||||
'The workspace could not generate a reply from the configured AI provider.',
|
||||
'',
|
||||
'```text',
|
||||
normalized,
|
||||
'```',
|
||||
'',
|
||||
'Retry the message or switch to another agent and try again.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildAssistantReply({ message, agent }) {
|
||||
const agentName = agent?.name || 'Assistant';
|
||||
const normalized = normalizeText(message);
|
||||
const lowerCase = normalized.toLowerCase();
|
||||
const asksForCode = /(code|debug|bug|typescript|javascript|python|sql|query|api|component|react|next|function)/i.test(
|
||||
lowerCase,
|
||||
);
|
||||
const asksForWriting = /(write|draft|copy|email|summary|summarize|post|documentation|spec)/i.test(
|
||||
lowerCase,
|
||||
);
|
||||
function buildAiInput(agent, historyMessages) {
|
||||
const input = [];
|
||||
const systemPrompt = normalizeText(agent?.system_prompt);
|
||||
const agentDescription = normalizeText(agent?.description);
|
||||
|
||||
const intro = `### ${agentName}`;
|
||||
const context = buildDraftParagraph(normalized, agentName);
|
||||
const echoedPrompt = `> ${cleanMarkdownPreview(normalized).slice(0, 240)}${
|
||||
cleanMarkdownPreview(normalized).length > 240 ? '…' : ''
|
||||
}`;
|
||||
|
||||
if (asksForCode) {
|
||||
return [
|
||||
intro,
|
||||
'',
|
||||
context,
|
||||
'',
|
||||
echoedPrompt,
|
||||
'',
|
||||
'#### Suggested path',
|
||||
'- Start with the smallest working example.',
|
||||
'- Validate inputs and define the expected output shape.',
|
||||
'- Add edge-case handling after the first happy-path pass.',
|
||||
'',
|
||||
'#### Starter example',
|
||||
buildCodeExample(),
|
||||
'',
|
||||
'If you want, ask me for the production version next and I can continue from this structure.',
|
||||
].join('\n');
|
||||
if (systemPrompt) {
|
||||
input.push({
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
});
|
||||
} else if (agentDescription) {
|
||||
input.push({
|
||||
role: 'system',
|
||||
content: `You are ${agent?.name || 'an AI assistant'}. ${agentDescription}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (asksForWriting) {
|
||||
return [
|
||||
intro,
|
||||
'',
|
||||
context,
|
||||
'',
|
||||
echoedPrompt,
|
||||
'',
|
||||
'#### First draft',
|
||||
'- Open with the outcome you want the reader to understand.',
|
||||
'- Keep the tone concise, direct, and useful.',
|
||||
'- End with one clear next action or decision.',
|
||||
'',
|
||||
'#### Polished version',
|
||||
'Here is a strong starting point you can refine in the next turn:',
|
||||
'',
|
||||
`**Goal:** move the work forward without losing context.`,
|
||||
'',
|
||||
'I can now turn this into a finished draft, a shorter version, or a more opinionated rewrite.',
|
||||
].join('\n');
|
||||
for (const message of historyMessages) {
|
||||
if (!['user', 'assistant', 'system'].includes(message.role)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = normalizeText(message.content_markdown || message.content);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
input.push({
|
||||
role: message.role,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
intro,
|
||||
'',
|
||||
context,
|
||||
'',
|
||||
echoedPrompt,
|
||||
'',
|
||||
'#### Working response',
|
||||
'- I understood the request and attached it to this conversation.',
|
||||
'- The conversation history is now saved in your workspace.',
|
||||
'- You can rename, archive, reopen, or delete this chat at any time.',
|
||||
'',
|
||||
'Tell me whether you want a **plan**, a **draft answer**, or a **code solution**, and I will continue in that format.',
|
||||
].join('\n');
|
||||
return input;
|
||||
}
|
||||
|
||||
async function requestAssistantReply(conversationId, assistantMessageId, agent) {
|
||||
const historyMessages = await db.messages.findAll({
|
||||
where: {
|
||||
conversationId,
|
||||
id: {
|
||||
[Op.ne]: assistantMessageId,
|
||||
},
|
||||
delivery_status: {
|
||||
[Op.ne]: 'failed',
|
||||
},
|
||||
role: {
|
||||
[Op.in]: ['user', 'assistant', 'system'],
|
||||
},
|
||||
},
|
||||
order: [['sequence', 'DESC']],
|
||||
limit: MAX_CONTEXT_MESSAGES,
|
||||
});
|
||||
|
||||
const orderedMessages = [...historyMessages].reverse();
|
||||
const input = buildAiInput(agent, orderedMessages);
|
||||
|
||||
if (input.length === 0) {
|
||||
throw new Error('AI input could not be built from the conversation history.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
input,
|
||||
};
|
||||
|
||||
if (agent?.model) {
|
||||
payload.model = agent.model;
|
||||
}
|
||||
|
||||
if (agent?.temperature !== undefined && agent?.temperature !== null && agent.temperature !== '') {
|
||||
payload.temperature = Number(agent.temperature);
|
||||
}
|
||||
|
||||
if (agent?.max_output_tokens) {
|
||||
payload.max_output_tokens = Number(agent.max_output_tokens);
|
||||
}
|
||||
|
||||
const response = await LocalAIApi.createResponse(payload, {
|
||||
poll_interval: 5,
|
||||
poll_timeout: 300,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || response.message || 'AI proxy request failed.');
|
||||
}
|
||||
|
||||
const text = normalizeText(LocalAIApi.extractText(response));
|
||||
|
||||
if (!text) {
|
||||
throw new Error('AI response was empty.');
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
model: payload.model || 'default-ai-model',
|
||||
};
|
||||
}
|
||||
|
||||
async function findConversationMessage(conversationId, messageId, transaction) {
|
||||
return db.messages.findOne({
|
||||
where: {
|
||||
id: messageId,
|
||||
conversationId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function findLatestUserMessageBeforeSequence(conversationId, sequence, transaction) {
|
||||
return db.messages.findOne({
|
||||
where: {
|
||||
conversationId,
|
||||
role: 'user',
|
||||
sequence: {
|
||||
[Op.lt]: sequence,
|
||||
},
|
||||
},
|
||||
order: [['sequence', 'DESC']],
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveConversationAgent(conversation, currentUser, transaction) {
|
||||
let agent = conversation.agent;
|
||||
|
||||
if (agent) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
agent = await findDefaultAgent(transaction);
|
||||
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await conversation.update(
|
||||
{
|
||||
agentId: agent.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function finalizeAssistantResponse({
|
||||
assistantMessageId,
|
||||
conversationId,
|
||||
currentUser,
|
||||
sourceContent,
|
||||
agentId,
|
||||
agentModel,
|
||||
titleSeed,
|
||||
metadataAction,
|
||||
}) {
|
||||
let aiError = null;
|
||||
|
||||
try {
|
||||
const agent =
|
||||
agentId
|
||||
? await db.agents.findOne({
|
||||
where: {
|
||||
id: agentId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const aiResult = await requestAssistantReply(conversationId, assistantMessageId, agent);
|
||||
const completeTransaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const completedAt = new Date();
|
||||
const assistantMessage = await db.messages.findOne({
|
||||
where: {
|
||||
id: assistantMessageId,
|
||||
},
|
||||
transaction: completeTransaction,
|
||||
});
|
||||
|
||||
if (!assistantMessage) {
|
||||
throw new Error('Assistant message placeholder was not found.');
|
||||
}
|
||||
|
||||
await assistantMessage.update(
|
||||
{
|
||||
content: aiResult.content,
|
||||
content_markdown: aiResult.content,
|
||||
delivery_status: 'completed',
|
||||
completed_at: completedAt,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction: completeTransaction },
|
||||
);
|
||||
|
||||
const conversation = await db.conversations.findOne({
|
||||
where: {
|
||||
id: conversationId,
|
||||
},
|
||||
transaction: completeTransaction,
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('Conversation was not found while completing the assistant response.');
|
||||
}
|
||||
|
||||
const conversationUpdate = {
|
||||
summary: buildSummary(aiResult.content),
|
||||
last_message_at: completedAt,
|
||||
updatedById: currentUser.id,
|
||||
};
|
||||
|
||||
if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) {
|
||||
conversationUpdate.title = buildConversationTitle(titleSeed);
|
||||
}
|
||||
|
||||
await conversation.update(conversationUpdate, { transaction: completeTransaction });
|
||||
|
||||
const inputTokens = estimateTokens(sourceContent);
|
||||
const outputTokens = estimateTokens(aiResult.content);
|
||||
|
||||
await createUsageEvent(
|
||||
{
|
||||
event_type: 'message_generated',
|
||||
occurred_at: completedAt,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
cost_usd: 0,
|
||||
provider: 'local-ai',
|
||||
model: aiResult.model || agentModel,
|
||||
metadata_json: JSON.stringify({
|
||||
role: 'assistant',
|
||||
action: metadataAction,
|
||||
}),
|
||||
conversationId,
|
||||
messageId: assistantMessageId,
|
||||
agentId,
|
||||
},
|
||||
completeTransaction,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
await completeTransaction.commit();
|
||||
} catch (error) {
|
||||
await completeTransaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
aiError = error.message || String(error);
|
||||
const failTransaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const failedAt = new Date();
|
||||
const assistantMessage = await db.messages.findOne({
|
||||
where: {
|
||||
id: assistantMessageId,
|
||||
},
|
||||
transaction: failTransaction,
|
||||
});
|
||||
|
||||
if (!assistantMessage) {
|
||||
throw new Error('Assistant message placeholder was not found while handling the AI failure.');
|
||||
}
|
||||
|
||||
const failureMessage = buildAssistantFailureMessage(aiError);
|
||||
|
||||
await assistantMessage.update(
|
||||
{
|
||||
content: failureMessage,
|
||||
content_markdown: failureMessage,
|
||||
delivery_status: 'failed',
|
||||
completed_at: failedAt,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction: failTransaction },
|
||||
);
|
||||
|
||||
const conversation = await db.conversations.findOne({
|
||||
where: {
|
||||
id: conversationId,
|
||||
},
|
||||
transaction: failTransaction,
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('Conversation was not found while marking the AI failure.');
|
||||
}
|
||||
|
||||
const conversationUpdate = {
|
||||
last_message_at: failedAt,
|
||||
updatedById: currentUser.id,
|
||||
};
|
||||
|
||||
if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) {
|
||||
conversationUpdate.title = buildConversationTitle(titleSeed);
|
||||
}
|
||||
|
||||
await conversation.update(conversationUpdate, { transaction: failTransaction });
|
||||
|
||||
await failTransaction.commit();
|
||||
} catch (failureHandlingError) {
|
||||
await failTransaction.rollback();
|
||||
throw failureHandlingError;
|
||||
}
|
||||
}
|
||||
|
||||
return aiError;
|
||||
}
|
||||
|
||||
function serializeAgent(agent) {
|
||||
@ -556,20 +784,25 @@ module.exports = class WorkspaceService {
|
||||
}
|
||||
|
||||
static async sendMessage(id, data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
const content = normalizeText(data?.content);
|
||||
|
||||
if (!content) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const createTransaction = await db.sequelize.transaction();
|
||||
let conversationId = null;
|
||||
let assistantMessageId = null;
|
||||
let userMessageId = null;
|
||||
let agentId = null;
|
||||
let agentModel = null;
|
||||
|
||||
try {
|
||||
const content = normalizeText(data?.content);
|
||||
|
||||
if (!content) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
||||
const conversation = await findOwnedConversation(id, currentUser, createTransaction);
|
||||
let agent = conversation.agent;
|
||||
|
||||
if (data?.agentId) {
|
||||
@ -578,7 +811,7 @@ module.exports = class WorkspaceService {
|
||||
id: data.agentId,
|
||||
is_active: true,
|
||||
},
|
||||
transaction,
|
||||
transaction: createTransaction,
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
@ -590,35 +823,21 @@ module.exports = class WorkspaceService {
|
||||
agentId: agent.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
{ transaction: createTransaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
agent = await findDefaultAgent(transaction);
|
||||
|
||||
if (agent) {
|
||||
await conversation.update(
|
||||
{
|
||||
agentId: agent.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
agent = await resolveConversationAgent(conversation, currentUser, createTransaction);
|
||||
}
|
||||
|
||||
const messageCount = await db.messages.count({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
},
|
||||
transaction,
|
||||
transaction: createTransaction,
|
||||
});
|
||||
const sentAt = new Date();
|
||||
const assistantReply = buildAssistantReply({
|
||||
message: content,
|
||||
agent,
|
||||
});
|
||||
|
||||
const userMessage = await db.messages.create(
|
||||
{
|
||||
@ -634,44 +853,36 @@ module.exports = class WorkspaceService {
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
{ transaction: createTransaction },
|
||||
);
|
||||
|
||||
const assistantMessage = await db.messages.create(
|
||||
{
|
||||
role: 'assistant',
|
||||
content: assistantReply,
|
||||
content_markdown: assistantReply,
|
||||
delivery_status: 'completed',
|
||||
content: '',
|
||||
content_markdown: '',
|
||||
delivery_status: 'streaming',
|
||||
sent_at: sentAt,
|
||||
completed_at: new Date(sentAt.getTime() + 300),
|
||||
completed_at: null,
|
||||
sequence: messageCount + 2,
|
||||
conversationId: conversation.id,
|
||||
author_userId: currentUser.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
{ transaction: createTransaction },
|
||||
);
|
||||
|
||||
const nextTitle =
|
||||
conversation.title && conversation.title !== DEFAULT_CONVERSATION_TITLE
|
||||
? conversation.title
|
||||
: buildConversationTitle(content);
|
||||
|
||||
await conversation.update(
|
||||
{
|
||||
title: nextTitle,
|
||||
status: 'active',
|
||||
summary: buildSummary(assistantReply),
|
||||
last_message_at: sentAt,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
{ transaction: createTransaction },
|
||||
);
|
||||
|
||||
const inputTokens = estimateTokens(content);
|
||||
const outputTokens = estimateTokens(assistantReply);
|
||||
|
||||
await createUsageEvent(
|
||||
{
|
||||
@ -681,8 +892,8 @@ module.exports = class WorkspaceService {
|
||||
output_tokens: 0,
|
||||
total_tokens: inputTokens,
|
||||
cost_usd: 0,
|
||||
provider: 'workspace-shell',
|
||||
model: agent?.model || 'shell-draft',
|
||||
provider: 'local-ai',
|
||||
model: agent?.model || 'default-ai-model',
|
||||
metadata_json: JSON.stringify({
|
||||
role: 'user',
|
||||
}),
|
||||
@ -690,36 +901,236 @@ module.exports = class WorkspaceService {
|
||||
messageId: userMessage.id,
|
||||
agentId: agent?.id || null,
|
||||
},
|
||||
transaction,
|
||||
createTransaction,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
await createUsageEvent(
|
||||
{
|
||||
event_type: 'message_generated',
|
||||
occurred_at: new Date(sentAt.getTime() + 300),
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
cost_usd: 0,
|
||||
provider: 'workspace-shell',
|
||||
model: agent?.model || 'shell-draft',
|
||||
metadata_json: JSON.stringify({
|
||||
role: 'assistant',
|
||||
}),
|
||||
await createTransaction.commit();
|
||||
|
||||
conversationId = conversation.id;
|
||||
assistantMessageId = assistantMessage.id;
|
||||
userMessageId = userMessage.id;
|
||||
agentId = agent?.id || null;
|
||||
agentModel = agent?.model || 'default-ai-model';
|
||||
} catch (error) {
|
||||
await createTransaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const aiError = await finalizeAssistantResponse({
|
||||
assistantMessageId,
|
||||
conversationId,
|
||||
currentUser,
|
||||
sourceContent: content,
|
||||
agentId,
|
||||
agentModel,
|
||||
titleSeed: content,
|
||||
metadataAction: 'initial',
|
||||
});
|
||||
|
||||
const payload = await this.getConversation(conversationId, currentUser);
|
||||
|
||||
if (aiError) {
|
||||
return {
|
||||
...payload,
|
||||
ai_error: aiError,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
static async retryMessage(id, messageId, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let conversationId = null;
|
||||
let assistantMessageId = null;
|
||||
let sourceContent = '';
|
||||
let agentId = null;
|
||||
let agentModel = null;
|
||||
|
||||
try {
|
||||
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
||||
const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction);
|
||||
|
||||
if (!assistantMessage || assistantMessage.role !== 'assistant' || assistantMessage.delivery_status !== 'failed') {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const newerUserMessageCount = await db.messages.count({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
messageId: assistantMessage.id,
|
||||
agentId: agent?.id || null,
|
||||
role: 'user',
|
||||
sequence: {
|
||||
[Op.gt]: assistantMessage.sequence,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
currentUser,
|
||||
});
|
||||
|
||||
if (newerUserMessageCount > 0) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const sourceMessage = await findLatestUserMessageBeforeSequence(
|
||||
conversation.id,
|
||||
assistantMessage.sequence,
|
||||
transaction,
|
||||
);
|
||||
|
||||
if (!sourceMessage) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const agent = await resolveConversationAgent(conversation, currentUser, transaction);
|
||||
|
||||
await assistantMessage.update(
|
||||
{
|
||||
content: '',
|
||||
content_markdown: '',
|
||||
delivery_status: 'streaming',
|
||||
completed_at: null,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await conversation.update(
|
||||
{
|
||||
status: 'active',
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return this.getConversation(conversation.id, currentUser);
|
||||
|
||||
conversationId = conversation.id;
|
||||
assistantMessageId = assistantMessage.id;
|
||||
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
|
||||
agentId = agent?.id || null;
|
||||
agentModel = agent?.model || 'default-ai-model';
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const aiError = await finalizeAssistantResponse({
|
||||
assistantMessageId,
|
||||
conversationId,
|
||||
currentUser,
|
||||
sourceContent,
|
||||
agentId,
|
||||
agentModel,
|
||||
titleSeed: sourceContent,
|
||||
metadataAction: 'retry',
|
||||
});
|
||||
|
||||
const payload = await this.getConversation(conversationId, currentUser);
|
||||
|
||||
if (aiError) {
|
||||
return {
|
||||
...payload,
|
||||
ai_error: aiError,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
static async regenerateMessage(id, messageId, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let conversationId = null;
|
||||
let assistantMessageId = null;
|
||||
let sourceContent = '';
|
||||
let agentId = null;
|
||||
let agentModel = null;
|
||||
|
||||
try {
|
||||
const conversation = await findOwnedConversation(id, currentUser, transaction);
|
||||
const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction);
|
||||
|
||||
if (!assistantMessage || assistantMessage.role !== 'assistant') {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const newerUserMessageCount = await db.messages.count({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
sequence: {
|
||||
[Op.gt]: assistantMessage.sequence,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (newerUserMessageCount > 0) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const sourceMessage = await findLatestUserMessageBeforeSequence(
|
||||
conversation.id,
|
||||
assistantMessage.sequence,
|
||||
transaction,
|
||||
);
|
||||
|
||||
if (!sourceMessage) {
|
||||
throw new ValidationError();
|
||||
}
|
||||
|
||||
const agent = await resolveConversationAgent(conversation, currentUser, transaction);
|
||||
|
||||
await assistantMessage.update(
|
||||
{
|
||||
content: '',
|
||||
content_markdown: '',
|
||||
delivery_status: 'streaming',
|
||||
completed_at: null,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await conversation.update(
|
||||
{
|
||||
status: 'active',
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
conversationId = conversation.id;
|
||||
assistantMessageId = assistantMessage.id;
|
||||
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
|
||||
agentId = agent?.id || null;
|
||||
agentModel = agent?.model || 'default-ai-model';
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const aiError = await finalizeAssistantResponse({
|
||||
assistantMessageId,
|
||||
conversationId,
|
||||
currentUser,
|
||||
sourceContent,
|
||||
agentId,
|
||||
agentModel,
|
||||
titleSeed: sourceContent,
|
||||
metadataAction: 'regenerate',
|
||||
});
|
||||
|
||||
const payload = await this.getConversation(conversationId, currentUser);
|
||||
|
||||
if (aiError) {
|
||||
return {
|
||||
...payload,
|
||||
ai_error: aiError,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
|
||||
147
frontend/src/Authenticated.tsx
Normal file
147
frontend/src/Authenticated.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
import menuNavBar from '../menuNavBar'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
import NavBar from '../components/NavBar'
|
||||
import NavBarItemPlain from '../components/NavBarItemPlain'
|
||||
import AsideMenu from '../components/AsideMenu'
|
||||
import FooterBar from '../components/FooterBar'
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Search from '../components/Search';
|
||||
import { useRouter } from 'next/router'
|
||||
import {findMe, logoutUser} from "../stores/authSlice";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../helpers/userPermissions";
|
||||
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
|
||||
permission?: string
|
||||
|
||||
}
|
||||
|
||||
export default function LayoutAuthenticated({
|
||||
children,
|
||||
|
||||
permission
|
||||
|
||||
}: Props) {
|
||||
const dispatch = useAppDispatch()
|
||||
const router = useRouter()
|
||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||
const isWorkspaceRoute = router.pathname.startsWith('/workspace');
|
||||
let localToken
|
||||
if (typeof window !== 'undefined') {
|
||||
// Perform localStorage action
|
||||
localToken = localStorage.getItem('token')
|
||||
}
|
||||
|
||||
const isTokenValid = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const date = new Date().getTime() / 1000;
|
||||
const data = jwt.decode(token);
|
||||
if (!data) return;
|
||||
return date < data.exp;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(findMe());
|
||||
if (!isTokenValid()) {
|
||||
dispatch(logoutUser());
|
||||
router.push('/login');
|
||||
}
|
||||
}, [token, localToken]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!permission || !currentUser) return;
|
||||
|
||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||
}, [currentUser, permission]);
|
||||
|
||||
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||
|
||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChangeStart = () => {
|
||||
setIsAsideMobileExpanded(false)
|
||||
setIsAsideLgActive(false)
|
||||
}
|
||||
|
||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||
|
||||
// If the component is unmounted, unsubscribe
|
||||
// from the event with the `off` method:
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', handleRouteChangeStart)
|
||||
}
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-60'
|
||||
const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${layoutOffsetClass} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${layoutOffsetClass}`}
|
||||
>
|
||||
{isWorkspaceRoute ? (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between px-4 sm:px-6">
|
||||
<Link
|
||||
className="truncate text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300"
|
||||
href="/workspace"
|
||||
>
|
||||
AI Chat Workspace
|
||||
</Link>
|
||||
<span className="hidden text-xs uppercase tracking-[0.24em] text-slate-400 md:inline-flex">
|
||||
Focused workspace
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden lg:flex xl:hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</>
|
||||
)}
|
||||
</NavBar>
|
||||
{!isWorkspaceRoute && (
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{!isWorkspaceRoute && <FooterBar>Hand-crafted & Made with ❤️</FooterBar>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
frontend/src/ChatMarkdown.tsx
Normal file
251
frontend/src/ChatMarkdown.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
|
||||
type ChatMarkdownProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type InlineToken = {
|
||||
type: 'text' | 'code' | 'bold' | 'italic' | 'link';
|
||||
value: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const INLINE_REGEX = /(\[[^\]]+\]\([^\)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g;
|
||||
|
||||
const parseInlineTokens = (text: string): InlineToken[] => {
|
||||
const parts = text.split(INLINE_REGEX).filter(Boolean);
|
||||
|
||||
return parts.map((part) => {
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return {
|
||||
type: 'code',
|
||||
value: part.slice(1, -1),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return {
|
||||
type: 'bold',
|
||||
value: part.slice(2, -2),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.startsWith('*') && part.endsWith('*')) {
|
||||
return {
|
||||
type: 'italic',
|
||||
value: part.slice(1, -1),
|
||||
};
|
||||
}
|
||||
|
||||
const linkMatch = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
|
||||
|
||||
if (linkMatch) {
|
||||
return {
|
||||
type: 'link',
|
||||
value: linkMatch[1],
|
||||
href: linkMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: part,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renderInline = (text: string, keyPrefix: string) =>
|
||||
parseInlineTokens(text).map((token, index) => {
|
||||
const key = `${keyPrefix}-${index}`;
|
||||
|
||||
if (token.type === 'code') {
|
||||
return (
|
||||
<code
|
||||
key={key}
|
||||
className="rounded-[6px] border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]"
|
||||
>
|
||||
{token.value}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'bold') {
|
||||
return (
|
||||
<strong key={key} className="font-semibold text-slate-900 dark:text-white">
|
||||
{token.value}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'italic') {
|
||||
return (
|
||||
<em key={key} className="italic text-slate-700 dark:text-slate-100">
|
||||
{token.value}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'link') {
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
className="text-sky-600 underline underline-offset-4 dark:text-[#7DD3FC]"
|
||||
href={token.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{token.value}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment key={key}>{token.value}</React.Fragment>;
|
||||
});
|
||||
|
||||
const headingClassNames: Record<string, string> = {
|
||||
h1: 'text-2xl font-semibold tracking-[-0.03em] text-slate-900 dark:text-white',
|
||||
h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white',
|
||||
h3: 'text-lg font-semibold text-slate-900 dark:text-white',
|
||||
};
|
||||
|
||||
const isSpecialBlock = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
return (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('```') ||
|
||||
trimmed.startsWith('- ') ||
|
||||
trimmed.startsWith('* ') ||
|
||||
trimmed.startsWith('> ') ||
|
||||
trimmed.startsWith('# ')
|
||||
);
|
||||
};
|
||||
|
||||
export default function ChatMarkdown({ content, className = '' }: ChatMarkdownProps) {
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks: React.ReactNode[] = [];
|
||||
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const currentLine = lines[index];
|
||||
const trimmed = currentLine.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('```')) {
|
||||
const language = trimmed.replace(/```/, '').trim();
|
||||
const codeLines: string[] = [];
|
||||
index += 1;
|
||||
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<div key={`code-${index}`} className="overflow-hidden rounded-[10px] border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
|
||||
<span>{language || 'code'}</span>
|
||||
<span>{codeLines.length} lines</span>
|
||||
</div>
|
||||
<pre className="overflow-x-auto px-4 py-4 text-sm leading-6 text-[#E2E8F0]">
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>
|
||||
</div>,
|
||||
);
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('# ')) {
|
||||
blocks.push(
|
||||
<h1 key={`h1-${index}`} className={headingClassNames.h1}>
|
||||
{renderInline(trimmed.replace(/^#\s+/, ''), `h1-${index}`)}
|
||||
</h1>,
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('## ')) {
|
||||
blocks.push(
|
||||
<h2 key={`h2-${index}`} className={headingClassNames.h2}>
|
||||
{renderInline(trimmed.replace(/^##\s+/, ''), `h2-${index}`)}
|
||||
</h2>,
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
blocks.push(
|
||||
<h3 key={`h3-${index}`} className={headingClassNames.h3}>
|
||||
{renderInline(trimmed.replace(/^###\s+/, ''), `h3-${index}`)}
|
||||
</h3>,
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('> ')) {
|
||||
const quoteLines: string[] = [];
|
||||
|
||||
while (index < lines.length && lines[index].trim().startsWith('> ')) {
|
||||
quoteLines.push(lines[index].trim().replace(/^>\s+/, ''));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<blockquote
|
||||
key={`quote-${index}`}
|
||||
className="rounded-[10px] border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]"
|
||||
>
|
||||
{renderInline(quoteLines.join(' '), `quote-${index}`)}
|
||||
</blockquote>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
const items: string[] = [];
|
||||
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index].trim().startsWith('- ') || lines[index].trim().startsWith('* '))
|
||||
) {
|
||||
items.push(lines[index].trim().replace(/^[-*]\s+/, ''));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<ul key={`list-${index}`} className="space-y-2 pl-5 text-slate-100">
|
||||
{items.map((item, itemIndex) => (
|
||||
<li key={`item-${index}-${itemIndex}`} className="list-disc">
|
||||
{renderInline(item, `list-${index}-${itemIndex}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (index < lines.length && !isSpecialBlock(lines[index])) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-700 dark:text-slate-100/95">
|
||||
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={`space-y-4 ${className}`}>{blocks}</div>;
|
||||
}
|
||||
1170
frontend/src/WorkspaceShell.tsx
Normal file
1170
frontend/src/WorkspaceShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,13 +10,15 @@ import { useAppSelector } from '../stores/hooks';
|
||||
type Props = {
|
||||
menu: MenuNavBarItem[]
|
||||
className: string
|
||||
contentClassName?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function NavBar({ menu, className = '', children }: Props) {
|
||||
export default function NavBar({ menu, className = '', contentClassName = containerMaxW, children }: Props) {
|
||||
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false)
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||
const hasMenu = menu.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@ -37,20 +39,24 @@ export default function NavBar({ menu, className = '', children }: Props) {
|
||||
<nav
|
||||
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
|
||||
>
|
||||
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
|
||||
<div className={`flex lg:items-stretch ${contentClassName} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
|
||||
<div className="flex flex-1 items-stretch h-14">{children}</div>
|
||||
<div className="flex-none items-stretch flex h-14 lg:hidden">
|
||||
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
||||
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
|
||||
</NavBarItemPlain>
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
isMenuNavBarActive ? 'block' : 'hidden'
|
||||
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
|
||||
>
|
||||
<NavBarMenuList menu={menu} />
|
||||
</div>
|
||||
{hasMenu && (
|
||||
<>
|
||||
<div className="flex-none items-stretch flex h-14 lg:hidden">
|
||||
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
||||
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
|
||||
</NavBarItemPlain>
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
isMenuNavBarActive ? 'block' : 'hidden'
|
||||
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
|
||||
>
|
||||
<NavBarMenuList menu={menu} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
|
||||
@ -63,7 +63,7 @@ const renderInline = (text: string, keyPrefix: string) =>
|
||||
return (
|
||||
<code
|
||||
key={key}
|
||||
className="rounded-md border border-white/10 bg-white/10 px-1.5 py-0.5 font-mono text-[0.95em] text-[#CDE4FF]"
|
||||
className="rounded-md border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]"
|
||||
>
|
||||
{token.value}
|
||||
</code>
|
||||
@ -72,7 +72,7 @@ const renderInline = (text: string, keyPrefix: string) =>
|
||||
|
||||
if (token.type === 'bold') {
|
||||
return (
|
||||
<strong key={key} className="font-semibold text-white">
|
||||
<strong key={key} className="font-semibold text-slate-900 dark:text-white">
|
||||
{token.value}
|
||||
</strong>
|
||||
);
|
||||
@ -80,7 +80,7 @@ const renderInline = (text: string, keyPrefix: string) =>
|
||||
|
||||
if (token.type === 'italic') {
|
||||
return (
|
||||
<em key={key} className="italic text-slate-100">
|
||||
<em key={key} className="italic text-slate-700 dark:text-slate-100">
|
||||
{token.value}
|
||||
</em>
|
||||
);
|
||||
@ -90,7 +90,7 @@ const renderInline = (text: string, keyPrefix: string) =>
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
className="text-[#7DD3FC] underline underline-offset-4 transition hover:text-[#BAE6FD]"
|
||||
className="text-sky-600 underline underline-offset-4 transition hover:text-sky-700 dark:text-[#7DD3FC] dark:hover:text-[#BAE6FD]"
|
||||
href={token.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@ -104,9 +104,9 @@ const renderInline = (text: string, keyPrefix: string) =>
|
||||
});
|
||||
|
||||
const headingClassNames: Record<string, string> = {
|
||||
h1: 'text-2xl font-semibold tracking-[-0.03em] text-white',
|
||||
h2: 'text-xl font-semibold tracking-[-0.02em] text-white',
|
||||
h3: 'text-lg font-semibold text-white',
|
||||
h1: 'text-2xl font-semibold tracking-[-0.03em] text-slate-900 dark:text-white',
|
||||
h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white',
|
||||
h3: 'text-lg font-semibold text-slate-900 dark:text-white',
|
||||
};
|
||||
|
||||
const isSpecialBlock = (line: string) => {
|
||||
@ -147,8 +147,8 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<div key={`code-${index}`} className="overflow-hidden rounded-2xl border border-white/10 bg-[#050816]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
|
||||
<div key={`code-${index}`} className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
|
||||
<span>{language || 'code'}</span>
|
||||
<span>{codeLines.length} lines</span>
|
||||
</div>
|
||||
@ -203,7 +203,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
|
||||
blocks.push(
|
||||
<blockquote
|
||||
key={`quote-${index}`}
|
||||
className="rounded-2xl border border-[#38BDF8]/20 bg-[#0F172A] px-4 py-3 text-sm text-[#CFE8FF]"
|
||||
className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]"
|
||||
>
|
||||
{renderInline(quoteLines.join(' '), `quote-${index}`)}
|
||||
</blockquote>,
|
||||
@ -241,7 +241,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-100/95">
|
||||
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-700 dark:text-slate-100/95">
|
||||
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
|
||||
</p>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,31 +1,95 @@
|
||||
@layer base {
|
||||
select:not([multiple]):not([size]) {
|
||||
appearance: none;
|
||||
background-color: rgb(255 255 255);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6L8 10L12 6' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-position: right 0.85rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 14px 14px;
|
||||
@apply rounded-[10px] border border-slate-200 px-3.5 py-2.5 pr-10 text-[13px] leading-5 text-slate-900 shadow-none outline-none ring-0 transition-colors;
|
||||
}
|
||||
|
||||
select:not([multiple]):not([size]):hover {
|
||||
@apply border-slate-300;
|
||||
}
|
||||
|
||||
select:not([multiple]):not([size]):focus {
|
||||
@apply border-slate-900 outline-none ring-0;
|
||||
}
|
||||
|
||||
select:not([multiple]):not([size]):disabled {
|
||||
@apply cursor-not-allowed bg-slate-100 text-slate-400;
|
||||
}
|
||||
|
||||
select option {
|
||||
@apply bg-white text-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
.react-select__control {
|
||||
@apply dark:bg-dark-800 dark:border-dark-700 !important;
|
||||
@apply min-h-[42px] rounded-[10px] border border-slate-200 bg-white text-slate-900 shadow-none transition-colors dark:border-dark-700 dark:bg-dark-800 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.react-select__control:hover {
|
||||
@apply border-slate-300 dark:border-dark-600 !important;
|
||||
}
|
||||
|
||||
.react-select__control--is-focused {
|
||||
@apply border-slate-900 dark:border-white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.react-select__value-container {
|
||||
@apply px-2 py-0.5 !important;
|
||||
}
|
||||
|
||||
.react-select__single-value {
|
||||
@apply dark:text-white !important;
|
||||
@apply text-slate-900 dark:text-white !important;
|
||||
}
|
||||
|
||||
.react-select__placeholder {
|
||||
@apply text-slate-400 !important;
|
||||
}
|
||||
|
||||
.react-select__indicator-separator {
|
||||
@apply hidden !important;
|
||||
}
|
||||
|
||||
.react-select__dropdown-indicator,
|
||||
.react-select__clear-indicator {
|
||||
@apply text-slate-400 dark:text-slate-300 !important;
|
||||
}
|
||||
|
||||
.react-select__menu {
|
||||
@apply dark:border-dark-700
|
||||
@apply overflow-hidden rounded-[10px] border border-slate-200 bg-white shadow-[0_16px_48px_-32px_rgba(15,23,42,0.25)] dark:border-dark-700 dark:bg-dark-800 !important;
|
||||
}
|
||||
|
||||
.react-select__menu-list {
|
||||
@apply dark:bg-dark-800 dark:border-dark-700 dark:rounded !important;
|
||||
@apply p-1 dark:bg-dark-800 !important;
|
||||
}
|
||||
|
||||
.react-select__option {
|
||||
@apply cursor-pointer hover:bg-gray-200 dark:hover:bg-dark-700 !important;
|
||||
@apply cursor-pointer rounded-[8px] px-3 py-2 text-[13px] text-slate-700 dark:text-slate-100 !important;
|
||||
}
|
||||
|
||||
.react-select__option--is-focused {
|
||||
@apply dark:bg-dark-800 dark:text-white hover:dark:bg-dark-700 hover:dark:text-white !important;
|
||||
.react-select__option--is-focused {
|
||||
@apply bg-slate-100 text-slate-900 dark:bg-dark-700 dark:text-white !important;
|
||||
}
|
||||
|
||||
.react-select__option--is-selected, .react-select__option--is-selected:hover {
|
||||
@apply dark:bg-dark-600 !important;
|
||||
.react-select__option--is-selected,
|
||||
.react-select__option--is-selected:hover {
|
||||
@apply bg-slate-900 text-white dark:bg-dark-600 !important;
|
||||
}
|
||||
|
||||
.react-select__multi-value {
|
||||
@apply rounded-[8px] border border-slate-200 bg-slate-50 dark:border-dark-700 dark:bg-dark-700 !important;
|
||||
}
|
||||
|
||||
.react-select__multi-value__label {
|
||||
@apply text-[12px] text-slate-700 dark:text-slate-100 !important;
|
||||
}
|
||||
|
||||
.react-select__multi-value__remove {
|
||||
@apply dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important;
|
||||
@apply rounded-r-[8px] text-slate-500 hover:bg-slate-200 hover:text-slate-900 dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important;
|
||||
}
|
||||
|
||||
200
frontend/src/index.tsx
Normal file
200
frontend/src/index.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const quickPoints = [
|
||||
'Calm, chat-first workspace',
|
||||
'Conversation history that stays out of the way',
|
||||
'Backoffice still included when you need it',
|
||||
];
|
||||
|
||||
const previewThreads = [
|
||||
{
|
||||
title: 'Launch prep',
|
||||
summary: 'Define the MVP checklist and the admin follow-up tasks.',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: 'API cleanup',
|
||||
summary: 'Tighten validation and error handling in the route layer.',
|
||||
},
|
||||
{
|
||||
title: 'Spec drafting',
|
||||
summary: 'Turn rough notes into a short product spec with decisions.',
|
||||
desktopOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('AI Chat Workspace')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f6f1_0%,#f2f4f8_52%,#f8f5ef_100%)] text-slate-900">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-[-10rem] top-[-7rem] h-[24rem] w-[24rem] rounded-full bg-[#f7d9c7]/55 blur-3xl" />
|
||||
<div className="absolute right-[-6rem] top-[7rem] h-[26rem] w-[26rem] rounded-full bg-[#d7defd]/65 blur-3xl" />
|
||||
<div className="absolute bottom-[-10rem] left-[32%] h-[22rem] w-[22rem] rounded-full bg-[#e6efe6]/55 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<header className="relative z-10 mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||
<Link className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-500" href="/">
|
||||
AI Chat Workspace
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link
|
||||
className="rounded-md border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)]"
|
||||
href="/workspace"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto w-full max-w-7xl px-6 pb-16 pt-6 lg:px-10 lg:pb-24 lg:pt-10">
|
||||
<section className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_460px] lg:items-start xl:grid-cols-[minmax(0,1fr)_540px] xl:items-center">
|
||||
<div className="max-w-[38rem] xl:max-w-3xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-400">
|
||||
Chat-first product shell
|
||||
</p>
|
||||
<h1 className="mt-6 max-w-4xl text-5xl font-semibold tracking-[-0.075em] text-slate-900 md:text-6xl lg:text-[4.5rem] lg:leading-[0.94] xl:text-[5.2rem]">
|
||||
A more human workspace for thinking with an AI agent.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-600 md:text-lg">
|
||||
Start a new chat, reopen older threads, switch agents, and keep work moving without
|
||||
losing context. The interface stays calm and legible so the conversation can stay
|
||||
central.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
className="rounded-md bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)]"
|
||||
href="/workspace"
|
||||
>
|
||||
Try the workspace
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-md border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:max-w-2xl">
|
||||
{quickPoints.map((point) => (
|
||||
<div
|
||||
className="inline-flex w-fit items-center rounded-md border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
|
||||
key={point}
|
||||
>
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="relative lg:pt-2">
|
||||
<div className="absolute inset-4 rounded-[16px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-[18px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
|
||||
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
||||
Workspace preview
|
||||
</p>
|
||||
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
|
||||
History stays visible, but the active conversation keeps the spotlight.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
|
||||
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
|
||||
<button
|
||||
className="inline-flex w-full items-center justify-center rounded-md bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
|
||||
type="button"
|
||||
>
|
||||
New chat
|
||||
</button>
|
||||
|
||||
<div className="mt-4 space-y-2.5 xl:space-y-3">
|
||||
{previewThreads.map((thread) => (
|
||||
<div
|
||||
className={`rounded-[10px] border px-3 py-3 xl:px-3.5 xl:py-3.5 ${
|
||||
thread.active
|
||||
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
|
||||
: 'border-slate-200/90 bg-white/85 text-slate-900'
|
||||
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
|
||||
key={thread.title}
|
||||
>
|
||||
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
|
||||
<p
|
||||
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
|
||||
thread.active ? 'text-slate-300' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{thread.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
|
||||
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
||||
Current conversation
|
||||
</p>
|
||||
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
|
||||
Draft a launch checklist
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
|
||||
<div className="ml-auto max-w-[88%] rounded-[12px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:px-4.5 xl:py-4.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
|
||||
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
|
||||
Build a concise launch checklist for the MVP and include the most
|
||||
important backoffice follow-ups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[94%] rounded-[12px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:px-4.5 xl:py-4.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
|
||||
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
|
||||
<p className="font-semibold text-slate-900">Launch checklist</p>
|
||||
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
|
||||
<p>- Confirm agent selection and conversation persistence.</p>
|
||||
<p className="hidden xl:block">
|
||||
- Review visibility of users, conversations, messages, and usage events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
|
||||
<div className="rounded-[10px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:py-3 xl:text-sm">
|
||||
Ask for a draft, plan, code snippet, or product spec…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,6 +1,14 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import {
|
||||
mdiForwardburger,
|
||||
mdiBackburger,
|
||||
mdiMenu,
|
||||
mdiAccountCircleOutline,
|
||||
mdiChevronDown,
|
||||
mdiCogOutline,
|
||||
mdiLogout,
|
||||
} from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
import menuNavBar from '../menuNavBar'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
@ -12,6 +20,8 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Search from '../components/Search';
|
||||
import { useRouter } from 'next/router'
|
||||
import {findMe, logoutUser} from "../stores/authSlice";
|
||||
import Link from 'next/link';
|
||||
import ClickOutside from '../components/ClickOutside';
|
||||
|
||||
import {hasPermission} from "../helpers/userPermissions";
|
||||
|
||||
@ -33,6 +43,8 @@ export default function LayoutAuthenticated({
|
||||
const router = useRouter()
|
||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||
const isWorkspaceRoute = router.pathname.startsWith('/workspace');
|
||||
const workspaceNavMenu = isWorkspaceRoute ? [] : menuNavBar;
|
||||
let localToken
|
||||
if (typeof window !== 'undefined') {
|
||||
// Perform localStorage action
|
||||
@ -68,11 +80,14 @@ export default function LayoutAuthenticated({
|
||||
|
||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
|
||||
const workspaceAccountMenuButtonRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChangeStart = () => {
|
||||
setIsAsideMobileExpanded(false)
|
||||
setIsAsideLgActive(false)
|
||||
setIsWorkspaceAccountMenuOpen(false)
|
||||
}
|
||||
|
||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||
@ -85,43 +100,103 @@ export default function LayoutAuthenticated({
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-60'
|
||||
const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${
|
||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
className={`${layoutAsidePadding} ${layoutOffsetClass} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
menu={workspaceNavMenu}
|
||||
className={`${layoutAsidePadding} ${layoutOffsetClass}`}
|
||||
contentClassName={isWorkspaceRoute ? 'w-full' : undefined}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden lg:flex xl:hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
{isWorkspaceRoute ? (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between px-4 sm:px-6">
|
||||
<Link
|
||||
className="truncate text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300"
|
||||
href="/workspace"
|
||||
>
|
||||
AI Chat Workspace
|
||||
</Link>
|
||||
<div className="relative hidden md:block">
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-[8px] border border-slate-200 bg-white px-3 py-1.5 text-[12px] text-slate-500"
|
||||
onClick={() => setIsWorkspaceAccountMenuOpen((previous) => !previous)}
|
||||
ref={workspaceAccountMenuButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<BaseIcon path={mdiAccountCircleOutline} size="18" />
|
||||
<span className="max-w-[220px] truncate">
|
||||
{currentUser?.email || 'Workspace user'}
|
||||
</span>
|
||||
<BaseIcon path={mdiChevronDown} size="16" />
|
||||
</button>
|
||||
{isWorkspaceAccountMenuOpen && (
|
||||
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-40 min-w-[220px]">
|
||||
<ClickOutside
|
||||
excludedElements={[workspaceAccountMenuButtonRef]}
|
||||
onClickOutside={() => setIsWorkspaceAccountMenuOpen(false)}
|
||||
>
|
||||
<div className="overflow-hidden rounded-[10px] border border-slate-200 bg-white p-1 shadow-[0_16px_48px_-32px_rgba(15,23,42,0.25)]">
|
||||
<Link
|
||||
className="flex items-center gap-2 rounded-[8px] px-3 py-2 text-[13px] text-slate-700"
|
||||
href="/profile"
|
||||
onClick={() => setIsWorkspaceAccountMenuOpen(false)}
|
||||
>
|
||||
<BaseIcon path={mdiCogOutline} size="16" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-[8px] px-3 py-2 text-left text-[13px] text-slate-700"
|
||||
onClick={() => {
|
||||
setIsWorkspaceAccountMenuOpen(false)
|
||||
dispatch(logoutUser())
|
||||
router.push('/login')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<BaseIcon path={mdiLogout} size="16" />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</ClickOutside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden lg:flex xl:hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</>
|
||||
)}
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{!isWorkspaceRoute && (
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
{!isWorkspaceRoute && <FooterBar>Hand-crafted & Made with ❤️</FooterBar>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
224
frontend/src/login.tsx
Normal file
224
frontend/src/login.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-toastify';
|
||||
import { mdiArrowRight, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
const demoAccounts = [
|
||||
{
|
||||
label: 'Admin workspace',
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '7fd6af4a',
|
||||
},
|
||||
{
|
||||
label: 'User workspace',
|
||||
email: 'client@hello.com',
|
||||
password: 'e92fbaf31efb',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState({
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '7fd6af4a',
|
||||
remember: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(findMe());
|
||||
}
|
||||
}, [token, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.id) {
|
||||
router.push('/workspace');
|
||||
}
|
||||
}, [currentUser?.id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
notify('error', errorMessage);
|
||||
}
|
||||
}, [errorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification);
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const { remember, ...rest } = value;
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
const applyDemoAccount = (email: string, password: string) => {
|
||||
setInitialValues({
|
||||
email,
|
||||
password,
|
||||
remember: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[#f5f5f7] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-7xl overflow-hidden rounded-[16px] border border-slate-200 bg-white shadow-[0_32px_120px_-48px_rgba(15,23,42,0.28)]">
|
||||
<section className="hidden border-r border-slate-200 bg-[#fafafa] lg:flex lg:w-[46%] lg:flex-col lg:justify-between lg:p-10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400">
|
||||
AI Chat Workspace
|
||||
</p>
|
||||
<h1 className="mt-5 text-5xl font-semibold tracking-[-0.06em] text-slate-900">
|
||||
Sign in to your workspace.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-8 text-slate-500">
|
||||
A calm place for long-running chats, structured drafts, code help, and agent-guided
|
||||
work. Conversation history stays organized on the left, while the main canvas stays
|
||||
focused on the active thread.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[10px] border border-slate-200 bg-white p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Why this feels clearer
|
||||
</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-7 text-slate-600">
|
||||
<li>One main action: start or continue a conversation.</li>
|
||||
<li>History is visible, but secondary to the active chat.</li>
|
||||
<li>Backoffice tools stay available without taking over the product.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-slate-200 bg-slate-900 p-6 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/50">
|
||||
Demo access
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-200">
|
||||
Use one of the demo accounts on the right to enter the workspace immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex w-full items-center justify-center px-5 py-8 sm:px-8 lg:w-[54%] lg:px-12">
|
||||
<div className="w-full max-w-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400 lg:hidden">
|
||||
AI Chat Workspace
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-slate-900 sm:text-4xl">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-500">
|
||||
Sign in to open your workspace, continue previous chats, and start new threads.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{demoAccounts.map((account) => (
|
||||
<button
|
||||
className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4 text-left"
|
||||
key={account.email}
|
||||
onClick={() => applyDemoAccount(account.email, account.password)}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{account.label}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{account.email}</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-400">{account.password}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[12px] border border-slate-200 bg-white p-6">
|
||||
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
|
||||
<Form className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Email
|
||||
</label>
|
||||
<Field
|
||||
className="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 outline-none focus:border-slate-900"
|
||||
name="email"
|
||||
placeholder="you@company.com"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Password
|
||||
</label>
|
||||
<Field
|
||||
className="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-3 pr-12 text-slate-900 outline-none focus:border-slate-900"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
<button
|
||||
className="absolute right-4 top-[2.85rem] text-slate-400"
|
||||
onClick={() => setShowPassword((previous) => !previous)}
|
||||
type="button"
|
||||
>
|
||||
<BaseIcon path={showPassword ? mdiEyeOff : mdiEye} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="inline-flex items-center gap-3">
|
||||
<Field className="rounded border-slate-300" name="remember" type="checkbox" />
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
<Link className="font-medium text-slate-700" href="/forgot">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-slate-900 px-5 py-3.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isFetching}
|
||||
type="submit"
|
||||
>
|
||||
{isFetching ? 'Signing in…' : 'Sign in'}
|
||||
<BaseIcon path={mdiArrowRight} size={18} />
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Don't have an account yet?{' '}
|
||||
<Link className="font-medium text-slate-700" href="/register">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Login.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,14 +1,7 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChatOutline' in icon ? icon['mdiChatOutline' as keyof typeof icon] : icon.mdiTable,
|
||||
label: 'Workspace',
|
||||
},
|
||||
const backofficeMenu: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
@ -78,11 +71,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: 'mdiChartTimelineVariant' in icon ? icon['mdiChartTimelineVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_USAGE_EVENTS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Settings',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
@ -92,4 +80,25 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChatOutline' in icon ? icon['mdiChatOutline' as keyof typeof icon] : icon.mdiTable,
|
||||
label: 'Workspace',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Settings',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
icon: icon.mdiCogOutline,
|
||||
menu: backofficeMenu,
|
||||
permissions: 'READ_USERS'
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
|
||||
@ -88,13 +88,13 @@ const Dashboard = () => {
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{getPageTitle('Overview')}
|
||||
{getPageTitle('Admin')}
|
||||
</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title='Overview'
|
||||
title='Admin'
|
||||
main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
@ -375,7 +375,7 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
return <LayoutAuthenticated permission={'READ_USERS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
@ -17,7 +17,7 @@ export default function Error() {
|
||||
<SectionFullScreen bg="pinkRed">
|
||||
<CardBox
|
||||
className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl"
|
||||
footer={<BaseButton href="/dashboard" label="Done" color="danger" />}
|
||||
footer={<BaseButton href="/workspace" label="Open workspace" color="danger" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl">Unhandled exception</h1>
|
||||
|
||||
@ -4,29 +4,27 @@ import type { ReactElement } from 'react';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Focused chat workspace',
|
||||
description:
|
||||
'A dedicated AI workspace with conversation history on the left and a distraction-free composer at the bottom.',
|
||||
},
|
||||
{
|
||||
title: 'Markdown-ready replies',
|
||||
description:
|
||||
'Assistant responses support headings, bullets, inline code, and fenced code blocks for practical work.',
|
||||
},
|
||||
{
|
||||
title: 'Admin still included',
|
||||
description:
|
||||
'The generated admin area remains available for users, agents, conversations, and usage visibility.',
|
||||
},
|
||||
const quickPoints = [
|
||||
'Calm, chat-first workspace',
|
||||
'Conversation history that stays out of the way',
|
||||
'Backoffice still included when you need it',
|
||||
];
|
||||
|
||||
const workflow = [
|
||||
'Sign in and open the workspace',
|
||||
'Start a new chat or reopen a past conversation',
|
||||
'Send a message and receive a markdown-friendly assistant draft',
|
||||
'Rename, archive, or delete chats as your history grows',
|
||||
const previewThreads = [
|
||||
{
|
||||
title: 'Launch prep',
|
||||
summary: 'Define the MVP checklist and the admin follow-up tasks.',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: 'API cleanup',
|
||||
summary: 'Tighten validation and error handling in the route layer.',
|
||||
},
|
||||
{
|
||||
title: 'Spec drafting',
|
||||
summary: 'Turn rough notes into a short product spec with decisions.',
|
||||
desktopOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
@ -36,178 +34,162 @@ export default function LandingPage() {
|
||||
<title>{getPageTitle('AI Chat Workspace')}</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,#1E3A8A_0%,#0F172A_36%,#020617_100%)] text-white">
|
||||
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||
<Link className="text-sm font-semibold uppercase tracking-[0.32em] text-[#7DD3FC]" href="/">
|
||||
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f6f1_0%,#f2f4f8_52%,#f8f5ef_100%)] text-slate-900">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-[-10rem] top-[-7rem] h-[24rem] w-[24rem] rounded-full bg-[#f7d9c7]/55 blur-3xl" />
|
||||
<div className="absolute right-[-6rem] top-[7rem] h-[26rem] w-[26rem] rounded-full bg-[#d7defd]/65 blur-3xl" />
|
||||
<div className="absolute bottom-[-10rem] left-[32%] h-[22rem] w-[22rem] rounded-full bg-[#e6efe6]/55 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<header className="relative z-10 mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||
<Link className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-500" href="/">
|
||||
AI Chat Workspace
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
|
||||
className="rounded-full border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur transition hover:bg-white"
|
||||
href="/login"
|
||||
>
|
||||
Login
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
|
||||
href="/register"
|
||||
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
|
||||
href="/workspace"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-4 py-2 text-sm font-medium text-white shadow-[0_16px_40px_-22px_rgba(37,99,235,0.95)] transition hover:bg-[#1D4ED8]"
|
||||
href="/dashboard"
|
||||
>
|
||||
Admin area
|
||||
Open workspace
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto grid w-full max-w-7xl gap-12 px-6 pb-16 pt-8 lg:grid-cols-[1.1fr_minmax(0,0.9fr)] lg:px-10 lg:pb-24 lg:pt-12">
|
||||
<section className="flex flex-col justify-center">
|
||||
<p className="mb-4 text-xs font-medium uppercase tracking-[0.32em] text-[#7DD3FC]">
|
||||
Custom SaaS landing · first product slice
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-5xl font-semibold tracking-[-0.06em] text-white md:text-6xl lg:text-7xl">
|
||||
A modern AI agent workspace for focused conversations.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">
|
||||
The app now leads with a clean chat experience instead of a generic dashboard: open a
|
||||
workspace, start a conversation, receive markdown-friendly replies, and keep your chat
|
||||
history organized over time.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||
<Link
|
||||
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-6 py-3 text-sm font-medium text-white shadow-[0_18px_50px_-24px_rgba(37,99,235,0.95)] transition hover:bg-[#1D4ED8]"
|
||||
href="/workspace"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full border border-white/10 px-6 py-3 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
|
||||
href="/login"
|
||||
>
|
||||
Sign in to continue
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_50px_-36px_rgba(15,23,42,0.95)]"
|
||||
key={feature.title}
|
||||
>
|
||||
<h2 className="text-lg font-semibold tracking-[-0.03em] text-white">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[34px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0.02))] p-4 shadow-[0_36px_90px_-48px_rgba(15,23,42,0.95)] backdrop-blur">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#060913] p-4">
|
||||
<div className="grid min-h-[34rem] gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="mb-4 rounded-full border border-[#60A5FA]/30 bg-[#0F172A] px-4 py-2 text-xs uppercase tracking-[0.24em] text-[#7DD3FC]">
|
||||
Conversations
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[18px] border border-[#60A5FA]/30 bg-[#132142] px-4 py-3">
|
||||
<p className="text-sm font-medium text-white">Launch prep</p>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-300">
|
||||
Drafting the release checklist for the AI workspace MVP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<p className="text-sm font-medium text-white">Code review follow-up</p>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-400">
|
||||
Asking the assistant to improve a route handler and validation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<p className="text-sm font-medium text-white">Settings polish</p>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-400">
|
||||
Defining the next iteration for profile and preference updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-[22px] border border-white/10 bg-[radial-gradient(circle_at_top_left,#18264F_0%,#090D1C_48%,#060913_100%)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Workspace preview</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
Markdown-friendly assistant replies, built for real work.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 px-5 py-5">
|
||||
<div className="ml-auto max-w-[85%] rounded-[24px] border border-[#4F7CFF]/30 bg-[linear-gradient(135deg,#2563EB_0%,#1D4ED8_100%)] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(37,99,235,0.95)]">
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-white/70">You</p>
|
||||
<p className="mt-3 text-sm leading-7 text-white">
|
||||
Build a short launch checklist for an AI workspace MVP and include the most
|
||||
important admin follow-up tasks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-[90%] rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-slate-400">Assistant</p>
|
||||
<div className="mt-3 space-y-3 text-sm leading-7 text-slate-100">
|
||||
<p>### Launch checklist</p>
|
||||
<p>- Confirm auth and workspace routing</p>
|
||||
<p>- Validate conversation create / rename / archive / delete flows</p>
|
||||
<p>- Review admin visibility for users, conversations, agents, and usage</p>
|
||||
<div className="rounded-2xl border border-white/10 bg-[#050816] p-4 text-xs text-[#CDE4FF]">
|
||||
<p className="uppercase tracking-[0.24em] text-slate-400">code</p>
|
||||
<p className="mt-3 font-mono">POST /workspace/conversations/:id/messages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-5 py-4 text-sm text-slate-400">
|
||||
Designed for productivity on desktop and tablet, with the admin area still linked
|
||||
from the public site.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="mx-auto w-full max-w-7xl px-6 pb-20 lg:px-10">
|
||||
<div className="grid gap-6 rounded-[34px] border border-white/10 bg-white/[0.03] p-8 lg:grid-cols-[1fr_auto] lg:items-center lg:p-10">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.32em] text-[#7DD3FC]">
|
||||
How the MVP flows
|
||||
<main className="relative z-10 mx-auto w-full max-w-7xl px-6 pb-16 pt-6 lg:px-10 lg:pb-24 lg:pt-10">
|
||||
<section className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_460px] lg:items-start xl:grid-cols-[minmax(0,1fr)_540px] xl:items-center">
|
||||
<div className="max-w-[38rem] xl:max-w-3xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-400">
|
||||
Chat-first product shell
|
||||
</p>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
{workflow.map((step) => (
|
||||
<h1 className="mt-6 max-w-4xl text-5xl font-semibold tracking-[-0.075em] text-slate-900 md:text-6xl lg:text-[4.5rem] lg:leading-[0.94] xl:text-[5.2rem]">
|
||||
A more human workspace for thinking with an AI agent.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-600 md:text-lg">
|
||||
Start a new chat, reopen older threads, switch agents, and keep work moving without
|
||||
losing context. The interface stays calm and legible so the conversation can stay
|
||||
central.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
|
||||
href="/workspace"
|
||||
>
|
||||
Try the workspace
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur transition hover:bg-white"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:max-w-2xl">
|
||||
{quickPoints.map((point) => (
|
||||
<div
|
||||
className="rounded-[24px] border border-white/10 bg-[#091224]/80 px-5 py-4 text-sm leading-7 text-slate-200"
|
||||
key={step}
|
||||
className="inline-flex w-fit items-center rounded-full border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
|
||||
key={point}
|
||||
>
|
||||
{step}
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:w-[240px]">
|
||||
<Link
|
||||
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-6 py-3 text-center text-sm font-medium text-white transition hover:bg-[#1D4ED8]"
|
||||
href="/workspace"
|
||||
>
|
||||
Try the workspace
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full border border-white/10 px-6 py-3 text-center text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
|
||||
href="/dashboard"
|
||||
>
|
||||
Open admin interface
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative lg:pt-2">
|
||||
<div className="absolute inset-4 rounded-[30px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-[34px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
|
||||
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
||||
Workspace preview
|
||||
</p>
|
||||
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
|
||||
History stays visible, but the active conversation keeps the spotlight.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
|
||||
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
|
||||
<button
|
||||
className="inline-flex w-full items-center justify-center rounded-2xl bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
|
||||
type="button"
|
||||
>
|
||||
New chat
|
||||
</button>
|
||||
|
||||
<div className="mt-4 space-y-2.5 xl:space-y-3">
|
||||
{previewThreads.map((thread) => (
|
||||
<div
|
||||
className={`rounded-[20px] border px-3 py-3 transition xl:px-3.5 xl:py-3.5 ${
|
||||
thread.active
|
||||
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
|
||||
: 'border-slate-200/90 bg-white/85 text-slate-900'
|
||||
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
|
||||
key={thread.title}
|
||||
>
|
||||
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
|
||||
<p
|
||||
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
|
||||
thread.active ? 'text-slate-300' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{thread.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
|
||||
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
|
||||
Current conversation
|
||||
</p>
|
||||
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
|
||||
Draft a launch checklist
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
|
||||
<div className="ml-auto max-w-[88%] rounded-[22px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
|
||||
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
|
||||
Build a concise launch checklist for the MVP and include the most
|
||||
important backoffice follow-ups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[94%] rounded-[22px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
|
||||
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
|
||||
<p className="font-semibold text-slate-900">Launch checklist</p>
|
||||
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
|
||||
<p>- Confirm agent selection and conversation persistence.</p>
|
||||
<p className="hidden xl:block">
|
||||
- Review visibility of users, conversations, messages, and usage events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
|
||||
<div className="rounded-[20px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:rounded-[22px] xl:py-3 xl:text-sm">
|
||||
Ask for a draft, plan, code snippet, or product spec…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,273 +1,221 @@
|
||||
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-toastify';
|
||||
import { mdiArrowRight, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
|
||||
const demoAccounts = [
|
||||
{
|
||||
label: 'Admin workspace',
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '7fd6af4a',
|
||||
},
|
||||
{
|
||||
label: 'User workspace',
|
||||
email: 'client@hello.com',
|
||||
password: 'e92fbaf31efb',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState({
|
||||
email: 'admin@flatlogic.com',
|
||||
password: '7fd6af4a',
|
||||
remember: true })
|
||||
remember: true,
|
||||
});
|
||||
|
||||
const title = 'AI Chat Workspace'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage()
|
||||
const video = await getPexelsVideo()
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(findMe());
|
||||
}
|
||||
}, [token, dispatch]);
|
||||
// Redirect to dashboard if user is logged in
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.id) {
|
||||
router.push('/workspace');
|
||||
}
|
||||
}, [currentUser?.id, router]);
|
||||
// Show error message if there is one
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage){
|
||||
notify('error', errorMessage)
|
||||
if (errorMessage) {
|
||||
notify('error', errorMessage);
|
||||
}
|
||||
}, [errorMessage]);
|
||||
|
||||
}, [errorMessage])
|
||||
// Show notification if there is one
|
||||
useEffect(() => {
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification)
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification])
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification);
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const {remember, ...rest} = value
|
||||
const { remember, ...rest } = value;
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
email : target.innerText.trim(),
|
||||
password: target.dataset.password ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
||||
style={{
|
||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}>
|
||||
<div className="flex justify-center w-full bg-blue-300/20">
|
||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
||||
by {image?.photographer} on Pexels</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video.user.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
const applyDemoAccount = (email: string, password: string) => {
|
||||
setInitialValues({
|
||||
email,
|
||||
password,
|
||||
remember: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={contentPosition === 'background' ? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
} : {}}>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
|
||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||
|
||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||
|
||||
<div className='flex flex-row text-gray-500 justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="7fd6af4a"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>7fd6af4a</code>{' / '}
|
||||
to login as Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="e92fbaf31efb"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>e92fbaf31efb</code>{' / '}
|
||||
to login as User</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={mdiInformation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
||||
onClick={togglePasswordVisibility}
|
||||
>
|
||||
<BaseIcon
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
size={20}
|
||||
path={showPassword ? mdiEyeOff : mdiEye}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
<div className="min-h-screen bg-[#f5f5f7] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-7xl overflow-hidden rounded-[32px] border border-slate-200 bg-white shadow-[0_32px_120px_-48px_rgba(15,23,42,0.28)]">
|
||||
<section className="hidden border-r border-slate-200 bg-[#fafafa] lg:flex lg:w-[46%] lg:flex-col lg:justify-between lg:p-10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400">
|
||||
AI Chat Workspace
|
||||
</p>
|
||||
<h1 className="mt-5 text-5xl font-semibold tracking-[-0.06em] text-slate-900">
|
||||
Sign in to your workspace.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-8 text-slate-500">
|
||||
A calm place for long-running chats, structured drafts, code help, and agent-guided
|
||||
work. Conversation history stays organized on the left, while the main canvas stays
|
||||
focused on the active thread.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[28px] border border-slate-200 bg-white p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Why this feels clearer
|
||||
</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-7 text-slate-600">
|
||||
<li>One main action: start or continue a conversation.</li>
|
||||
<li>History is visible, but secondary to the active chat.</li>
|
||||
<li>Backoffice tools stay available without taking over the product.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-slate-200 bg-slate-900 p-6 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/50">
|
||||
Demo access
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-200">
|
||||
Use one of the demo accounts on the right to enter the workspace immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex w-full items-center justify-center px-5 py-8 sm:px-8 lg:w-[54%] lg:px-12">
|
||||
<div className="w-full max-w-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400 lg:hidden">
|
||||
AI Chat Workspace
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-slate-900 sm:text-4xl">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-500">
|
||||
Sign in to open your workspace, continue previous chats, and start new threads.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{demoAccounts.map((account) => (
|
||||
<button
|
||||
className="rounded-[24px] border border-slate-200 bg-slate-50 px-4 py-4 text-left transition hover:bg-slate-100"
|
||||
key={account.email}
|
||||
onClick={() => applyDemoAccount(account.email, account.password)}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{account.label}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{account.email}</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-400">{account.password}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[28px] border border-slate-200 bg-white p-6">
|
||||
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
|
||||
<Form className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Email
|
||||
</label>
|
||||
<Field
|
||||
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 outline-none transition focus:border-slate-900"
|
||||
name="email"
|
||||
placeholder="you@company.com"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
Password
|
||||
</label>
|
||||
<Field
|
||||
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 pr-12 text-slate-900 outline-none transition focus:border-slate-900"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
<button
|
||||
className="absolute right-4 top-[2.85rem] text-slate-400 transition hover:text-slate-700"
|
||||
onClick={() => setShowPassword((previous) => !previous)}
|
||||
type="button"
|
||||
>
|
||||
<BaseIcon path={showPassword ? mdiEyeOff : mdiEye} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="inline-flex items-center gap-3">
|
||||
<Field className="rounded border-slate-300" name="remember" type="checkbox" />
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
<Link className="font-medium text-slate-700 transition hover:text-slate-900" href="/forgot">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-5 py-3.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isFetching}
|
||||
type="submit"
|
||||
>
|
||||
{isFetching ? 'Signing in…' : 'Sign in'}
|
||||
<BaseIcon path={mdiArrowRight} size={18} />
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Don't have an account yet?{' '}
|
||||
<Link className="font-medium text-slate-700 transition hover:text-slate-900" href="/register">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user