From 9eaef583122a9cce54d01b21251c4f58d08e3b79 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 14 May 2026 11:23:22 +0000 Subject: [PATCH] 1 --- .../db/seeders/20231127130745-sample-data.js | 8 +- backend/src/index.js | 3 +- backend/src/routes/workspace.js | 60 + backend/src/services/workspace.js | 725 +++++++++++ frontend/src/components/NavBarItem.tsx | 3 +- .../src/components/Workspace/ChatMarkdown.tsx | 251 ++++ .../components/Workspace/WorkspaceShell.tsx | 1118 +++++++++++++++++ frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 12 +- frontend/src/pages/index.tsx | 346 ++--- frontend/src/pages/login.tsx | 2 +- .../src/pages/workspace/[conversationId].tsx | 20 + frontend/src/pages/workspace/index.tsx | 20 + 13 files changed, 2410 insertions(+), 161 deletions(-) create mode 100644 backend/src/routes/workspace.js create mode 100644 backend/src/services/workspace.js create mode 100644 frontend/src/components/Workspace/ChatMarkdown.tsx create mode 100644 frontend/src/components/Workspace/WorkspaceShell.tsx create mode 100644 frontend/src/pages/workspace/[conversationId].tsx create mode 100644 frontend/src/pages/workspace/index.tsx diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index b20c939..fa55986 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -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`, diff --git a/backend/src/index.js b/backend/src/index.js index 665f1ba..30d9f39 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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', diff --git a/backend/src/routes/workspace.js b/backend/src/routes/workspace.js new file mode 100644 index 0000000..871a0cf --- /dev/null +++ b/backend/src/routes/workspace.js @@ -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; diff --git a/backend/src/services/workspace.js b/backend/src/services/workspace.js new file mode 100644 index 0000000..c3cbfc5 --- /dev/null +++ b/backend/src/services/workspace.js @@ -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; + } + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/components/Workspace/ChatMarkdown.tsx b/frontend/src/components/Workspace/ChatMarkdown.tsx new file mode 100644 index 0000000..d551c47 --- /dev/null +++ b/frontend/src/components/Workspace/ChatMarkdown.tsx @@ -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 ( + + {token.value} + + ); + } + + if (token.type === 'bold') { + return ( + + {token.value} + + ); + } + + if (token.type === 'italic') { + return ( + + {token.value} + + ); + } + + if (token.type === 'link') { + return ( + + {token.value} + + ); + } + + return {token.value}; + }); + +const headingClassNames: Record = { + 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( +
+
+ {language || 'code'} + {codeLines.length} lines +
+
+            {codeLines.join('\n')}
+          
+
, + ); + + index += 1; + continue; + } + + if (trimmed.startsWith('# ')) { + blocks.push( +

+ {renderInline(trimmed.replace(/^#\s+/, ''), `h1-${index}`)} +

, + ); + index += 1; + continue; + } + + if (trimmed.startsWith('## ')) { + blocks.push( +

+ {renderInline(trimmed.replace(/^##\s+/, ''), `h2-${index}`)} +

, + ); + index += 1; + continue; + } + + if (trimmed.startsWith('### ')) { + blocks.push( +

+ {renderInline(trimmed.replace(/^###\s+/, ''), `h3-${index}`)} +

, + ); + 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( +
+ {renderInline(quoteLines.join(' '), `quote-${index}`)} +
, + ); + 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( + , + ); + continue; + } + + const paragraphLines: string[] = []; + while (index < lines.length && !isSpecialBlock(lines[index])) { + paragraphLines.push(lines[index].trim()); + index += 1; + } + + blocks.push( +

+ {renderInline(paragraphLines.join(' '), `paragraph-${index}`)} +

, + ); + } + + return
{blocks}
; +} diff --git a/frontend/src/components/Workspace/WorkspaceShell.tsx b/frontend/src/components/Workspace/WorkspaceShell.tsx new file mode 100644 index 0000000..d582985 --- /dev/null +++ b/frontend/src/components/Workspace/WorkspaceShell.tsx @@ -0,0 +1,1118 @@ +import { + mdiArchiveOutline, + mdiArrowRight, + mdiChatOutline, + mdiClose, + mdiCogOutline, + mdiDeleteOutline, + mdiMenu, + mdiOpenInNew, + mdiPencilOutline, + mdiPlus, +} from '@mdi/js'; +import axios from 'axios'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import BaseIcon from '../BaseIcon'; +import { hasPermission } from '../../helpers/userPermissions'; +import { useAppSelector } from '../../stores/hooks'; +import ChatMarkdown from './ChatMarkdown'; + +type AgentSummary = { + id: string; + name: string; + description: string; + model: string; + is_default?: boolean; +}; + +type WorkspaceMessage = { + id: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + content?: string; + content_markdown?: string; + delivery_status?: string; + createdAt?: string; + sent_at?: string; + completed_at?: string; + sequence?: number; + optimistic?: boolean; + pending?: boolean; +}; + +type ConversationSummary = { + id: string; + title: string; + summary?: string | null; + status: 'active' | 'archived' | 'deleted'; + is_pinned?: boolean; + last_message_at?: string | null; + createdAt?: string; + updatedAt?: string; + agent?: AgentSummary | null; +}; + +type ConversationDetail = ConversationSummary & { + client_context_json?: string | null; + messages: WorkspaceMessage[]; +}; + +type Notice = { + type: 'success' | 'error'; + message: string; +}; + +const SUGGESTED_PROMPTS = [ + 'Draft a launch checklist for an AI feature release.', + 'Explain how to structure a clean API endpoint with validation.', + 'Create a short spec for a user settings page with profile editing.', + 'Help me plan the next milestone for this workspace product.', +]; + +const sortConversations = (items: ConversationSummary[]) => + [...items].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; + }); + +const formatSidebarTime = (value?: string | null) => { + if (!value) { + return 'New'; + } + + const date = new Date(value); + const diff = Date.now() - date.getTime(); + + if (diff < 1000 * 60 * 60 * 24) { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + }).format(date); + } + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(date); +}; + +const formatMessageTime = (value?: string) => { + if (!value) { + return ''; + } + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +}; + +const getErrorMessage = (error: unknown, fallback: string) => { + if (axios.isAxiosError(error)) { + if (typeof error.response?.data === 'string') { + return error.response.data; + } + + if (typeof error.response?.data?.message === 'string') { + return error.response.data.message; + } + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +}; + +const toConversationSummary = (conversation: ConversationDetail): ConversationSummary => ({ + id: conversation.id, + title: 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: conversation.agent, +}); + +function TypingIndicator() { + return ( +
+ + + +
+ ); +} + +type MessageBubbleProps = { + currentUserName: string; + message: WorkspaceMessage; + streamingText: string; + streamingMessageId: string | null; +}; + +function MessageBubble({ + currentUserName, + message, + streamingText, + streamingMessageId, +}: MessageBubbleProps) { + const isUser = message.role === 'user'; + const isStreaming = streamingMessageId === message.id; + const rawContent = message.content_markdown || message.content || ''; + const displayTime = formatMessageTime(message.completed_at || message.sent_at || message.createdAt); + const bubbleClassName = isUser + ? 'border border-[#4F7CFF]/30 bg-[linear-gradient(135deg,#2563EB_0%,#1D4ED8_100%)] text-white shadow-[0_14px_40px_-18px_rgba(37,99,235,0.85)]' + : 'border border-white/10 bg-white/[0.04] text-slate-100'; + const avatarLabel = isUser ? currentUserName : 'AI'; + + return ( +
+ {!isUser && ( +
+ {avatarLabel} +
+ )} +
+
+ {isUser ? currentUserName : 'Assistant'} + {message.optimistic && sending} + {displayTime && {displayTime}} +
+ + {message.pending ? ( + + ) : isStreaming ? ( +
{streamingText}
+ ) : ( + + )} +
+ {isUser && ( +
+ {avatarLabel.slice(0, 1)} +
+ )} +
+ ); +} + +type ConversationRowProps = { + conversation: ConversationSummary; + isActive: boolean; + onSelect: (id: string) => void; +}; + +function ConversationRow({ conversation, isActive, onSelect }: ConversationRowProps) { + return ( + + ); +} + +export default function WorkspaceShell() { + const router = useRouter(); + const composerRef = useRef(null); + const scrollContainerRef = useRef(null); + const didBootstrapRef = useRef(false); + const { currentUser } = useAppSelector((state) => state.auth); + + const [agents, setAgents] = useState([]); + const [conversations, setConversations] = useState([]); + const [activeConversation, setActiveConversation] = useState(null); + const [composer, setComposer] = useState(''); + const [selectedAgentId, setSelectedAgentId] = useState(''); + const [notice, setNotice] = useState(null); + const [loadingBootstrap, setLoadingBootstrap] = useState(true); + const [loadingConversation, setLoadingConversation] = useState(false); + const [sending, setSending] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [draftTitle, setDraftTitle] = useState(''); + const [optimisticMessages, setOptimisticMessages] = useState([]); + const [streamingMessageId, setStreamingMessageId] = useState(null); + const [streamingText, setStreamingText] = useState(''); + const [hasBootstrapped, setHasBootstrapped] = useState(false); + + const currentConversationId = + typeof router.query.conversationId === 'string' ? router.query.conversationId : null; + const currentUserName = currentUser?.firstName || currentUser?.email || 'You'; + const canAccessAdmin = hasPermission(currentUser, 'READ_USERS'); + + const activeConversations = useMemo( + () => conversations.filter((conversation) => conversation.status !== 'archived'), + [conversations], + ); + const archivedConversations = useMemo( + () => conversations.filter((conversation) => conversation.status === 'archived'), + [conversations], + ); + const visibleMessages = useMemo( + () => [...(activeConversation?.messages || []), ...optimisticMessages], + [activeConversation?.messages, optimisticMessages], + ); + + const upsertConversation = useCallback((conversation: ConversationSummary) => { + setConversations((previous) => { + const next = previous.filter((item) => item.id !== conversation.id); + return sortConversations([conversation, ...next]); + }); + }, []); + + const removeConversation = useCallback((conversationId: string) => { + setConversations((previous) => previous.filter((conversation) => conversation.id !== conversationId)); + }, []); + + const applyConversationPayload = useCallback( + (conversation: ConversationDetail) => { + setActiveConversation(conversation); + setDraftTitle(conversation.title); + upsertConversation(toConversationSummary(conversation)); + }, + [upsertConversation], + ); + + const loadBootstrap = useCallback(async () => { + setLoadingBootstrap(true); + try { + const { data } = await axios.get('/workspace/bootstrap'); + setAgents(data.agents || []); + setConversations(sortConversations(data.conversations || [])); + return data; + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to load the workspace.'), + }); + return { agents: [], conversations: [] }; + } finally { + setLoadingBootstrap(false); + } + }, []); + + const loadConversation = useCallback( + async (conversationId: string) => { + setLoadingConversation(true); + try { + const { data } = await axios.get(`/workspace/conversations/${conversationId}`); + applyConversationPayload(data.conversation); + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to load this conversation.'), + }); + setActiveConversation(null); + } finally { + setLoadingConversation(false); + } + }, + [applyConversationPayload], + ); + + useEffect(() => { + if (!router.isReady || didBootstrapRef.current) { + return; + } + + didBootstrapRef.current = true; + + void (async () => { + const data = await loadBootstrap(); + setHasBootstrapped(true); + + if (!currentConversationId && data.conversations?.[0]?.id) { + await router.replace(`/workspace/${data.conversations[0].id}`); + } + })(); + }, [router.isReady, loadBootstrap, currentConversationId, router]); + + useEffect(() => { + if (!hasBootstrapped || !currentConversationId) { + if (hasBootstrapped && !currentConversationId) { + setActiveConversation(null); + setDraftTitle(''); + setOptimisticMessages([]); + } + return; + } + + void loadConversation(currentConversationId); + }, [hasBootstrapped, currentConversationId, loadConversation]); + + useEffect(() => { + if (!selectedAgentId && agents.length) { + const defaultAgent = agents.find((agent) => agent.is_default) || agents[0]; + if (defaultAgent) { + setSelectedAgentId(defaultAgent.id); + } + } + }, [agents, selectedAgentId]); + + useEffect(() => { + if (!streamingMessageId) { + return; + } + + const streamedMessage = activeConversation?.messages.find((message) => message.id === streamingMessageId); + const fullText = streamedMessage?.content_markdown || streamedMessage?.content || ''; + + if (!fullText) { + setStreamingMessageId(null); + setStreamingText(''); + return; + } + + setStreamingText(''); + let cursor = 0; + const step = Math.max(12, Math.ceil(fullText.length / 32)); + + const timer = window.setInterval(() => { + cursor = Math.min(fullText.length, cursor + step); + setStreamingText(fullText.slice(0, cursor)); + + if (cursor >= fullText.length) { + window.clearInterval(timer); + setStreamingMessageId(null); + } + }, 18); + + return () => window.clearInterval(timer); + }, [activeConversation?.messages, streamingMessageId]); + + useEffect(() => { + if (!scrollContainerRef.current) { + return; + } + + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + }, [visibleMessages.length, loadingConversation, streamingText]); + + const handleNewChat = useCallback(async () => { + setNotice(null); + setComposer(''); + setActiveConversation(null); + setDraftTitle(''); + setIsEditingTitle(false); + setOptimisticMessages([]); + setIsSidebarOpen(false); + await router.push('/workspace'); + composerRef.current?.focus(); + }, [router]); + + const handleConversationSelect = useCallback( + async (conversationId: string) => { + setIsSidebarOpen(false); + await router.push(`/workspace/${conversationId}`); + }, + [router], + ); + + const handleRenameConversation = useCallback(async () => { + if (!activeConversation) { + return; + } + + const nextTitle = draftTitle.trim(); + if (!nextTitle) { + setNotice({ + type: 'error', + message: 'Conversation titles cannot be empty.', + }); + return; + } + + try { + const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, { + title: nextTitle, + }); + applyConversationPayload(data.conversation); + setIsEditingTitle(false); + setNotice({ + type: 'success', + message: 'Conversation renamed.', + }); + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to rename the conversation.'), + }); + } + }, [activeConversation, applyConversationPayload, draftTitle]); + + const handleArchiveConversation = useCallback(async () => { + if (!activeConversation) { + return; + } + + const nextStatus = activeConversation.status === 'archived' ? 'active' : 'archived'; + + try { + const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, { + status: nextStatus, + }); + applyConversationPayload(data.conversation); + setNotice({ + type: 'success', + message: nextStatus === 'archived' ? 'Conversation archived.' : 'Conversation restored.', + }); + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to update the conversation status.'), + }); + } + }, [activeConversation, applyConversationPayload]); + + const handleDeleteConversation = useCallback(async () => { + if (!activeConversation) { + return; + } + + const confirmed = window.confirm('Delete this conversation? This action can be undone only from the database.'); + if (!confirmed) { + return; + } + + const nextConversation = conversations.find((conversation) => conversation.id !== activeConversation.id); + + try { + await axios.delete(`/workspace/conversations/${activeConversation.id}`); + removeConversation(activeConversation.id); + setNotice({ + type: 'success', + message: 'Conversation deleted.', + }); + + if (nextConversation) { + await router.replace(`/workspace/${nextConversation.id}`); + } else { + await router.replace('/workspace'); + } + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to delete the conversation.'), + }); + } + }, [activeConversation, conversations, removeConversation, router]); + + const handleAgentChange = useCallback( + async (nextAgentId: string) => { + setSelectedAgentId(nextAgentId); + + if (!activeConversation) { + return; + } + + try { + const { data } = await axios.patch(`/workspace/conversations/${activeConversation.id}`, { + agentId: nextAgentId, + }); + applyConversationPayload(data.conversation); + } catch (error) { + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to change the active agent.'), + }); + } + }, + [activeConversation, applyConversationPayload], + ); + + const handleSendMessage = useCallback(async () => { + const messageToSend = composer.trim(); + + if (!messageToSend || sending) { + return; + } + + setNotice(null); + setSending(true); + setComposer(''); + + let conversation = activeConversation; + let createdConversationId: string | null = null; + + try { + if (!conversation) { + const { data } = await axios.post('/workspace/conversations', { + agentId: selectedAgentId || undefined, + }); + conversation = data.conversation; + createdConversationId = conversation.id; + applyConversationPayload(conversation); + } + + const optimisticBase = Date.now(); + setOptimisticMessages([ + { + id: `optimistic-user-${optimisticBase}`, + role: 'user', + content_markdown: messageToSend, + createdAt: new Date().toISOString(), + optimistic: true, + }, + { + id: `optimistic-assistant-${optimisticBase}`, + role: 'assistant', + content_markdown: '', + createdAt: new Date().toISOString(), + pending: true, + }, + ]); + + const { data } = await axios.post(`/workspace/conversations/${conversation.id}/messages`, { + content: messageToSend, + agentId: conversation.agent?.id || selectedAgentId || undefined, + }); + + setOptimisticMessages([]); + applyConversationPayload(data.conversation); + + const newestAssistant = [...data.conversation.messages] + .reverse() + .find((message: WorkspaceMessage) => message.role === 'assistant'); + + if (newestAssistant?.id) { + setStreamingMessageId(newestAssistant.id); + } + + if (router.asPath !== `/workspace/${data.conversation.id}`) { + await router.replace(`/workspace/${data.conversation.id}`); + } + } catch (error) { + setOptimisticMessages([]); + setComposer(messageToSend); + setNotice({ + type: 'error', + message: getErrorMessage(error, 'Failed to send the message.'), + }); + + if (createdConversationId) { + upsertConversation(toConversationSummary(conversation as ConversationDetail)); + if (router.asPath !== `/workspace/${createdConversationId}`) { + await router.replace(`/workspace/${createdConversationId}`); + } + } + } finally { + setSending(false); + composerRef.current?.focus(); + } + }, [ + activeConversation, + applyConversationPayload, + composer, + router, + selectedAgentId, + sending, + upsertConversation, + ]); + + const agentSelectionValue = activeConversation?.agent?.id || selectedAgentId; + const emptyStateAgent = agents.find((agent) => agent.id === agentSelectionValue) || agents[0]; + + return ( +
+
+
+
+
+

+ AI agent workspace +

+

+ Keep every chat, draft, and idea in one focused space. +

+

+ This first iteration turns the generated app into a modern chat product shell: a + dedicated workspace, scoped conversation history, markdown-friendly assistant + replies, and the admin area still available in the background. +

+
+
+ + + + + Settings + + {canAccessAdmin && ( + + + Admin + + )} +
+
+
+ +
+ + + {isSidebarOpen && ( + + +
+
+ ) : ( +
+

+ {activeConversation.title} +

+ +
+ )} +

+ {activeConversation.summary || + 'This chat is ready. Send a message to generate a markdown-friendly assistant reply.'} +

+ + +
+ + +
+ + +
+
+ + + +
+
+ {loadingConversation ? ( +
+
+
+
+
+ ) : visibleMessages.length ? ( +
+ {visibleMessages.map((message) => ( + + ))} +
+ ) : ( +
+
+ +
+

+ This conversation is ready for the next prompt. +

+

+ Replies render with markdown and code blocks, and the conversation stays + saved in your personal workspace. +

+
+ )} +
+ +
+
+
+
+

Composer

+

+ {activeConversation?.status === 'archived' + ? 'Sending a message will reopen this archived conversation.' + : 'Use Enter to send and Shift+Enter for a new line.'} +

+
+
+ {activeConversation?.agent?.name || emptyStateAgent?.name || 'General Assistant'} +
+
+ +
+