From ccb52987a0bdf5fca22c43178b42030f6081bcf1 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 14 May 2026 15:36:14 +0000 Subject: [PATCH] 2 --- backend/src/routes/workspace.js | 24 + backend/src/services/workspace.js | 711 +++++++--- frontend/src/Authenticated.tsx | 147 +++ frontend/src/ChatMarkdown.tsx | 251 ++++ frontend/src/WorkspaceShell.tsx | 1170 +++++++++++++++++ frontend/src/components/NavBar.tsx | 34 +- .../src/components/Workspace/ChatMarkdown.tsx | 22 +- .../components/Workspace/WorkspaceShell.tsx | 1001 +++++++++----- frontend/src/css/_select-dropdown.css | 84 +- frontend/src/index.tsx | 200 +++ frontend/src/layouts/Authenticated.tsx | 135 +- frontend/src/login.tsx | 224 ++++ frontend/src/menuAside.ts | 35 +- frontend/src/pages/dashboard.tsx | 6 +- frontend/src/pages/error.tsx | 2 +- frontend/src/pages/index.tsx | 328 +++-- frontend/src/pages/login.tsx | 402 +++--- frontend/tsconfig.tsbuildinfo | 1 + 18 files changed, 3792 insertions(+), 985 deletions(-) create mode 100644 frontend/src/Authenticated.tsx create mode 100644 frontend/src/ChatMarkdown.tsx create mode 100644 frontend/src/WorkspaceShell.tsx create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/login.tsx create mode 100644 frontend/tsconfig.tsbuildinfo diff --git a/backend/src/routes/workspace.js b/backend/src/routes/workspace.js index 871a0cf..09919f0 100644 --- a/backend/src/routes/workspace.js +++ b/backend/src/routes/workspace.js @@ -57,4 +57,28 @@ router.post( }), ); +router.post( + '/conversations/:id/messages/:messageId/retry', + wrapAsync(async (req, res) => { + const payload = await WorkspaceService.retryMessage( + req.params.id, + req.params.messageId, + req.currentUser, + ); + res.status(200).send(payload); + }), +); + +router.post( + '/conversations/:id/messages/:messageId/regenerate', + wrapAsync(async (req, res) => { + const payload = await WorkspaceService.regenerateMessage( + req.params.id, + req.params.messageId, + req.currentUser, + ); + res.status(200).send(payload); + }), +); + module.exports = router; diff --git a/backend/src/services/workspace.js b/backend/src/services/workspace.js index c3cbfc5..7031440 100644 --- a/backend/src/services/workspace.js +++ b/backend/src/services/workspace.js @@ -1,4 +1,5 @@ const db = require('../db/models'); +const { LocalAIApi } = require('../ai/LocalAIApi'); const ValidationError = require('./notifications/errors/validation'); const { Op } = db.Sequelize; @@ -6,6 +7,7 @@ const { Op } = db.Sequelize; const DEFAULT_CONVERSATION_TITLE = 'New conversation'; const MAX_MESSAGE_LENGTH = 8000; const MAX_TITLE_LENGTH = 120; +const MAX_CONTEXT_MESSAGES = 12; function normalizeText(value) { if (typeof value !== 'string') { @@ -62,103 +64,329 @@ function estimateTokens(content) { return Math.max(1, Math.ceil(normalized.length / 4)); } -function buildDraftParagraph(message, agentName) { - const excerpt = cleanMarkdownPreview(message).slice(0, 220) || 'your request'; +function buildAssistantFailureMessage(errorMessage) { + const normalized = cleanMarkdownPreview(errorMessage).slice(0, 400) || 'Unknown AI error.'; - return `I captured the main goal from **${excerpt}** and prepared a structured draft in the **${agentName}** style. This workspace is ready for a real model later, so the current reply is a product-shell response that keeps the chat flow working end to end.`; -} - -function buildCodeExample() { return [ - '```ts', - 'type Result = {', - ' summary: string;', - ' nextStep: string;', - '};', + '### Response failed', '', - 'export function respond(prompt: string): Result {', - ' return {', - " summary: `Working on: ${prompt.trim()}`,", - " nextStep: 'Refine the prompt or ask for implementation details.',", - ' };', - '}', + 'The workspace could not generate a reply from the configured AI provider.', + '', + '```text', + normalized, '```', + '', + 'Retry the message or switch to another agent and try again.', ].join('\n'); } -function buildAssistantReply({ message, agent }) { - const agentName = agent?.name || 'Assistant'; - const normalized = normalizeText(message); - const lowerCase = normalized.toLowerCase(); - const asksForCode = /(code|debug|bug|typescript|javascript|python|sql|query|api|component|react|next|function)/i.test( - lowerCase, - ); - const asksForWriting = /(write|draft|copy|email|summary|summarize|post|documentation|spec)/i.test( - lowerCase, - ); +function buildAiInput(agent, historyMessages) { + const input = []; + const systemPrompt = normalizeText(agent?.system_prompt); + const agentDescription = normalizeText(agent?.description); - const intro = `### ${agentName}`; - const context = buildDraftParagraph(normalized, agentName); - const echoedPrompt = `> ${cleanMarkdownPreview(normalized).slice(0, 240)}${ - cleanMarkdownPreview(normalized).length > 240 ? '…' : '' - }`; - - if (asksForCode) { - return [ - intro, - '', - context, - '', - echoedPrompt, - '', - '#### Suggested path', - '- Start with the smallest working example.', - '- Validate inputs and define the expected output shape.', - '- Add edge-case handling after the first happy-path pass.', - '', - '#### Starter example', - buildCodeExample(), - '', - 'If you want, ask me for the production version next and I can continue from this structure.', - ].join('\n'); + if (systemPrompt) { + input.push({ + role: 'system', + content: systemPrompt, + }); + } else if (agentDescription) { + input.push({ + role: 'system', + content: `You are ${agent?.name || 'an AI assistant'}. ${agentDescription}`, + }); } - if (asksForWriting) { - return [ - intro, - '', - context, - '', - echoedPrompt, - '', - '#### First draft', - '- Open with the outcome you want the reader to understand.', - '- Keep the tone concise, direct, and useful.', - '- End with one clear next action or decision.', - '', - '#### Polished version', - 'Here is a strong starting point you can refine in the next turn:', - '', - `**Goal:** move the work forward without losing context.`, - '', - 'I can now turn this into a finished draft, a shorter version, or a more opinionated rewrite.', - ].join('\n'); + for (const message of historyMessages) { + if (!['user', 'assistant', 'system'].includes(message.role)) { + continue; + } + + const content = normalizeText(message.content_markdown || message.content); + if (!content) { + continue; + } + + input.push({ + role: message.role, + content, + }); } - return [ - intro, - '', - context, - '', - echoedPrompt, - '', - '#### Working response', - '- I understood the request and attached it to this conversation.', - '- The conversation history is now saved in your workspace.', - '- You can rename, archive, reopen, or delete this chat at any time.', - '', - 'Tell me whether you want a **plan**, a **draft answer**, or a **code solution**, and I will continue in that format.', - ].join('\n'); + return input; +} + +async function requestAssistantReply(conversationId, assistantMessageId, agent) { + const historyMessages = await db.messages.findAll({ + where: { + conversationId, + id: { + [Op.ne]: assistantMessageId, + }, + delivery_status: { + [Op.ne]: 'failed', + }, + role: { + [Op.in]: ['user', 'assistant', 'system'], + }, + }, + order: [['sequence', 'DESC']], + limit: MAX_CONTEXT_MESSAGES, + }); + + const orderedMessages = [...historyMessages].reverse(); + const input = buildAiInput(agent, orderedMessages); + + if (input.length === 0) { + throw new Error('AI input could not be built from the conversation history.'); + } + + const payload = { + input, + }; + + if (agent?.model) { + payload.model = agent.model; + } + + if (agent?.temperature !== undefined && agent?.temperature !== null && agent.temperature !== '') { + payload.temperature = Number(agent.temperature); + } + + if (agent?.max_output_tokens) { + payload.max_output_tokens = Number(agent.max_output_tokens); + } + + const response = await LocalAIApi.createResponse(payload, { + poll_interval: 5, + poll_timeout: 300, + }); + + if (!response.success) { + throw new Error(response.error || response.message || 'AI proxy request failed.'); + } + + const text = normalizeText(LocalAIApi.extractText(response)); + + if (!text) { + throw new Error('AI response was empty.'); + } + + return { + content: text, + model: payload.model || 'default-ai-model', + }; +} + +async function findConversationMessage(conversationId, messageId, transaction) { + return db.messages.findOne({ + where: { + id: messageId, + conversationId, + }, + transaction, + }); +} + +async function findLatestUserMessageBeforeSequence(conversationId, sequence, transaction) { + return db.messages.findOne({ + where: { + conversationId, + role: 'user', + sequence: { + [Op.lt]: sequence, + }, + }, + order: [['sequence', 'DESC']], + transaction, + }); +} + +async function resolveConversationAgent(conversation, currentUser, transaction) { + let agent = conversation.agent; + + if (agent) { + return agent; + } + + agent = await findDefaultAgent(transaction); + + if (!agent) { + return null; + } + + await conversation.update( + { + agentId: agent.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return agent; +} + +async function finalizeAssistantResponse({ + assistantMessageId, + conversationId, + currentUser, + sourceContent, + agentId, + agentModel, + titleSeed, + metadataAction, +}) { + let aiError = null; + + try { + const agent = + agentId + ? await db.agents.findOne({ + where: { + id: agentId, + }, + }) + : null; + const aiResult = await requestAssistantReply(conversationId, assistantMessageId, agent); + const completeTransaction = await db.sequelize.transaction(); + + try { + const completedAt = new Date(); + const assistantMessage = await db.messages.findOne({ + where: { + id: assistantMessageId, + }, + transaction: completeTransaction, + }); + + if (!assistantMessage) { + throw new Error('Assistant message placeholder was not found.'); + } + + await assistantMessage.update( + { + content: aiResult.content, + content_markdown: aiResult.content, + delivery_status: 'completed', + completed_at: completedAt, + updatedById: currentUser.id, + }, + { transaction: completeTransaction }, + ); + + const conversation = await db.conversations.findOne({ + where: { + id: conversationId, + }, + transaction: completeTransaction, + }); + + if (!conversation) { + throw new Error('Conversation was not found while completing the assistant response.'); + } + + const conversationUpdate = { + summary: buildSummary(aiResult.content), + last_message_at: completedAt, + updatedById: currentUser.id, + }; + + if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) { + conversationUpdate.title = buildConversationTitle(titleSeed); + } + + await conversation.update(conversationUpdate, { transaction: completeTransaction }); + + const inputTokens = estimateTokens(sourceContent); + const outputTokens = estimateTokens(aiResult.content); + + await createUsageEvent( + { + event_type: 'message_generated', + occurred_at: completedAt, + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + cost_usd: 0, + provider: 'local-ai', + model: aiResult.model || agentModel, + metadata_json: JSON.stringify({ + role: 'assistant', + action: metadataAction, + }), + conversationId, + messageId: assistantMessageId, + agentId, + }, + completeTransaction, + currentUser, + ); + + await completeTransaction.commit(); + } catch (error) { + await completeTransaction.rollback(); + throw error; + } + } catch (error) { + aiError = error.message || String(error); + const failTransaction = await db.sequelize.transaction(); + + try { + const failedAt = new Date(); + const assistantMessage = await db.messages.findOne({ + where: { + id: assistantMessageId, + }, + transaction: failTransaction, + }); + + if (!assistantMessage) { + throw new Error('Assistant message placeholder was not found while handling the AI failure.'); + } + + const failureMessage = buildAssistantFailureMessage(aiError); + + await assistantMessage.update( + { + content: failureMessage, + content_markdown: failureMessage, + delivery_status: 'failed', + completed_at: failedAt, + updatedById: currentUser.id, + }, + { transaction: failTransaction }, + ); + + const conversation = await db.conversations.findOne({ + where: { + id: conversationId, + }, + transaction: failTransaction, + }); + + if (!conversation) { + throw new Error('Conversation was not found while marking the AI failure.'); + } + + const conversationUpdate = { + last_message_at: failedAt, + updatedById: currentUser.id, + }; + + if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) { + conversationUpdate.title = buildConversationTitle(titleSeed); + } + + await conversation.update(conversationUpdate, { transaction: failTransaction }); + + await failTransaction.commit(); + } catch (failureHandlingError) { + await failTransaction.rollback(); + throw failureHandlingError; + } + } + + return aiError; } function serializeAgent(agent) { @@ -556,20 +784,25 @@ module.exports = class WorkspaceService { } static async sendMessage(id, data, currentUser) { - const transaction = await db.sequelize.transaction(); + const content = normalizeText(data?.content); + + if (!content) { + throw new ValidationError(); + } + + if (content.length > MAX_MESSAGE_LENGTH) { + throw new ValidationError(); + } + + const createTransaction = await db.sequelize.transaction(); + let conversationId = null; + let assistantMessageId = null; + let userMessageId = null; + let agentId = null; + let agentModel = null; try { - const content = normalizeText(data?.content); - - if (!content) { - throw new ValidationError(); - } - - if (content.length > MAX_MESSAGE_LENGTH) { - throw new ValidationError(); - } - - const conversation = await findOwnedConversation(id, currentUser, transaction); + const conversation = await findOwnedConversation(id, currentUser, createTransaction); let agent = conversation.agent; if (data?.agentId) { @@ -578,7 +811,7 @@ module.exports = class WorkspaceService { id: data.agentId, is_active: true, }, - transaction, + transaction: createTransaction, }); if (!agent) { @@ -590,35 +823,21 @@ module.exports = class WorkspaceService { agentId: agent.id, updatedById: currentUser.id, }, - { transaction }, + { transaction: createTransaction }, ); } if (!agent) { - agent = await findDefaultAgent(transaction); - - if (agent) { - await conversation.update( - { - agentId: agent.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - } + agent = await resolveConversationAgent(conversation, currentUser, createTransaction); } const messageCount = await db.messages.count({ where: { conversationId: conversation.id, }, - transaction, + transaction: createTransaction, }); const sentAt = new Date(); - const assistantReply = buildAssistantReply({ - message: content, - agent, - }); const userMessage = await db.messages.create( { @@ -634,44 +853,36 @@ module.exports = class WorkspaceService { createdById: currentUser.id, updatedById: currentUser.id, }, - { transaction }, + { transaction: createTransaction }, ); const assistantMessage = await db.messages.create( { role: 'assistant', - content: assistantReply, - content_markdown: assistantReply, - delivery_status: 'completed', + content: '', + content_markdown: '', + delivery_status: 'streaming', sent_at: sentAt, - completed_at: new Date(sentAt.getTime() + 300), + completed_at: null, sequence: messageCount + 2, conversationId: conversation.id, author_userId: currentUser.id, createdById: currentUser.id, updatedById: currentUser.id, }, - { transaction }, + { transaction: createTransaction }, ); - const nextTitle = - conversation.title && conversation.title !== DEFAULT_CONVERSATION_TITLE - ? conversation.title - : buildConversationTitle(content); - await conversation.update( { - title: nextTitle, status: 'active', - summary: buildSummary(assistantReply), last_message_at: sentAt, updatedById: currentUser.id, }, - { transaction }, + { transaction: createTransaction }, ); const inputTokens = estimateTokens(content); - const outputTokens = estimateTokens(assistantReply); await createUsageEvent( { @@ -681,8 +892,8 @@ module.exports = class WorkspaceService { output_tokens: 0, total_tokens: inputTokens, cost_usd: 0, - provider: 'workspace-shell', - model: agent?.model || 'shell-draft', + provider: 'local-ai', + model: agent?.model || 'default-ai-model', metadata_json: JSON.stringify({ role: 'user', }), @@ -690,36 +901,236 @@ module.exports = class WorkspaceService { messageId: userMessage.id, agentId: agent?.id || null, }, - transaction, + createTransaction, currentUser, ); - await createUsageEvent( - { - event_type: 'message_generated', - occurred_at: new Date(sentAt.getTime() + 300), - input_tokens: inputTokens, - output_tokens: outputTokens, - total_tokens: inputTokens + outputTokens, - cost_usd: 0, - provider: 'workspace-shell', - model: agent?.model || 'shell-draft', - metadata_json: JSON.stringify({ - role: 'assistant', - }), + await createTransaction.commit(); + + conversationId = conversation.id; + assistantMessageId = assistantMessage.id; + userMessageId = userMessage.id; + agentId = agent?.id || null; + agentModel = agent?.model || 'default-ai-model'; + } catch (error) { + await createTransaction.rollback(); + throw error; + } + + const aiError = await finalizeAssistantResponse({ + assistantMessageId, + conversationId, + currentUser, + sourceContent: content, + agentId, + agentModel, + titleSeed: content, + metadataAction: 'initial', + }); + + const payload = await this.getConversation(conversationId, currentUser); + + if (aiError) { + return { + ...payload, + ai_error: aiError, + }; + } + + return payload; + } + + static async retryMessage(id, messageId, currentUser) { + const transaction = await db.sequelize.transaction(); + let conversationId = null; + let assistantMessageId = null; + let sourceContent = ''; + let agentId = null; + let agentModel = null; + + try { + const conversation = await findOwnedConversation(id, currentUser, transaction); + const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction); + + if (!assistantMessage || assistantMessage.role !== 'assistant' || assistantMessage.delivery_status !== 'failed') { + throw new ValidationError(); + } + + const newerUserMessageCount = await db.messages.count({ + where: { conversationId: conversation.id, - messageId: assistantMessage.id, - agentId: agent?.id || null, + role: 'user', + sequence: { + [Op.gt]: assistantMessage.sequence, + }, }, transaction, - currentUser, + }); + + if (newerUserMessageCount > 0) { + throw new ValidationError(); + } + + const sourceMessage = await findLatestUserMessageBeforeSequence( + conversation.id, + assistantMessage.sequence, + transaction, + ); + + if (!sourceMessage) { + throw new ValidationError(); + } + + const agent = await resolveConversationAgent(conversation, currentUser, transaction); + + await assistantMessage.update( + { + content: '', + content_markdown: '', + delivery_status: 'streaming', + completed_at: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await conversation.update( + { + status: 'active', + updatedById: currentUser.id, + }, + { transaction }, ); await transaction.commit(); - return this.getConversation(conversation.id, currentUser); + + conversationId = conversation.id; + assistantMessageId = assistantMessage.id; + sourceContent = sourceMessage.content_markdown || sourceMessage.content || ''; + agentId = agent?.id || null; + agentModel = agent?.model || 'default-ai-model'; } catch (error) { await transaction.rollback(); throw error; } + + const aiError = await finalizeAssistantResponse({ + assistantMessageId, + conversationId, + currentUser, + sourceContent, + agentId, + agentModel, + titleSeed: sourceContent, + metadataAction: 'retry', + }); + + const payload = await this.getConversation(conversationId, currentUser); + + if (aiError) { + return { + ...payload, + ai_error: aiError, + }; + } + + return payload; + } + + static async regenerateMessage(id, messageId, currentUser) { + const transaction = await db.sequelize.transaction(); + let conversationId = null; + let assistantMessageId = null; + let sourceContent = ''; + let agentId = null; + let agentModel = null; + + try { + const conversation = await findOwnedConversation(id, currentUser, transaction); + const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction); + + if (!assistantMessage || assistantMessage.role !== 'assistant') { + throw new ValidationError(); + } + + const newerUserMessageCount = await db.messages.count({ + where: { + conversationId: conversation.id, + role: 'user', + sequence: { + [Op.gt]: assistantMessage.sequence, + }, + }, + transaction, + }); + + if (newerUserMessageCount > 0) { + throw new ValidationError(); + } + + const sourceMessage = await findLatestUserMessageBeforeSequence( + conversation.id, + assistantMessage.sequence, + transaction, + ); + + if (!sourceMessage) { + throw new ValidationError(); + } + + const agent = await resolveConversationAgent(conversation, currentUser, transaction); + + await assistantMessage.update( + { + content: '', + content_markdown: '', + delivery_status: 'streaming', + completed_at: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await conversation.update( + { + status: 'active', + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + + conversationId = conversation.id; + assistantMessageId = assistantMessage.id; + sourceContent = sourceMessage.content_markdown || sourceMessage.content || ''; + agentId = agent?.id || null; + agentModel = agent?.model || 'default-ai-model'; + } catch (error) { + await transaction.rollback(); + throw error; + } + + const aiError = await finalizeAssistantResponse({ + assistantMessageId, + conversationId, + currentUser, + sourceContent, + agentId, + agentModel, + titleSeed: sourceContent, + metadataAction: 'regenerate', + }); + + const payload = await this.getConversation(conversationId, currentUser); + + if (aiError) { + return { + ...payload, + ai_error: aiError, + }; + } + + return payload; } }; diff --git a/frontend/src/Authenticated.tsx b/frontend/src/Authenticated.tsx new file mode 100644 index 0000000..193fa19 --- /dev/null +++ b/frontend/src/Authenticated.tsx @@ -0,0 +1,147 @@ +import React, { ReactNode, useEffect, useState } from 'react' +import jwt from 'jsonwebtoken'; +import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' +import menuAside from '../menuAside' +import menuNavBar from '../menuNavBar' +import BaseIcon from '../components/BaseIcon' +import NavBar from '../components/NavBar' +import NavBarItemPlain from '../components/NavBarItemPlain' +import AsideMenu from '../components/AsideMenu' +import FooterBar from '../components/FooterBar' +import { useAppDispatch, useAppSelector } from '../stores/hooks' +import Search from '../components/Search'; +import { useRouter } from 'next/router' +import {findMe, logoutUser} from "../stores/authSlice"; +import Link from 'next/link'; + +import {hasPermission} from "../helpers/userPermissions"; + + +type Props = { + children: ReactNode + + permission?: string + +} + +export default function LayoutAuthenticated({ + children, + + permission + +}: Props) { + const dispatch = useAppDispatch() + const router = useRouter() + const { token, currentUser } = useAppSelector((state) => state.auth) + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const isWorkspaceRoute = router.pathname.startsWith('/workspace'); + let localToken + if (typeof window !== 'undefined') { + // Perform localStorage action + localToken = localStorage.getItem('token') + } + + const isTokenValid = () => { + const token = localStorage.getItem('token'); + if (!token) return; + const date = new Date().getTime() / 1000; + const data = jwt.decode(token); + if (!data) return; + return date < data.exp; + }; + + useEffect(() => { + dispatch(findMe()); + if (!isTokenValid()) { + dispatch(logoutUser()); + router.push('/login'); + } + }, [token, localToken]); + + + useEffect(() => { + if (!permission || !currentUser) return; + + if (!hasPermission(currentUser, permission)) router.push('/error'); + }, [currentUser, permission]); + + + const darkMode = useAppSelector((state) => state.style.darkMode) + + const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) + const [isAsideLgActive, setIsAsideLgActive] = useState(false) + + useEffect(() => { + const handleRouteChangeStart = () => { + setIsAsideMobileExpanded(false) + setIsAsideLgActive(false) + } + + router.events.on('routeChangeStart', handleRouteChangeStart) + + // If the component is unmounted, unsubscribe + // from the event with the `off` method: + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart) + } + }, [router.events, dispatch]) + + + const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-60' + const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-60 lg:ml-0' : '' + + return ( +
+
+ + {isWorkspaceRoute ? ( +
+ + AI Chat Workspace + + + Focused workspace + +
+ ) : ( + <> + setIsAsideMobileExpanded(!isAsideMobileExpanded)} + > + + + setIsAsideLgActive(true)} + > + + + + + + + )} +
+ {!isWorkspaceRoute && ( + setIsAsideLgActive(false)} + /> + )} + {children} + {!isWorkspaceRoute && Hand-crafted & Made with ❤️} +
+
+ ) +} diff --git a/frontend/src/ChatMarkdown.tsx b/frontend/src/ChatMarkdown.tsx new file mode 100644 index 0000000..1f3dd5a --- /dev/null +++ b/frontend/src/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-slate-900 dark:text-white', + h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white', + h3: 'text-lg font-semibold text-slate-900 dark:text-white', +}; + +const isSpecialBlock = (line: string) => { + const trimmed = line.trim(); + + return ( + !trimmed || + trimmed.startsWith('```') || + trimmed.startsWith('- ') || + trimmed.startsWith('* ') || + trimmed.startsWith('> ') || + trimmed.startsWith('# ') + ); +}; + +export default function ChatMarkdown({ content, className = '' }: ChatMarkdownProps) { + const lines = content.replace(/\r\n/g, '\n').split('\n'); + const blocks: React.ReactNode[] = []; + + let index = 0; + while (index < lines.length) { + const currentLine = lines[index]; + const trimmed = currentLine.trim(); + + if (!trimmed) { + index += 1; + continue; + } + + if (trimmed.startsWith('```')) { + const language = trimmed.replace(/```/, '').trim(); + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && !lines[index].trim().startsWith('```')) { + codeLines.push(lines[index]); + index += 1; + } + + blocks.push( +
+
+ {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/WorkspaceShell.tsx b/frontend/src/WorkspaceShell.tsx new file mode 100644 index 0000000..29cd41e --- /dev/null +++ b/frontend/src/WorkspaceShell.tsx @@ -0,0 +1,1170 @@ +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 isFailed = message.delivery_status === 'failed'; + const rawContent = message.content_markdown || message.content || ''; + const displayTime = formatMessageTime(message.completed_at || message.sent_at || message.createdAt); + let statusLabel = ''; + + if (message.pending) { + statusLabel = 'thinking'; + } else if (isStreaming) { + statusLabel = 'responding'; + } else if (message.optimistic) { + statusLabel = 'sending'; + } + + const bubbleClassName = isUser + ? 'border border-slate-900 bg-slate-900 text-white shadow-sm' + : isFailed + ? 'border border-red-200 bg-red-50 text-red-900 shadow-sm' + : 'border border-slate-200 bg-white text-slate-900 shadow-sm'; + const avatarLabel = isUser ? currentUserName : 'AI'; + const metaClassName = isUser + ? 'text-[11px] uppercase tracking-[0.18em] text-white/70' + : isFailed + ? 'text-[11px] uppercase tracking-[0.18em] text-red-500' + : 'text-[11px] uppercase tracking-[0.18em] text-slate-400'; + + return ( +
+ {!isUser && ( +
+ {avatarLabel} +
+ )} +
+
+ {isUser ? currentUserName : 'Assistant'} + {statusLabel && ( + + {statusLabel} + + )} + {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 () => { + await loadBootstrap(); + setHasBootstrapped(true); + })(); + }, [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 && newestAssistant.delivery_status !== 'failed') { + setStreamingMessageId(newestAssistant.id); + } + + if (data.ai_error) { + setNotice({ + type: 'error', + message: data.ai_error, + }); + } + + 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]; + const activeConversationTimestamp = activeConversation?.last_message_at || activeConversation?.updatedAt; + const activeConversationMessageCount = activeConversation?.messages.length || 0; + + return ( +
+
+ + + {isSidebarOpen && ( + + + + Settings + + {canAccessAdmin && ( + + + Backoffice + + )} +
+ + + +
+ {notice && ( +
+
+ {notice.message} +
+
+ )} + + {activeConversation ? ( + <> +
+
+
+
+ + {activeConversation.status === 'archived' ? 'Archived' : 'Active'} + + + {activeConversation.agent?.name || 'General assistant'} + + + {activeConversationMessageCount} messages + +
+ {isEditingTitle ? ( +
+ setDraftTitle(event.target.value)} + placeholder="Give this conversation a title" + value={draftTitle} + /> +
+ + +
+
+ ) : ( +
+

+ {activeConversation.title} +

+ +
+ )} +

+ {activeConversation.summary || activeConversation.agent?.description || 'Keep the thread moving.'} +

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

+ Keep this thread moving. +

+

+ Ask a follow-up, switch the agent, or keep refining the work without + opening a new chat. +

+
+ )} +
+ +
+
+
+
+

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'} +
+
+ +
+