This commit is contained in:
Flatlogic Bot 2026-05-14 11:23:22 +00:00
parent 835fde1e48
commit 9eaef58312
13 changed files with 2410 additions and 161 deletions

View File

@ -814,7 +814,7 @@ const MessagesData = [
"content_markdown": "## Goals
"content_markdown": `## Goals
- Improve chat reliability
- Reduce latency
@ -828,7 +828,7 @@ const MessagesData = [
## Milestones
- Week 2: telemetry
- Week 6: search beta",
- Week 6: search beta`,
@ -1004,12 +1004,12 @@ const MessagesData = [
"content_markdown": "Checklist:
"content_markdown": `Checklist:
- Set Content-Type to text/event-stream
- Disable proxy buffering
- Send periodic heartbeats
- Ensure client parser handles partial chunks
- Watch Node backpressure",
- Watch Node backpressure`,

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -36,6 +35,7 @@ const messagesRoutes = require('./routes/messages');
const attachmentsRoutes = require('./routes/attachments');
const usage_eventsRoutes = require('./routes/usage_events');
const workspaceRoutes = require('./routes/workspace');
const getBaseUrl = (url) => {
@ -110,6 +110,7 @@ app.use('/api/messages', passport.authenticate('jwt', {session: false}), message
app.use('/api/attachments', passport.authenticate('jwt', {session: false}), attachmentsRoutes);
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
app.use('/api/workspace', passport.authenticate('jwt', { session: false }), workspaceRoutes);
app.use(
'/api/openai',

View File

@ -0,0 +1,60 @@
const express = require('express');
const WorkspaceService = require('../services/workspace');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get(
'/bootstrap',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.bootstrap(req.currentUser);
res.status(200).send(payload);
}),
);
router.post(
'/conversations',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.createConversation(req.body, req.currentUser);
res.status(200).send(payload);
}),
);
router.get(
'/conversations/:id',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.getConversation(req.params.id, req.currentUser);
res.status(200).send(payload);
}),
);
router.patch(
'/conversations/:id',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.updateConversation(
req.params.id,
req.body,
req.currentUser,
);
res.status(200).send(payload);
}),
);
router.delete(
'/conversations/:id',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.deleteConversation(req.params.id, req.currentUser);
res.status(200).send(payload);
}),
);
router.post(
'/conversations/:id/messages',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.sendMessage(req.params.id, req.body, req.currentUser);
res.status(200).send(payload);
}),
);
module.exports = router;

View File

