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
|
- Improve chat reliability
|
||||||
- Reduce latency
|
- Reduce latency
|
||||||
|
|
||||||
@ -828,7 +828,7 @@ const MessagesData = [
|
|||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
- Week 2: telemetry
|
- 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
|
- Set Content-Type to text/event-stream
|
||||||
- Disable proxy buffering
|
- Disable proxy buffering
|
||||||
- Send periodic heartbeats
|
- Send periodic heartbeats
|
||||||
- Ensure client parser handles partial chunks
|
- 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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -36,6 +35,7 @@ const messagesRoutes = require('./routes/messages');
|
|||||||
const attachmentsRoutes = require('./routes/attachments');
|
const attachmentsRoutes = require('./routes/attachments');
|
||||||
|
|
||||||
const usage_eventsRoutes = require('./routes/usage_events');
|
const usage_eventsRoutes = require('./routes/usage_events');
|
||||||
|
const workspaceRoutes = require('./routes/workspace');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/attachments', passport.authenticate('jwt', {session: false}), attachmentsRoutes);
|
||||||
|
|
||||||
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
|
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
|
||||||
|
app.use('/api/workspace', passport.authenticate('jwt', { session: false }), workspaceRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
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 React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -2,12 +2,18 @@ import * as icon from '@mdi/js';
|
|||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
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: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
@ -74,11 +80,9 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
label: 'Profile',
|
label: 'Settings',
|
||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/api-docs',
|
href: '/api-docs',
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
|
|||||||
@ -1,166 +1,218 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import type { ReactElement } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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 workflow = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
'Sign in and open the workspace',
|
||||||
src: undefined,
|
'Start a new chat or reopen a past conversation',
|
||||||
photographer: undefined,
|
'Send a message and receive a markdown-friendly assistant draft',
|
||||||
photographer_url: undefined,
|
'Rename, archive, or delete chats as your history grows',
|
||||||
})
|
];
|
||||||
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
return (
|
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('AI Chat Workspace')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,#1E3A8A_0%,#0F172A_36%,#020617_100%)] text-white">
|
||||||
<div
|
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||||
className={`flex ${
|
<Link className="text-sm font-semibold uppercase tracking-[0.32em] text-[#7DD3FC]" href="/">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
AI Chat Workspace
|
||||||
} min-h-screen w-full`}
|
</Link>
|
||||||
>
|
<nav className="flex items-center gap-3">
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<Link
|
||||||
? imageBlock(illustrationImage)
|
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]"
|
||||||
: null}
|
href="/login"
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
>
|
||||||
? videoBlock(illustrationVideo)
|
Login
|
||||||
: null}
|
</Link>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<Link
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
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]"
|
||||||
<CardBoxComponentTitle title="Welcome to your AI Chat Workspace app!"/>
|
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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
||||||
<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>
|
<section className="flex flex-col justify-center">
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<p className="mb-4 text-xs font-medium uppercase tracking-[0.32em] text-[#7DD3FC]">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
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>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
<BaseButton
|
{features.map((feature) => (
|
||||||
href='/login'
|
<div
|
||||||
label='Login'
|
className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_50px_-36px_rgba(15,23,42,0.95)]"
|
||||||
color='info'
|
key={feature.title}
|
||||||
className='w-full'
|
>
|
||||||
/>
|
<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>
|
||||||
|
|
||||||
</BaseButtons>
|
<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">
|
||||||
</CardBox>
|
<div className="rounded-[28px] border border-white/10 bg-[#060913] p-4">
|
||||||
</div>
|
<div className="grid min-h-[34rem] gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
</div>
|
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||||
</SectionFullScreen>
|
<div className="mb-4 rounded-full border border-[#60A5FA]/30 bg-[#0F172A] px-4 py-2 text-xs uppercase tracking-[0.24em] text-[#7DD3FC]">
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
Conversations
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
</div>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<div className="space-y-3">
|
||||||
Privacy Policy
|
<div className="rounded-[18px] border border-[#60A5FA]/30 bg-[#132142] px-4 py-3">
|
||||||
</Link>
|
<p className="text-sm font-medium text-white">Launch prep</p>
|
||||||
</div>
|
<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>
|
<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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export default function Login() {
|
|||||||
// Redirect to dashboard if user is logged in
|
// Redirect to dashboard if user is logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (currentUser?.id) {
|
||||||
router.push('/dashboard');
|
router.push('/workspace');
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
}, [currentUser?.id, router]);
|
||||||
// Show error message if there is one
|
// 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