1
This commit is contained in:
parent
835fde1e48
commit
9eaef58312
@ -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`,
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
60
backend/src/routes/workspace.js
Normal file
60
backend/src/routes/workspace.js
Normal 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;
|
||||
725
backend/src/services/workspace.js
Normal file
725
backend/src/services/workspace.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
|
||||
251
frontend/src/components/Workspace/ChatMarkdown.tsx
Normal file
251
frontend/src/components/Workspace/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-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>;
|
||||
}
|
||||
1118
frontend/src/components/Workspace/WorkspaceShell.tsx
Normal file
1118
frontend/src/components/Workspace/WorkspaceShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
20
frontend/src/pages/workspace/[conversationId].tsx
Normal file
20
frontend/src/pages/workspace/[conversationId].tsx
Normal 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>;
|
||||
};
|
||||
20
frontend/src/pages/workspace/index.tsx
Normal file
20
frontend/src/pages/workspace/index.tsx
Normal 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>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user