@ -0,0 +1,725 @@
const db = require('../db/models');
const ValidationError = require('./notifications/errors/validation');
const { Op } = db.Sequelize;
const DEFAULT_CONVERSATION_TITLE = 'New conversation';
const MAX_MESSAGE_LENGTH = 8000;
const MAX_TITLE_LENGTH = 120;
function normalizeText(value) {
if (typeof value !== 'string') {
return '';
}
return value.replace(/\r\n/g, '\n').trim();
}
function cleanMarkdownPreview(value) {
return normalizeText(value)
.replace(/```[\s\S]*?```/g, ' code example ')
.replace(/`([^`]+)`/g, '$1')
.replace(/[>#*_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildConversationTitle(content) {
const normalized = cleanMarkdownPreview(content);
if (!normalized) {
return DEFAULT_CONVERSATION_TITLE;
}
if (normalized.length <= 48) {
return normalized;
}
return `${normalized.slice(0, 45)}...`;
}
function buildSummary(content) {
const normalized = cleanMarkdownPreview(content);
if (!normalized) {
return null;
}
if (normalized.length <= 140) {
return normalized;
}
return `${normalized.slice(0, 137)}...`;
}
function estimateTokens(content) {
const normalized = normalizeText(content);
if (!normalized) {
return 0;
}
return Math.max(1, Math.ceil(normalized.length / 4));
}
function buildDraftParagraph(message, agentName) {
const excerpt = cleanMarkdownPreview(message).slice(0, 220) || 'your request';
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;',
'};',
'',
'export function respond(prompt: string): Result {',
' return {',
" summary: `Working on: ${prompt.trim()}`,",
" nextStep: 'Refine the prompt or ask for implementation details.',",
' };',
'}',
'```',
].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,
);
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 (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');
}
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');
}
function serializeAgent(agent) {
if (!agent) {
return null;
}
return {
id: agent.id,
name: agent.name,
description: agent.description,
model: agent.model,
is_default: agent.is_default,
};
}
function serializeMessage(message) {
return {
id: message.id,
role: message.role,
content: message.content,
content_markdown: message.content_markdown,
delivery_status: message.delivery_status,
sent_at: message.sent_at,
completed_at: message.completed_at,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
sequence: message.sequence,
author_user: message.author_user
? {
id: message.author_user.id,
firstName: message.author_user.firstName,
lastName: message.author_user.lastName,
email: message.author_user.email,
}
: null,
};
}
function serializeConversationSummary(conversation) {
return {
id: conversation.id,
title: conversation.title || DEFAULT_CONVERSATION_TITLE,
summary: conversation.summary,
status: conversation.status,
is_pinned: conversation.is_pinned,
last_message_at: conversation.last_message_at,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
agent: serializeAgent(conversation.agent),
};
}
function serializeConversationDetail(conversation) {
return {
...serializeConversationSummary(conversation),
client_context_json: conversation.client_context_json,
messages: Array.isArray(conversation.messages_conversation)
? conversation.messages_conversation.map(serializeMessage)
: [],
};
}
function sortConversations(conversations) {
return [...conversations].sort((left, right) => {
if (Boolean(left.is_pinned) !== Boolean(right.is_pinned)) {
return left.is_pinned ? -1 : 1;
}
const leftDate = new Date(left.last_message_at || left.updatedAt || left.createdAt || 0).getTime();
const rightDate = new Date(right.last_message_at || right.updatedAt || right.createdAt || 0).getTime();
return rightDate - leftDate;
});
}
async function findDefaultAgent(transaction) {
const defaultAgent = await db.agents.findOne({
where: {
is_active: true,
is_default: true,
},
order: [['createdAt', 'ASC']],
transaction,
});
if (defaultAgent) {
return defaultAgent;
}
return db.agents.findOne({
where: {
is_active: true,
},
order: [['createdAt', 'ASC']],
transaction,
});
}
async function findOwnedConversation(id, currentUser, transaction) {
const conversation = await db.conversations.findOne({
where: {
id,
userId: currentUser.id,
status: {
[Op.ne]: 'deleted',
},
},
include: [
{
model: db.agents,
as: 'agent',
},
],
transaction,
});
if (!conversation) {
const error = new Error('Conversation not found');
error.code = 404;
throw error;
}
return conversation;
}
async function createUsageEvent(data, transaction, currentUser) {
await db.usage_events.create(
{
...data,
createdById: currentUser.id,
updatedById: currentUser.id,
userId: currentUser.id,
},
{ transaction },
);
}
module.exports = class WorkspaceService {
static async bootstrap(currentUser) {
const [agents, conversations] = await Promise.all([
db.agents.findAll({
where: {
is_active: true,
},
order: [
['is_default', 'DESC'],
['name', 'ASC'],
],
}),
db.conversations.findAll({
where: {
userId: currentUser.id,
status: {
[Op.ne]: 'deleted',
},
},
include: [
{
model: db.agents,
as: 'agent',
},
],
}),
]);
return {
agents: agents.map(serializeAgent),
conversations: sortConversations(conversations.map(serializeConversationSummary)),
};
}
static async getConversation(id, currentUser) {
const conversation = await db.conversations.findOne({
where: {
id,
userId: currentUser.id,
status: {
[Op.ne]: 'deleted',
},
},
include: [
{
model: db.agents,
as: 'agent',
},
{
model: db.messages,
as: 'messages_conversation',
separate: true,
order: [
['sequence', 'ASC'],
['createdAt', 'ASC'],
],
include: [
{
model: db.users,
as: 'author_user',
attributes: ['id', 'firstName', 'lastName', 'email'],
},
],
},
],
});
if (!conversation) {
const error = new Error('Conversation not found');
error.code = 404;
throw error;
}
return {
conversation: serializeConversationDetail(conversation),
};
}
static async createConversation(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const requestedTitle = normalizeText(data?.title).slice(0, MAX_TITLE_LENGTH);
let agent = null;
if (data?.agentId) {
agent = await db.agents.findOne({
where: {
id: data.agentId,
is_active: true,
},
transaction,
});
}
if (!agent) {
agent = await findDefaultAgent(transaction);
}
const conversation = await db.conversations.create(
{
title: requestedTitle || DEFAULT_CONVERSATION_TITLE,
summary: null,
status: 'active',
is_pinned: false,
last_message_at: null,
client_context_json: null,
userId: currentUser.id,
agentId: agent?.id || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
return this.getConversation(conversation.id, currentUser);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async updateConversation(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
const updatePayload = {
updatedById: currentUser.id,
};
if (data?.title !== undefined) {
const title = normalizeText(data.title);
if (!title) {
throw new ValidationError();
}
updatePayload.title = title.slice(0, MAX_TITLE_LENGTH);
}
if (data?.status !== undefined) {
if (!['active', 'archived'].includes(data.status)) {
throw new ValidationError();
}
updatePayload.status = data.status;
}
if (data?.is_pinned !== undefined) {
updatePayload.is_pinned = Boolean(data.is_pinned);
}
if (data?.agentId !== undefined) {
if (!data.agentId) {
updatePayload.agentId = null;
} else {
const agent = await db.agents.findOne({
where: {
id: data.agentId,
is_active: true,
},
transaction,
});
if (!agent) {
throw new ValidationError();
}
updatePayload.agentId = agent.id;
}
}
await conversation.update(updatePayload, { transaction });
await transaction.commit();
return this.getConversation(id, currentUser);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteConversation(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
const messages = await db.messages.findAll({
where: {
conversationId: conversation.id,
},
attributes: ['id'],
transaction,
});
const messageIds = messages.map((message) => message.id);
if (messageIds.length) {
await db.attachments.destroy({
where: {
messageId: {
[Op.in]: messageIds,
},
},
transaction,
});
await db.usage_events.destroy({
where: {
[Op.or]: [
{
conversationId: conversation.id,
},
{
messageId: {
[Op.in]: messageIds,
},
},
],
},
transaction,
});
await db.messages.destroy({
where: {
id: {
[Op.in]: messageIds,
},
},
transaction,
});
} else {
await db.usage_events.destroy({
where: {
conversationId: conversation.id,
},
transaction,
});
}
await conversation.update(
{
status: 'deleted',
updatedById: currentUser.id,
},
{ transaction },
);
await conversation.destroy({ transaction });
await transaction.commit();
return { success: true };
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async sendMessage(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
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);
let agent = conversation.agent;
if (data?.agentId) {
agent = await db.agents.findOne({
where: {
id: data.agentId,
is_active: true,
},
transaction,
});
if (!agent) {
throw new ValidationError();
}
await conversation.update(
{
agentId: agent.id,
updatedById: currentUser.id,
},
{ transaction },
);
}
if (!agent) {
agent = await findDefaultAgent(transaction);
if (agent) {
await conversation.update(
{
agentId: agent.id,
updatedById: currentUser.id,
},
{ transaction },
);
}
}
const messageCount = await db.messages.count({
where: {
conversationId: conversation.id,
},
transaction,
});
const sentAt = new Date();
const assistantReply = buildAssistantReply({
message: content,
agent,
});
const userMessage = await db.messages.create(
{
role: 'user',
content,
content_markdown: content,
delivery_status: 'completed',
sent_at: sentAt,
completed_at: sentAt,
sequence: messageCount + 1,
conversationId: conversation.id,
author_userId: currentUser.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
const assistantMessage = await db.messages.create(
{
role: 'assistant',
content: assistantReply,
content_markdown: assistantReply,
delivery_status: 'completed',
sent_at: sentAt,
completed_at: new Date(sentAt.getTime() + 300),
sequence: messageCount + 2,
conversationId: conversation.id,
author_userId: currentUser.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
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 },
);
const inputTokens = estimateTokens(content);
const outputTokens = estimateTokens(assistantReply);
await createUsageEvent(
{
event_type: 'message_sent',
occurred_at: sentAt,
input_tokens: inputTokens,
output_tokens: 0,
total_tokens: inputTokens,
cost_usd: 0,
provider: 'workspace-shell',
model: agent?.model || 'shell-draft',
metadata_json: JSON.stringify({
role: 'user',
}),
conversationId: conversation.id,
messageId: userMessage.id,
agentId: agent?.id || null,
},
transaction,
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',
}),
conversationId: conversation.id,
messageId: assistantMessage.id,
agentId: agent?.id || null,
},
transaction,
currentUser,
);
await transaction.commit();
return this.getConversation(conversation.id, currentUser);
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View 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-md border border-white/10 bg-white/10 px-1.5 py-0.5 font-mono text-[0.95em] text-[#CDE4FF]"
>
{token.value}
</code>
);
}
if (token.type === 'bold') {
return (
<strong key={key} className="font-semibold text-white">
{token.value}
</strong>
);
}
if (token.type === 'italic') {
return (
<em key={key} className="italic text-slate-100">
{token.value}
</em>
);
}
if (token.type === 'link') {
return (
<a
key={key}
className="text-[#7DD3FC] underline underline-offset-4 transition hover:text-[#BAE6FD]"
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-white',
h2: 'text-xl font-semibold tracking-[-0.02em] text-white',
h3: 'text-lg font-semibold 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-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">
<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-2xl border border-[#38BDF8]/20 bg-[#0F172A] px-4 py-3 text-sm 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-100/95">
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
</p>,
);
}
return <div className={`space-y-4 ${className}`}>{blocks}</div>;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -2,12 +2,18 @@ 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',
},
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/users/users-list',
label: 'Users',
@ -74,11 +80,9 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/profile',
label: 'Profile',
label: 'Settings',
icon: icon.mdiAccountCircle,
},
{
href: '/api-docs',
target: '_blank',

View File

@ -1,166 +1,218 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import type { ReactElement } from 'react';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
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.',
},
];
export default function Starter() {
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 textColor = useAppSelector((state) => state.style.linkColor);
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();
}, []);
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 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',
];
export default function LandingPage() {
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('Starter Page')}</title>
<title>{getPageTitle('AI Chat Workspace')}</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 className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your AI Chat Workspace app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<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="/">
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]"
href="/login"
>
Login
</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"
>
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
</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>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</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>
<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>
</div>
<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
</p>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{workflow.map((step) => (
<div
className="rounded-[24px] border border-white/10 bg-[#091224]/80 px-5 py-4 text-sm leading-7 text-slate-200"
key={step}
>
{step}
</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>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -65,7 +65,7 @@ export default function Login() {
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/dashboard');
router.push('/workspace');
}
}, [currentUser?.id, router]);
// Show error message if there is one

View File

@ -0,0 +1,20 @@
import Head from 'next/head';
import type { ReactElement } from 'react';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { getPageTitle } from '../../config';
import WorkspaceShell from '../../components/Workspace/WorkspaceShell';
export default function WorkspaceConversationPage() {
return (
<>
<Head>
<title>{getPageTitle('Workspace')}</title>
</Head>
<WorkspaceShell />
</>
);
}
WorkspaceConversationPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};

View File

@ -0,0 +1,20 @@
import Head from 'next/head';
import type { ReactElement } from 'react';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { getPageTitle } from '../../config';
import WorkspaceShell from '../../components/Workspace/WorkspaceShell';
export default function WorkspaceIndexPage() {
return (
<>
<Head>
<title>{getPageTitle('Workspace')}</title>
</Head>
<WorkspaceShell />
</>
);
}
WorkspaceIndexPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};