From 338f9522a5ab2906881cc046e01a3dc783ba202e Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Mon, 23 Feb 2026 18:35:36 +0100 Subject: [PATCH] codeeditor mvp phase 1 done --- modules/datamodels/datamodelChat.py | 2 + modules/features/codeeditor/__init__.py | 1 + .../codeeditor/codeEditorProcessor.py | 148 +++++++ .../codeeditor/datamodelCodeeditor.py | 97 +++++ .../features/codeeditor/fileContextManager.py | 73 ++++ modules/features/codeeditor/mainCodeeditor.py | 248 ++++++++++++ modules/features/codeeditor/promptAssembly.py | 105 +++++ modules/features/codeeditor/responseParser.py | 139 +++++++ .../codeeditor/routeFeatureCodeeditor.py | 362 ++++++++++++++++++ modules/interfaces/interfaceBootstrap.py | 1 + modules/routes/routeSystem.py | 3 + modules/system/mainSystem.py | 5 + 12 files changed, 1184 insertions(+) create mode 100644 modules/features/codeeditor/__init__.py create mode 100644 modules/features/codeeditor/codeEditorProcessor.py create mode 100644 modules/features/codeeditor/datamodelCodeeditor.py create mode 100644 modules/features/codeeditor/fileContextManager.py create mode 100644 modules/features/codeeditor/mainCodeeditor.py create mode 100644 modules/features/codeeditor/promptAssembly.py create mode 100644 modules/features/codeeditor/responseParser.py create mode 100644 modules/features/codeeditor/routeFeatureCodeeditor.py diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index fbad3d57..8400ce2e 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -285,6 +285,7 @@ class WorkflowModeEnum(str, Enum): WORKFLOW_DYNAMIC = "Dynamic" WORKFLOW_AUTOMATION = "Automation" WORKFLOW_CHATBOT = "Chatbot" + WORKFLOW_CODEEDITOR = "CodeEditor" WORKFLOW_REACT = "React" # Legacy mode - kept for backward compatibility @@ -295,6 +296,7 @@ registerModelLabels( "WORKFLOW_DYNAMIC": {"en": "Dynamic", "fr": "Dynamique"}, "WORKFLOW_AUTOMATION": {"en": "Automation", "fr": "Automatisation"}, "WORKFLOW_CHATBOT": {"en": "Chatbot", "fr": "Chatbot"}, + "WORKFLOW_CODEEDITOR": {"en": "Code Editor", "fr": "Éditeur de code"}, "WORKFLOW_REACT": {"en": "React (Legacy)", "fr": "React (Hérité)"}, }, ) diff --git a/modules/features/codeeditor/__init__.py b/modules/features/codeeditor/__init__.py new file mode 100644 index 00000000..d6cca46d --- /dev/null +++ b/modules/features/codeeditor/__init__.py @@ -0,0 +1 @@ +"""CodeEditor Feature - Cursor-style AI file editing via chat interface.""" diff --git a/modules/features/codeeditor/codeEditorProcessor.py b/modules/features/codeeditor/codeEditorProcessor.py new file mode 100644 index 00000000..7986590d --- /dev/null +++ b/modules/features/codeeditor/codeEditorProcessor.py @@ -0,0 +1,148 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""CodeEditor processor -- single-shot orchestrator (Phase 1). +Loads files, builds prompt, calls AI, parses response, emits SSE events.""" + +import logging +import uuid +from typing import List, Optional, Dict, Any + +from modules.features.codeeditor import fileContextManager, promptAssembly, responseParser +from modules.features.codeeditor.datamodelCodeeditor import ( + FileEditProposal, ResponseSegment, SegmentTypeEnum +) +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + + +async def processMessage( + workflowId: str, + userPrompt: str, + selectedFileIds: List[str], + dbManagement, + interfaceAi, + chatInterface, + eventManager +): + """Process a user message: load files, call AI, parse and emit response segments. + + Args: + workflowId: the active workflow ID + userPrompt: user's input text + selectedFileIds: file IDs the user selected as context + dbManagement: interfaceDbManagement instance with user context + interfaceAi: AiObjects instance for AI calls + chatInterface: interfaceDbChat instance for storing messages + eventManager: EventManager for SSE emission + """ + try: + await eventManager.emit_event(workflowId, "chatdata", { + "type": "status", "label": "Loading files..." + }) + + fileContexts = await fileContextManager.loadFileContexts(dbManagement, selectedFileIds) + + await eventManager.emit_event(workflowId, "chatdata", { + "type": "status", "label": "Building prompt..." + }) + + chatHistory = _loadChatHistory(chatInterface, workflowId) + + aiRequest = promptAssembly.buildRequest(userPrompt, fileContexts, chatHistory) + + await eventManager.emit_event(workflowId, "chatdata", { + "type": "status", "label": "AI is processing..." + }) + + aiResponse = await interfaceAi.callWithTextContext(aiRequest) + + if aiResponse.errorCount > 0: + logger.error(f"AI call failed: {aiResponse.content}") + await eventManager.emit_event(workflowId, "chatdata", { + "type": "message", + "item": {"role": "assistant", "content": f"Error: {aiResponse.content}"} + }) + await eventManager.emit_event(workflowId, "error", { + "workflowId": workflowId, "error": aiResponse.content + }) + return + + segments = responseParser.parseResponse(aiResponse.content) + + for segment in segments: + messageData = { + "role": "assistant", + "content": segment.content, + "type": segment.type.value, + "createdAt": getUtcTimestamp() + } + + await eventManager.emit_event(workflowId, "chatdata", { + "type": "message", "item": messageData + }) + + if segment.type == SegmentTypeEnum.FILE_EDIT: + proposal = FileEditProposal( + workflowId=workflowId, + fileId=_resolveFileId(segment.fileName, fileContexts), + fileName=segment.fileName, + operation="edit", + oldContent=segment.oldContent, + newContent=segment.newContent + ) + await eventManager.emit_event(workflowId, "chatdata", { + "type": "file_edit_proposal", "item": proposal.model_dump() + }) + + _logAiStats(aiResponse, workflowId) + + await eventManager.emit_event(workflowId, "complete", { + "workflowId": workflowId, + "modelName": aiResponse.modelName, + "priceCHF": aiResponse.priceCHF, + "processingTime": aiResponse.processingTime + }) + + except Exception as e: + logger.error(f"CodeEditor processing failed for workflow {workflowId}: {e}", exc_info=True) + await eventManager.emit_event(workflowId, "error", { + "workflowId": workflowId, "error": str(e) + }) + + +def _loadChatHistory(chatInterface, workflowId: str) -> List[Dict[str, Any]]: + """Load recent chat messages for multi-turn context.""" + try: + messages = chatInterface.getMessages(workflowId) + if not messages: + return [] + history = [] + for msg in messages: + role = msg.get("role", "unknown") if isinstance(msg, dict) else getattr(msg, "role", "unknown") + content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "") + history.append({"role": role, "content": content}) + return history + except Exception as e: + logger.warning(f"Could not load chat history for {workflowId}: {e}") + return [] + + +def _resolveFileId(fileName: str, fileContexts) -> str: + """Resolve a fileName to its fileId from the loaded contexts.""" + for fc in fileContexts: + if fc.fileName == fileName: + return fc.fileId + return f"unknown-{fileName}" + + +def _logAiStats(aiResponse, workflowId: str): + """Log AI call statistics.""" + logger.info( + f"CodeEditor AI call for {workflowId}: " + f"model={aiResponse.modelName}, " + f"provider={aiResponse.provider}, " + f"cost={aiResponse.priceCHF:.4f} CHF, " + f"time={aiResponse.processingTime:.1f}s, " + f"sent={aiResponse.bytesSent}B, received={aiResponse.bytesReceived}B" + ) diff --git a/modules/features/codeeditor/datamodelCodeeditor.py b/modules/features/codeeditor/datamodelCodeeditor.py new file mode 100644 index 00000000..7f39cbca --- /dev/null +++ b/modules/features/codeeditor/datamodelCodeeditor.py @@ -0,0 +1,97 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Data models for the CodeEditor feature.""" + +from typing import List, Dict, Any, Optional +from enum import Enum +from pydantic import BaseModel, Field +from modules.shared.timeUtils import getUtcTimestamp +import uuid + + +class SegmentTypeEnum(str, Enum): + TEXT = "text" + CODE_BLOCK = "code_block" + FILE_EDIT = "file_edit" + + +class EditStatusEnum(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + + +class FileContext(BaseModel): + """A text file loaded as context for the AI.""" + fileId: str + fileName: str + content: Optional[str] = None + mimeType: str + sizeBytes: int = 0 + tags: List[str] = Field(default_factory=list) + + +class ResponseSegment(BaseModel): + """A parsed segment from the AI response.""" + type: SegmentTypeEnum + content: str + language: Optional[str] = None + fileId: Optional[str] = None + fileName: Optional[str] = None + oldContent: Optional[str] = None + newContent: Optional[str] = None + + +class FileEditProposal(BaseModel): + """A proposed file edit from the AI, awaiting user accept/reject.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + workflowId: str + fileId: str + fileName: str + operation: str = "edit" + oldContent: Optional[str] = None + newContent: str + diffSummary: Optional[str] = None + status: EditStatusEnum = EditStatusEnum.PENDING + createdAt: float = Field(default_factory=getUtcTimestamp) + + +class FileVersion(BaseModel): + """A new version of a file created after accepting an edit proposal.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + sourceFileId: str + editProposalId: str + newFileId: str + createdAt: float = Field(default_factory=getUtcTimestamp) + + +TEXT_MIME_TYPES = { + "text/plain", "text/markdown", "text/html", "text/css", "text/csv", + "text/xml", "text/yaml", "text/x-python", "text/x-java", + "text/javascript", "text/x-typescript", "text/x-sql", + "application/json", "application/xml", "application/yaml", + "application/x-yaml", "application/javascript", +} + +TEXT_EXTENSIONS = { + ".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", + ".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".htm", ".css", ".scss", + ".sql", ".sh", ".bash", ".zsh", ".ps1", ".bat", + ".toml", ".ini", ".cfg", ".conf", ".env", ".gitignore", + ".dockerfile", ".docker-compose", ".makefile", + ".java", ".kt", ".go", ".rs", ".rb", ".php", ".swift", ".c", ".cpp", ".h", + ".r", ".lua", ".dart", ".vue", ".svelte", +} + + +def isTextFile(mimeType: Optional[str], fileName: Optional[str] = None) -> bool: + """Check if a file is a text-based file suitable for the editor.""" + if mimeType and mimeType.lower() in TEXT_MIME_TYPES: + return True + if mimeType and mimeType.lower().startswith("text/"): + return True + if fileName: + ext = "." + fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" + if ext in TEXT_EXTENSIONS: + return True + return False diff --git a/modules/features/codeeditor/fileContextManager.py b/modules/features/codeeditor/fileContextManager.py new file mode 100644 index 00000000..558c40c5 --- /dev/null +++ b/modules/features/codeeditor/fileContextManager.py @@ -0,0 +1,73 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""File context manager for CodeEditor feature. +Loads text files from the database and provides them as context for AI calls.""" + +import logging +from typing import List, Optional + +from modules.features.codeeditor.datamodelCodeeditor import FileContext, isTextFile + +logger = logging.getLogger(__name__) + + +async def loadFileContexts(dbManagement, fileIds: List[str]) -> List[FileContext]: + """Load text files from DB and return as FileContext list. + + Args: + dbManagement: interfaceDbManagement instance with user context set + fileIds: list of file IDs to load + """ + contexts = [] + for fileId in fileIds: + fileItem = dbManagement.getFile(fileId) + if not fileItem: + logger.warning(f"File {fileId} not found or no access") + continue + + if not isTextFile(fileItem.mimeType, fileItem.fileName): + logger.warning(f"File {fileItem.fileName} ({fileItem.mimeType}) is not a text file, skipping") + continue + + fileData = dbManagement.getFileData(fileId) + if not fileData: + logger.warning(f"No data for file {fileId}") + continue + + try: + content = fileData.decode("utf-8") + except UnicodeDecodeError: + logger.warning(f"File {fileItem.fileName} is not valid UTF-8, skipping") + continue + + contexts.append(FileContext( + fileId=fileId, + fileName=fileItem.fileName, + content=content, + mimeType=fileItem.mimeType, + sizeBytes=fileItem.fileSize + )) + + logger.info(f"Loaded {len(contexts)} file contexts from {len(fileIds)} requested") + return contexts + + +def listTextFiles(dbManagement) -> List[FileContext]: + """List all text files accessible to the user (metadata only, no content).""" + allFiles = dbManagement.getAllFiles() + textFiles = [] + + if not allFiles: + return textFiles + + for fileItem in allFiles: + if isTextFile(fileItem.mimeType, fileItem.fileName): + textFiles.append(FileContext( + fileId=fileItem.id, + fileName=fileItem.fileName, + content=None, + mimeType=fileItem.mimeType, + sizeBytes=fileItem.fileSize + )) + + return textFiles diff --git a/modules/features/codeeditor/mainCodeeditor.py b/modules/features/codeeditor/mainCodeeditor.py new file mode 100644 index 00000000..4237f3ab --- /dev/null +++ b/modules/features/codeeditor/mainCodeeditor.py @@ -0,0 +1,248 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CodeEditor Feature Container - Main Module. +Handles feature initialization and RBAC catalog registration. +Cursor-style AI file editing via chat interface. +""" + +import logging +from typing import Dict, List, Any + +logger = logging.getLogger(__name__) + +FEATURE_CODE = "codeeditor" +FEATURE_LABEL = {"en": "Code Editor", "de": "Code Editor", "fr": "Code Editor"} +FEATURE_ICON = "mdi-file-document-edit" + +UI_OBJECTS = [ + { + "objectKey": "ui.feature.codeeditor.editor", + "label": {"en": "Editor", "de": "Editor", "fr": "Editeur"}, + "meta": {"area": "editor"} + }, + { + "objectKey": "ui.feature.codeeditor.workflows", + "label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, + "meta": {"area": "workflows"} + }, +] + +RESOURCE_OBJECTS = [ + { + "objectKey": "resource.feature.codeeditor.start", + "label": {"en": "Start Workflow", "de": "Workflow starten", "fr": "Demarrer workflow"}, + "meta": {"endpoint": "/api/codeeditor/{instanceId}/start/stream", "method": "POST"} + }, + { + "objectKey": "resource.feature.codeeditor.stop", + "label": {"en": "Stop Workflow", "de": "Workflow stoppen", "fr": "Arreter workflow"}, + "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/stop", "method": "POST"} + }, + { + "objectKey": "resource.feature.codeeditor.chatData", + "label": {"en": "Get Chat Data", "de": "Chat-Daten abrufen", "fr": "Recuperer donnees chat"}, + "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/chatData", "method": "GET"} + }, + { + "objectKey": "resource.feature.codeeditor.files", + "label": {"en": "Manage Files", "de": "Dateien verwalten", "fr": "Gerer fichiers"}, + "meta": {"endpoint": "/api/codeeditor/{instanceId}/files", "method": "GET"} + }, + { + "objectKey": "resource.feature.codeeditor.apply", + "label": {"en": "Apply Edit", "de": "Aenderung anwenden", "fr": "Appliquer modification"}, + "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/apply", "method": "POST"} + }, +] + +TEMPLATE_ROLES = [ + { + "roleLabel": "codeeditor-viewer", + "description": { + "en": "Code Editor Viewer - View editor (read-only)", + "de": "Code Editor Betrachter - Editor ansehen (nur lesen)", + "fr": "Visualiseur Code Editor - Consulter l'editeur (lecture seule)" + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.codeeditor.editor", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ] + }, + { + "roleLabel": "codeeditor-user", + "description": { + "en": "Code Editor User - Use editor and workflows", + "de": "Code Editor Benutzer - Editor und Workflows nutzen", + "fr": "Utilisateur Code Editor - Utiliser l'editeur et les workflows" + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.codeeditor.editor", "view": True}, + {"context": "UI", "item": "ui.feature.codeeditor.workflows", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.codeeditor.start", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.codeeditor.stop", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.codeeditor.chatData", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.codeeditor.files", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.codeeditor.apply", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, + ] + }, + { + "roleLabel": "codeeditor-admin", + "description": { + "en": "Code Editor Admin - Full access to code editor", + "de": "Code Editor Admin - Vollzugriff auf Code Editor", + "fr": "Administrateur Code Editor - Acces complet au code editor" + }, + "accessRules": [ + {"context": "UI", "item": None, "view": True}, + {"context": "RESOURCE", "item": None, "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + ] + }, +] + + +def getFeatureDefinition() -> Dict[str, Any]: + """Return the feature definition for registration.""" + return { + "code": FEATURE_CODE, + "label": FEATURE_LABEL, + "icon": FEATURE_ICON, + "autoCreateInstance": True, + } + + +def getUiObjects() -> List[Dict[str, Any]]: + """Return UI objects for RBAC catalog registration.""" + return UI_OBJECTS + + +def getResourceObjects() -> List[Dict[str, Any]]: + """Return resource objects for RBAC catalog registration.""" + return RESOURCE_OBJECTS + + +def getTemplateRoles() -> List[Dict[str, Any]]: + """Return template roles for this feature.""" + return TEMPLATE_ROLES + + +def registerFeature(catalogService) -> bool: + """Register this feature's RBAC objects in the catalog.""" + try: + for uiObj in UI_OBJECTS: + catalogService.registerUiObject( + featureCode=FEATURE_CODE, + objectKey=uiObj["objectKey"], + label=uiObj["label"], + meta=uiObj.get("meta") + ) + + for resObj in RESOURCE_OBJECTS: + catalogService.registerResourceObject( + featureCode=FEATURE_CODE, + objectKey=resObj["objectKey"], + label=resObj["label"], + meta=resObj.get("meta") + ) + + _syncTemplateRolesToDb() + + logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") + return True + + except Exception as e: + logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") + return False + + +def _syncTemplateRolesToDb() -> int: + """Sync template roles and their AccessRules to the database.""" + try: + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + rootInterface = getRootInterface() + + existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) + templateRoles = [r for r in existingRoles if r.mandateId is None] + existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles} + + createdCount = 0 + for roleTemplate in TEMPLATE_ROLES: + roleLabel = roleTemplate["roleLabel"] + + if roleLabel in existingRoleLabels: + roleId = existingRoleLabels[roleLabel] + _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) + else: + newRole = Role( + roleLabel=roleLabel, + description=roleTemplate.get("description", {}), + featureCode=FEATURE_CODE, + mandateId=None, + featureInstanceId=None, + isSystemRole=False + ) + createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) + roleId = createdRole.get("id") + _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) + logger.info(f"Created template role '{roleLabel}' with ID {roleId}") + createdCount += 1 + + if createdCount > 0: + logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") + + return createdCount + + except Exception as e: + logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") + return 0 + + +def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: + """Ensure AccessRules exist for a role based on templates.""" + from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext + + existingRules = rootInterface.getAccessRulesByRole(roleId) + existingSignatures = set() + for rule in existingRules: + sig = (rule.context.value if rule.context else None, rule.item) + existingSignatures.add(sig) + + createdCount = 0 + for template in ruleTemplates: + context = template.get("context", "UI") + item = template.get("item") + sig = (context, item) + + if sig in existingSignatures: + continue + + if context == "UI": + contextEnum = AccessRuleContext.UI + elif context == "DATA": + contextEnum = AccessRuleContext.DATA + elif context == "RESOURCE": + contextEnum = AccessRuleContext.RESOURCE + else: + contextEnum = context + + newRule = AccessRule( + roleId=roleId, + context=contextEnum, + item=item, + view=template.get("view", False), + read=template.get("read"), + create=template.get("create"), + update=template.get("update"), + delete=template.get("delete"), + ) + rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) + createdCount += 1 + + if createdCount > 0: + logger.debug(f"Created {createdCount} AccessRules for role {roleId}") + + return createdCount diff --git a/modules/features/codeeditor/promptAssembly.py b/modules/features/codeeditor/promptAssembly.py new file mode 100644 index 00000000..db145524 --- /dev/null +++ b/modules/features/codeeditor/promptAssembly.py @@ -0,0 +1,105 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Prompt assembly for the CodeEditor feature. +Builds Cursor-style system prompts with file context and format instructions.""" + +import logging +from typing import List, Optional, Dict, Any + +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum +from modules.features.codeeditor.datamodelCodeeditor import FileContext + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """You are an AI assistant for text and code file editing. You receive files as context and can suggest changes. + +## Rules for file edits +- Use ```file_edit``` blocks for file changes +- Each file_edit block must contain: fileName, oldContent (exact text to replace), newContent (replacement text) +- Explain changes in normal text before or after the block +- oldContent must EXACTLY match existing content (including whitespace and indentation) +- You may propose edits to multiple files in one response + +## Response format +Normal text is displayed as explanation. +File changes must use this format: + +```file_edit +fileName: +oldContent: | + +newContent: | + +``` + +Code examples (without edits) use standard markdown code blocks: +```language +code here +``` + +## Important +- Only edit files that are provided in context +- Make minimal, targeted changes +- Preserve existing formatting and style +- If a task is unclear, ask for clarification instead of guessing""" + + +def buildRequest( + userPrompt: str, + fileContexts: List[FileContext], + chatHistory: Optional[List[Dict[str, Any]]] = None +) -> AiCallRequest: + """Build an AiCallRequest with system prompt, file context, and user prompt.""" + systemPart = SYSTEM_PROMPT + fileContextPart = _buildFileContext(fileContexts) + historyPart = _buildChatHistory(chatHistory) if chatHistory else "" + + fullPrompt = systemPart + if historyPart: + fullPrompt += f"\n\n## Previous conversation\n{historyPart}" + fullPrompt += f"\n\n## User request\n{userPrompt}" + + return AiCallRequest( + prompt=fullPrompt, + context=fileContextPart if fileContextPart else None, + options=AiCallOptions( + operationType=OperationTypeEnum.DATA_ANALYSE, + temperature=0.0, + compressPrompt=False, + compressContext=False, + resultFormat="txt" + ) + ) + + +def _buildFileContext(fileContexts: List[FileContext]) -> str: + """Build the file context string with line numbers.""" + if not fileContexts: + return "" + + parts = [] + for fc in fileContexts: + if not fc.content: + continue + lines = fc.content.split("\n") + numberedLines = [f"{i + 1}|{line}" for i, line in enumerate(lines)] + numbered = "\n".join(numberedLines) + parts.append(f"--- FILE: {fc.fileName} ---\n{numbered}\n--- END FILE ---") + + return "\n\n".join(parts) + + +def _buildChatHistory(chatHistory: List[Dict[str, Any]]) -> str: + """Build a condensed chat history string for multi-turn context.""" + if not chatHistory: + return "" + + parts = [] + for msg in chatHistory[-10:]: + role = msg.get("role", "unknown") + content = msg.get("content", "") + if len(content) > 500: + content = content[:500] + "..." + parts.append(f"[{role}]: {content}") + + return "\n".join(parts) diff --git a/modules/features/codeeditor/responseParser.py b/modules/features/codeeditor/responseParser.py new file mode 100644 index 00000000..998a0abb --- /dev/null +++ b/modules/features/codeeditor/responseParser.py @@ -0,0 +1,139 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Response parser for the CodeEditor feature. +Parses AI responses into typed segments (text, code_block, file_edit).""" + +import logging +import re +from typing import List, Optional + +from modules.features.codeeditor.datamodelCodeeditor import ResponseSegment, SegmentTypeEnum + +logger = logging.getLogger(__name__) + +_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE) + + +def parseResponse(rawContent: str) -> List[ResponseSegment]: + """Parse an AI response into typed segments.""" + if not rawContent or not rawContent.strip(): + return [] + + segments = [] + lines = rawContent.split("\n") + i = 0 + + textBuffer = [] + + while i < len(lines): + line = lines[i] + + match = _FENCE_PATTERN.match(line) + if match: + if textBuffer: + _flushTextBuffer(textBuffer, segments) + textBuffer = [] + + lang = match.group(1).strip() + blockLines, endIdx = _collectBlock(lines, i + 1) + blockContent = "\n".join(blockLines) + + if lang == "file_edit": + segment = _parseFileEditBlock(blockContent) + if segment: + segments.append(segment) + else: + segments.append(ResponseSegment( + type=SegmentTypeEnum.CODE_BLOCK, + content=blockContent, + language="text" + )) + else: + segments.append(ResponseSegment( + type=SegmentTypeEnum.CODE_BLOCK, + content=blockContent, + language=lang or "text" + )) + + i = endIdx + 1 + else: + textBuffer.append(line) + i += 1 + + if textBuffer: + _flushTextBuffer(textBuffer, segments) + + return segments + + +def _collectBlock(lines: List[str], startIdx: int) -> tuple: + """Collect lines inside a fenced code block until closing ```.""" + blockLines = [] + idx = startIdx + while idx < len(lines): + if lines[idx].strip() == "```": + return blockLines, idx + blockLines.append(lines[idx]) + idx += 1 + return blockLines, idx + + +def _flushTextBuffer(buffer: List[str], segments: List[ResponseSegment]): + """Flush accumulated text lines into a text segment.""" + text = "\n".join(buffer).strip() + buffer.clear() + if text: + segments.append(ResponseSegment( + type=SegmentTypeEnum.TEXT, + content=text + )) + + +def _parseFileEditBlock(blockContent: str) -> Optional[ResponseSegment]: + """Parse a file_edit block into a ResponseSegment with fileName, oldContent, newContent.""" + fields = {"fileName": None, "oldContent": None, "newContent": None} + currentField = None + currentLines = [] + + for line in blockContent.split("\n"): + stripped = line.strip() + + newField = None + for key in ("fileName", "oldContent", "newContent"): + if stripped.startswith(f"{key}:"): + newField = key + break + + if newField: + if currentField and currentLines: + fields[currentField] = "\n".join(currentLines) + currentField = newField + value = stripped[len(f"{newField}:"):].strip() + if newField == "fileName": + fields["fileName"] = value if value else None + currentField = None + currentLines = [] + else: + currentLines = [value] if value and value != "|" else [] + else: + if currentField in ("oldContent", "newContent"): + dedented = line[2:] if line.startswith(" ") else line + currentLines.append(dedented) + + if currentField and currentLines: + fields[currentField] = "\n".join(currentLines) + + if not fields["fileName"]: + logger.warning("file_edit block missing fileName") + return None + if fields["newContent"] is None: + logger.warning(f"file_edit block for {fields['fileName']} missing newContent") + return None + + return ResponseSegment( + type=SegmentTypeEnum.FILE_EDIT, + content=f"Edit: {fields['fileName']}", + fileName=fields["fileName"], + oldContent=fields["oldContent"], + newContent=fields["newContent"] + ) diff --git a/modules/features/codeeditor/routeFeatureCodeeditor.py b/modules/features/codeeditor/routeFeatureCodeeditor.py new file mode 100644 index 00000000..421e9bbe --- /dev/null +++ b/modules/features/codeeditor/routeFeatureCodeeditor.py @@ -0,0 +1,362 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CodeEditor Feature Routes. +SSE-based endpoints for Cursor-style AI file editing. +""" + +import logging +import json +import asyncio +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request +from fastapi.responses import StreamingResponse + +from modules.auth import limiter, getRequestContext, RequestContext +from modules.interfaces import interfaceDbChat, interfaceDbManagement +from modules.interfaces.interfaceAiObjects import AiObjects +from modules.datamodels.datamodelChat import UserInputRequest +from modules.features.chatbot.streaming.events import get_event_manager +from modules.features.codeeditor import codeEditorProcessor, fileContextManager +from modules.features.codeeditor.datamodelCodeeditor import FileEditProposal, EditStatusEnum + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/codeeditor", + tags=["Code Editor Feature"], + responses={404: {"description": "Not found"}} +) + +_aiObjects: Optional[AiObjects] = None + + +async def _getAiObjects() -> AiObjects: + """Lazy-init singleton for AiObjects.""" + global _aiObjects + if _aiObjects is None: + _aiObjects = await AiObjects.create() + return _aiObjects + + +def _getServiceChat(context: RequestContext, featureInstanceId: str = None): + """Get chat interface with feature instance context.""" + return interfaceDbChat.getInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=featureInstanceId + ) + + +def _getDbManagement(context: RequestContext, featureInstanceId: str = None): + """Get management interface with user context for file access.""" + return interfaceDbManagement.getInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=featureInstanceId + ) + + +def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: + """Validate user has access to the feature instance. Returns mandateId.""" + from modules.interfaces.interfaceDbApp import getRootInterface + + rootInterface = getRootInterface() + instance = rootInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") + + featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) + if not featureAccess or not featureAccess.enabled: + raise HTTPException(status_code=403, detail="Access denied to this feature instance") + + return str(instance.mandateId) if instance.mandateId else None + + +@router.post("/{instanceId}/start/stream") +@limiter.limit("60/minute") +async def streamCodeeditorStart( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: Optional[str] = Query(None, description="Optional workflow ID to continue"), + userInput: UserInputRequest = Body(...), + context: RequestContext = Depends(getRequestContext) +): + """Start or continue a CodeEditor workflow with SSE streaming.""" + try: + mandateId = _validateInstanceAccess(instanceId, context) + chatInterface = _getServiceChat(context, featureInstanceId=instanceId) + dbManagement = _getDbManagement(context, featureInstanceId=instanceId) + aiObjects = await _getAiObjects() + eventManager = get_event_manager() + + if workflowId: + workflow = chatInterface.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") + else: + workflow = chatInterface.createWorkflow({ + "workflowMode": "CodeEditor", + "status": "running", + "label": userInput.prompt[:80] if userInput.prompt else "CodeEditor Session", + }) + workflowId = workflow.get("id") if isinstance(workflow, dict) else workflow.id + + queue = eventManager.create_queue(workflowId) + + userMessage = { + "role": "user", + "content": userInput.prompt, + "selectedFiles": userInput.listFileId or [] + } + await eventManager.emit_event(workflowId, "chatdata", { + "type": "message", "item": userMessage + }) + + selectedFileIds = userInput.listFileId or [] + + asyncio.create_task( + codeEditorProcessor.processMessage( + workflowId=workflowId, + userPrompt=userInput.prompt, + selectedFileIds=selectedFileIds, + dbManagement=dbManagement, + interfaceAi=aiObjects, + chatInterface=chatInterface, + eventManager=eventManager + ) + ) + + async def _eventStream(): + streamTimeout = 300 + lastActivity = asyncio.get_event_loop().time() + + while True: + now = asyncio.get_event_loop().time() + if now - lastActivity > streamTimeout: + yield f"data: {json.dumps({'type': 'error', 'error': 'Stream timeout'})}\n\n" + break + + if await request.is_disconnected(): + logger.info(f"Client disconnected for workflow {workflowId}") + break + + try: + event = await asyncio.wait_for(queue.get(), timeout=1.0) + lastActivity = asyncio.get_event_loop().time() + + eventType = event.get("type", "") + + if eventType == "chatdata": + yield f"data: {json.dumps(event.get('data', {}))}\n\n" + elif eventType in ("complete", "stopped", "error"): + yield f"data: {json.dumps({'type': eventType, **event.get('data', {})})}\n\n" + break + else: + yield f"data: {json.dumps(event)}\n\n" + + except asyncio.TimeoutError: + yield ": keepalive\n\n" + + await eventManager.cleanup(workflowId) + + return StreamingResponse( + _eventStream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in streamCodeeditorStart: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{instanceId}/{workflowId}/stop") +@limiter.limit("120/minute") +async def stopWorkflow( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + context: RequestContext = Depends(getRequestContext) +): + """Stop a running CodeEditor workflow.""" + try: + _validateInstanceAccess(instanceId, context) + eventManager = get_event_manager() + await eventManager.emit_event(workflowId, "stopped", { + "workflowId": workflowId + }, event_category="workflow", message="Workflow stopped by user") + return {"status": "stopped", "workflowId": workflowId} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error stopping workflow: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{instanceId}/{workflowId}/chatData") +@limiter.limit("120/minute") +def getWorkflowChatData( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + afterTimestamp: Optional[float] = Query(None, description="Unix timestamp for incremental fetch"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Get chat data for a workflow (polling fallback).""" + try: + _validateInstanceAccess(instanceId, context) + chatInterface = _getServiceChat(context, featureInstanceId=instanceId) + workflow = chatInterface.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") + return chatInterface.getUnifiedChatData(workflowId, afterTimestamp) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting chat data: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{instanceId}/workflows") +@limiter.limit("120/minute") +def getWorkflows( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + page: int = Query(1, ge=1), + pageSize: int = Query(20, ge=1, le=100), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """List workflows for this feature instance.""" + try: + _validateInstanceAccess(instanceId, context) + chatInterface = _getServiceChat(context, featureInstanceId=instanceId) + from modules.datamodels.datamodelPagination import PaginationParams + pagination = PaginationParams(page=page, pageSize=pageSize) + return chatInterface.getWorkflows(pagination=pagination) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting workflows: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{instanceId}/files") +@limiter.limit("120/minute") +def getFiles( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """List all text files accessible to the user.""" + try: + _validateInstanceAccess(instanceId, context) + dbManagement = _getDbManagement(context, featureInstanceId=instanceId) + textFiles = fileContextManager.listTextFiles(dbManagement) + return { + "files": [f.model_dump(exclude={"content"}) for f in textFiles], + "count": len(textFiles) + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing files: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{instanceId}/files/{fileId}/content") +@limiter.limit("120/minute") +def getFileContent( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + fileId: str = Path(..., description="File ID"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Get the text content of a file.""" + try: + _validateInstanceAccess(instanceId, context) + dbManagement = _getDbManagement(context, featureInstanceId=instanceId) + + fileItem = dbManagement.getFile(fileId) + if not fileItem: + raise HTTPException(status_code=404, detail=f"File {fileId} not found") + + fileData = dbManagement.getFileData(fileId) + if not fileData: + raise HTTPException(status_code=404, detail=f"No data for file {fileId}") + + try: + content = fileData.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(status_code=400, detail="File is not valid UTF-8 text") + + return { + "fileId": fileId, + "fileName": fileItem.fileName, + "mimeType": fileItem.mimeType, + "content": content + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting file content: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{instanceId}/{workflowId}/apply") +@limiter.limit("60/minute") +async def applyEdit( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + proposalData: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Accept a file edit proposal and create a new file version.""" + try: + _validateInstanceAccess(instanceId, context) + dbManagement = _getDbManagement(context, featureInstanceId=instanceId) + + fileId = proposalData.get("fileId") + newContent = proposalData.get("newContent") + fileName = proposalData.get("fileName", "") + + if not fileId or newContent is None: + raise HTTPException(status_code=400, detail="fileId and newContent are required") + + fileItem = dbManagement.getFile(fileId) + if not fileItem: + raise HTTPException(status_code=404, detail=f"File {fileId} not found") + + success = dbManagement.createFileData(fileId, newContent.encode("utf-8")) + if not success: + raise HTTPException(status_code=500, detail="Failed to store updated file content") + + eventManager = get_event_manager() + await eventManager.emit_event(workflowId, "chatdata", { + "type": "file_version", + "item": { + "fileId": fileId, + "fileName": fileName or fileItem.fileName, + "status": "accepted" + } + }) + + return { + "status": "accepted", + "fileId": fileId, + "fileName": fileName or fileItem.fileName + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error applying edit: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index ae6ef2e5..609dd61b 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -1932,6 +1932,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: storeResources = [ "resource.store.automation", "resource.store.chatplayground", + "resource.store.codeeditor", "resource.store.teamsbot", ] diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index e896c4f3..e3b001b1 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -105,6 +105,9 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: elif featureCode == "chatplayground": from modules.features.chatplayground.mainChatplayground import UI_OBJECTS return UI_OBJECTS + elif featureCode == "codeeditor": + from modules.features.codeeditor.mainCodeeditor import UI_OBJECTS + return UI_OBJECTS elif featureCode == "automation": from modules.features.automation.mainAutomation import UI_OBJECTS return UI_OBJECTS diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index feb997e8..927986e4 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -442,6 +442,11 @@ RESOURCE_OBJECTS = [ "label": {"en": "Store: Chat Playground", "de": "Store: Chat Playground", "fr": "Store: Chat Playground"}, "meta": {"category": "store", "featureCode": "chatplayground"} }, + { + "objectKey": "resource.store.codeeditor", + "label": {"en": "Store: Code Editor", "de": "Store: Code Editor", "fr": "Store: Code Editor"}, + "meta": {"category": "store", "featureCode": "codeeditor"} + }, { "objectKey": "resource.store.teamsbot", "label": {"en": "Store: Teams Bot", "de": "Store: Teams Bot", "fr": "Store: Teams Bot"},