From 338f9522a5ab2906881cc046e01a3dc783ba202e Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Mon, 23 Feb 2026 18:35:36 +0100
Subject: [PATCH 1/2] 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"},
From f6f42d8db7cfd9fd3c616aa228dd2e368960f458 Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Mon, 23 Feb 2026 22:09:27 +0100
Subject: [PATCH 2/2] phase 2 codeeditor and hotfixes voice
---
app.py | 7 +
modules/connectors/connectorVoiceGoogle.py | 36 ++-
.../codeeditor/codeEditorProcessor.py | 260 +++++++++++++-----
.../codeeditor/datamodelCodeeditor.py | 24 ++
.../features/codeeditor/fileContextManager.py | 9 +
modules/features/codeeditor/promptAssembly.py | 78 ++++++
modules/features/codeeditor/responseParser.py | 47 +++-
.../codeeditor/routeFeatureCodeeditor.py | 67 +++--
modules/features/codeeditor/toolRegistry.py | 157 +++++++++++
modules/features/teamsbot/service.py | 10 +-
10 files changed, 605 insertions(+), 90 deletions(-)
create mode 100644 modules/features/codeeditor/toolRegistry.py
diff --git a/app.py b/app.py
index 540d4e4d..68b51af0 100644
--- a/app.py
+++ b/app.py
@@ -461,6 +461,13 @@ app.add_middleware(
max_age=86400, # Increased caching for preflight requests
)
+# SlowAPI rate limiter initialization
+from modules.auth import limiter
+from slowapi.errors import RateLimitExceeded
+from slowapi import _rate_limit_exceeded_handler
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+
# CSRF protection middleware
from modules.auth import CSRFMiddleware
from modules.auth import (
diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py
index 42efffcf..10720efc 100644
--- a/modules/connectors/connectorVoiceGoogle.py
+++ b/modules/connectors/connectorVoiceGoogle.py
@@ -7,6 +7,7 @@ Replaces Azure Speech Services with Google Cloud APIs
import json
import html
+import asyncio
import logging
from typing import Dict, Optional, Any
from google.cloud import speech
@@ -73,6 +74,11 @@ class ConnectorGoogleSpeech:
Dict containing transcribed text, confidence, and metadata
"""
try:
+ # Treat sampleRate=0 as unknown (invalid value from client)
+ if sampleRate is not None and sampleRate <= 0:
+ logger.warning(f"Invalid sampleRate={sampleRate}, treating as unknown for auto-detection")
+ sampleRate = None
+
# Auto-detect audio format if not provided
if sampleRate is None or channels is None:
validation = self.validateAudioFormat(audioContent)
@@ -164,8 +170,11 @@ class ConnectorGoogleSpeech:
try:
# Use regular recognition for single audio files (not streaming)
+ # Run in thread pool to avoid blocking the asyncio event loop
logger.info("Using regular recognition for single audio file...")
- response = self.speech_client.recognize(config=config, audio=audio)
+ response = await asyncio.to_thread(
+ self.speech_client.recognize, config=config, audio=audio
+ )
logger.debug(f"Google Cloud response: {response}")
except Exception as apiError:
@@ -175,7 +184,7 @@ class ConnectorGoogleSpeech:
logger.info("Trying fallback with LINEAR16 encoding...")
fallbackConfig = speech.RecognitionConfig(
encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
- sample_rate_hertz=16000, # Use standard sample rate
+ sample_rate_hertz=16000,
audio_channel_count=1,
language_code=language,
enable_automatic_punctuation=True,
@@ -183,7 +192,9 @@ class ConnectorGoogleSpeech:
)
try:
- response = self.speech_client.recognize(config=fallbackConfig, audio=audio)
+ response = await asyncio.to_thread(
+ self.speech_client.recognize, config=fallbackConfig, audio=audio
+ )
logger.debug(f"Google Cloud fallback response: {response}")
except Exception as fallbackError:
logger.error(f"Google Cloud fallback error: {fallbackError}")
@@ -297,7 +308,18 @@ class ConnectorGoogleSpeech:
"description": f"LINEAR16 with {std_rate}Hz"
})
- # Try with different models
+ # Detect likely silence before expensive fallback loop
+ if len(audioContent) > 100:
+ sampleSlice = audioContent[100:min(500, len(audioContent))]
+ if len(set(sampleSlice)) < 3:
+ logger.warning("Audio appears silent (low byte variation) - skipping fallbacks")
+ return {
+ "success": False,
+ "text": "",
+ "confidence": 0.0,
+ "error": "No recognition results (silence or unclear audio)"
+ }
+
models = ["latest_long", "phone_call", "latest_short"]
for fallback_config in fallback_configs:
@@ -305,7 +327,6 @@ class ConnectorGoogleSpeech:
try:
logger.info(f"Trying fallback: {fallback_config['description']} with {model} model...")
- # Build fallback config with proper sample rate handling
fallback_config_params = {
"encoding": fallback_config["encoding"],
"audio_channel_count": fallback_config["channels"],
@@ -314,12 +335,13 @@ class ConnectorGoogleSpeech:
"model": model
}
- # Only add sample_rate_hertz if needed
if fallback_config["use_sample_rate"]:
fallback_config_params["sample_rate_hertz"] = fallback_config["sample_rate"]
fallback_config_obj = speech.RecognitionConfig(**fallback_config_params)
- fallback_response = self.speech_client.recognize(config=fallback_config_obj, audio=audio)
+ fallback_response = await asyncio.to_thread(
+ self.speech_client.recognize, config=fallback_config_obj, audio=audio
+ )
if fallback_response.results:
result = fallback_response.results[0]
diff --git a/modules/features/codeeditor/codeEditorProcessor.py b/modules/features/codeeditor/codeEditorProcessor.py
index 7986590d..7e472016 100644
--- a/modules/features/codeeditor/codeEditorProcessor.py
+++ b/modules/features/codeeditor/codeEditorProcessor.py
@@ -1,16 +1,16 @@
# 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."""
+"""CodeEditor processor -- single-shot (Phase 1) and agent loop (Phase 2).
+Orchestrates file loading, prompt building, AI calls, response parsing, and SSE emission."""
import logging
-import uuid
-from typing import List, Optional, Dict, Any
+from typing import List, Dict, Any
from modules.features.codeeditor import fileContextManager, promptAssembly, responseParser
from modules.features.codeeditor.datamodelCodeeditor import (
- FileEditProposal, ResponseSegment, SegmentTypeEnum
+ FileEditProposal, SegmentTypeEnum, AgentState
)
+from modules.features.codeeditor import toolRegistry
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
@@ -23,78 +23,41 @@ async def processMessage(
dbManagement,
interfaceAi,
chatInterface,
- eventManager
+ eventManager,
+ agentMode: bool = False
):
- """Process a user message: load files, call AI, parse and emit response segments.
+ """Process a user message. Dispatches to single-shot or agent loop based on mode."""
+ if agentMode:
+ await _processAgentMessage(
+ workflowId, userPrompt, dbManagement, interfaceAi, chatInterface, eventManager
+ )
+ else:
+ await _processSingleShot(
+ workflowId, userPrompt, selectedFileIds, dbManagement, interfaceAi, chatInterface, eventManager
+ )
- 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
- """
+
+async def _processSingleShot(
+ workflowId, userPrompt, selectedFileIds, dbManagement, interfaceAi, chatInterface, eventManager
+):
+ """Phase 1: Single AI call with pre-loaded file context."""
try:
- await eventManager.emit_event(workflowId, "chatdata", {
- "type": "status", "label": "Loading files..."
- })
-
+ await _emitStatus(eventManager, workflowId, "Loading files...")
fileContexts = await fileContextManager.loadFileContexts(dbManagement, selectedFileIds)
- await eventManager.emit_event(workflowId, "chatdata", {
- "type": "status", "label": "Building prompt..."
- })
-
+ await _emitStatus(eventManager, workflowId, "Building prompt...")
chatHistory = _loadChatHistory(chatInterface, workflowId)
-
aiRequest = promptAssembly.buildRequest(userPrompt, fileContexts, chatHistory)
- await eventManager.emit_event(workflowId, "chatdata", {
- "type": "status", "label": "AI is processing..."
- })
-
+ await _emitStatus(eventManager, workflowId, "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
- })
+ await _emitError(eventManager, workflowId, 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()
- })
-
+ await _emitSegments(eventManager, workflowId, segments, fileContexts)
_logAiStats(aiResponse, workflowId)
await eventManager.emit_event(workflowId, "complete", {
@@ -105,12 +68,181 @@ async def processMessage(
})
except Exception as e:
- logger.error(f"CodeEditor processing failed for workflow {workflowId}: {e}", exc_info=True)
+ logger.error(f"CodeEditor single-shot failed for {workflowId}: {e}", exc_info=True)
await eventManager.emit_event(workflowId, "error", {
"workflowId": workflowId, "error": str(e)
})
+async def _processAgentMessage(
+ workflowId, userPrompt, dbManagement, interfaceAi, chatInterface, eventManager
+):
+ """Phase 2: Agent loop -- multiple AI calls with tool execution until done."""
+ state = AgentState(workflowId=workflowId)
+
+ try:
+ await _emitStatus(eventManager, workflowId, "Agent: Scanning available files...")
+ fileListContext = fileContextManager.buildFileListContext(dbManagement)
+
+ state.conversationHistory.append({"role": "user", "content": userPrompt})
+
+ aiRequest = promptAssembly.buildAgentRequest(
+ userPrompt=userPrompt,
+ fileListContext=fileListContext,
+ conversationHistory=[]
+ )
+
+ while state.status == "running" and state.currentRound < state.maxRounds:
+ state.currentRound += 1
+ state.totalAiCalls += 1
+
+ await _emitStatus(eventManager, workflowId,
+ f"Agent round {state.currentRound}: AI is thinking...")
+
+ await eventManager.emit_event(workflowId, "chatdata", {
+ "type": "agent_progress",
+ "item": {
+ "round": state.currentRound,
+ "totalAiCalls": state.totalAiCalls,
+ "totalToolCalls": state.totalToolCalls,
+ "costCHF": round(state.totalCostCHF, 4),
+ }
+ })
+
+ aiResponse = await interfaceAi.callWithTextContext(aiRequest)
+ state.totalCostCHF += aiResponse.priceCHF
+ state.totalProcessingTime += aiResponse.processingTime
+
+ if aiResponse.errorCount > 0:
+ logger.error(f"Agent AI call failed in round {state.currentRound}: {aiResponse.content}")
+ await _emitError(eventManager, workflowId, aiResponse.content)
+ state.status = "error"
+ break
+
+ _logAiStats(aiResponse, workflowId)
+
+ state.conversationHistory.append({"role": "assistant", "content": aiResponse.content})
+
+ segments = responseParser.parseResponse(aiResponse.content)
+
+ textAndEditSegments = [s for s in segments if s.type != SegmentTypeEnum.TOOL_CALL]
+ if textAndEditSegments:
+ await _emitSegments(eventManager, workflowId, textAndEditSegments, [])
+
+ toolCallSegments = [s for s in segments if s.type == SegmentTypeEnum.TOOL_CALL]
+
+ if not toolCallSegments:
+ state.status = "completed"
+ break
+
+ toolResultTexts = []
+ for tc in toolCallSegments:
+ state.totalToolCalls += 1
+ await _emitStatus(eventManager, workflowId,
+ f"Agent: Running {tc.toolName}...")
+
+ result = await toolRegistry.dispatch(tc.toolName, tc.toolArgs or {}, dbManagement)
+ toolResultTexts.append(f"[{tc.toolName}] (success={result.success}):\n{result.result}")
+
+ logger.info(f"Agent tool {tc.toolName}: success={result.success}, time={result.executionTime:.2f}s")
+
+ combinedResults = "\n\n".join(toolResultTexts)
+ state.conversationHistory.append({
+ "role": "tool_result",
+ "content": combinedResults,
+ "toolName": "batch"
+ })
+
+ aiRequest = promptAssembly.buildAgentRequest(
+ userPrompt=None,
+ fileListContext=fileListContext,
+ conversationHistory=state.conversationHistory
+ )
+
+ if state.currentRound >= state.maxRounds and state.status == "running":
+ state.status = "max_rounds"
+ await eventManager.emit_event(workflowId, "chatdata", {
+ "type": "message",
+ "item": {
+ "role": "system",
+ "content": f"Agent stopped: maximum rounds ({state.maxRounds}) reached.",
+ "createdAt": getUtcTimestamp()
+ }
+ })
+
+ await eventManager.emit_event(workflowId, "chatdata", {
+ "type": "agent_summary",
+ "item": {
+ "rounds": state.currentRound,
+ "totalAiCalls": state.totalAiCalls,
+ "totalToolCalls": state.totalToolCalls,
+ "costCHF": round(state.totalCostCHF, 4),
+ "processingTime": round(state.totalProcessingTime, 1),
+ "status": state.status,
+ }
+ })
+
+ await eventManager.emit_event(workflowId, "complete", {
+ "workflowId": workflowId,
+ "agentRounds": state.currentRound,
+ "totalCostCHF": round(state.totalCostCHF, 4),
+ "processingTime": round(state.totalProcessingTime, 1)
+ })
+
+ except Exception as e:
+ logger.error(f"CodeEditor agent loop failed for {workflowId}: {e}", exc_info=True)
+ await eventManager.emit_event(workflowId, "error", {
+ "workflowId": workflowId, "error": str(e)
+ })
+
+
+# ---------------------------------------------------------------------------
+# Shared helpers
+# ---------------------------------------------------------------------------
+
+async def _emitStatus(eventManager, workflowId: str, label: str):
+ await eventManager.emit_event(workflowId, "chatdata", {
+ "type": "status", "label": label
+ })
+
+
+async def _emitError(eventManager, workflowId: str, errorMsg: str):
+ await eventManager.emit_event(workflowId, "chatdata", {
+ "type": "message",
+ "item": {"role": "assistant", "content": f"Error: {errorMsg}"}
+ })
+ await eventManager.emit_event(workflowId, "error", {
+ "workflowId": workflowId, "error": errorMsg
+ })
+
+
+async def _emitSegments(eventManager, workflowId: str, segments, fileContexts):
+ """Emit parsed segments as SSE events."""
+ 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()
+ })
+
+
def _loadChatHistory(chatInterface, workflowId: str) -> List[Dict[str, Any]]:
"""Load recent chat messages for multi-turn context."""
try:
diff --git a/modules/features/codeeditor/datamodelCodeeditor.py b/modules/features/codeeditor/datamodelCodeeditor.py
index 7f39cbca..72a4f9bc 100644
--- a/modules/features/codeeditor/datamodelCodeeditor.py
+++ b/modules/features/codeeditor/datamodelCodeeditor.py
@@ -13,6 +13,7 @@ class SegmentTypeEnum(str, Enum):
TEXT = "text"
CODE_BLOCK = "code_block"
FILE_EDIT = "file_edit"
+ TOOL_CALL = "tool_call"
class EditStatusEnum(str, Enum):
@@ -40,6 +41,8 @@ class ResponseSegment(BaseModel):
fileName: Optional[str] = None
oldContent: Optional[str] = None
newContent: Optional[str] = None
+ toolName: Optional[str] = None
+ toolArgs: Optional[Dict[str, Any]] = None
class FileEditProposal(BaseModel):
@@ -65,6 +68,27 @@ class FileVersion(BaseModel):
createdAt: float = Field(default_factory=getUtcTimestamp)
+class AgentState(BaseModel):
+ """Tracks state across an agent loop execution."""
+ workflowId: str
+ currentRound: int = 0
+ maxRounds: int = 50
+ totalAiCalls: int = 0
+ totalToolCalls: int = 0
+ totalCostCHF: float = 0.0
+ totalProcessingTime: float = 0.0
+ conversationHistory: List[Dict[str, Any]] = Field(default_factory=list)
+ status: str = "running"
+
+
+class ToolResult(BaseModel):
+ """Result from executing a tool."""
+ toolName: str
+ result: str
+ success: bool = True
+ executionTime: float = 0.0
+
+
TEXT_MIME_TYPES = {
"text/plain", "text/markdown", "text/html", "text/css", "text/csv",
"text/xml", "text/yaml", "text/x-python", "text/x-java",
diff --git a/modules/features/codeeditor/fileContextManager.py b/modules/features/codeeditor/fileContextManager.py
index 558c40c5..d0e2ff1b 100644
--- a/modules/features/codeeditor/fileContextManager.py
+++ b/modules/features/codeeditor/fileContextManager.py
@@ -71,3 +71,12 @@ def listTextFiles(dbManagement) -> List[FileContext]:
))
return textFiles
+
+
+def buildFileListContext(dbManagement) -> str:
+ """Build a compact file list string for the agent prompt (no content, just metadata)."""
+ textFiles = listTextFiles(dbManagement)
+ if not textFiles:
+ return "No text files available."
+ lines = [f"- {f.fileName} (id: {f.fileId}, size: {f.sizeBytes}B)" for f in textFiles]
+ return f"Total: {len(lines)} text files\n" + "\n".join(lines)
diff --git a/modules/features/codeeditor/promptAssembly.py b/modules/features/codeeditor/promptAssembly.py
index db145524..95f4ce91 100644
--- a/modules/features/codeeditor/promptAssembly.py
+++ b/modules/features/codeeditor/promptAssembly.py
@@ -89,6 +89,84 @@ def _buildFileContext(fileContexts: List[FileContext]) -> str:
return "\n\n".join(parts)
+def buildAgentRequest(
+ userPrompt: Optional[str],
+ fileListContext: str,
+ conversationHistory: List[Dict[str, Any]]
+) -> AiCallRequest:
+ """Build an AiCallRequest for agent mode with tool definitions and conversation history."""
+ from modules.features.codeeditor.toolRegistry import formatToolDefinitions
+
+ systemPrompt = _AGENT_SYSTEM_PROMPT.replace("{{TOOL_DEFINITIONS}}", formatToolDefinitions())
+
+ if not conversationHistory:
+ fullPrompt = systemPrompt
+ context = f"## Available files\n{fileListContext}\n\n## Task\n{userPrompt}"
+ else:
+ fullPrompt = systemPrompt
+ historyText = _buildConversationHistory(conversationHistory)
+ context = f"## Available files\n{fileListContext}\n\n## Conversation\n{historyText}"
+
+ return AiCallRequest(
+ prompt=fullPrompt,
+ context=context,
+ options=AiCallOptions(
+ operationType=OperationTypeEnum.DATA_ANALYSE,
+ temperature=0.0,
+ compressPrompt=False,
+ compressContext=False,
+ resultFormat="txt"
+ )
+ )
+
+
+_AGENT_SYSTEM_PROMPT = """You are an AI agent for file analysis and editing. You work autonomously by using tools to read files, search content, and propose edits.
+
+## Available tools
+{{TOOL_DEFINITIONS}}
+
+## How to call tools
+Use this exact format for each tool call:
+
+```tool_call
+tool:
+args: {"param": "value"}
+```
+
+## Rules
+- Read files ONE AT A TIME with read_file, never assume file contents
+- First create a plan, then execute it step by step
+- Use search_files to find relevant files before reading them
+- Use list_files to discover what files are available
+- For file changes, use ```file_edit``` blocks (same format as before)
+- You may combine text explanations, tool calls, and file edits in one response
+- When you are DONE and need no more tool calls, simply respond with text only (no tool_call blocks)
+- Keep responses focused and efficient
+
+## file_edit format (for changes)
+```file_edit
+fileName:
+oldContent: |
+
+newContent: |
+
+```"""
+
+
+def _buildConversationHistory(history: List[Dict[str, Any]]) -> str:
+ """Build the full conversation history for agent multi-turn context."""
+ parts = []
+ for msg in history:
+ role = msg.get("role", "unknown")
+ content = msg.get("content", "")
+ if role == "tool_result":
+ toolName = msg.get("toolName", "")
+ parts.append(f"[Tool Result - {toolName}]:\n{content}")
+ else:
+ parts.append(f"[{role}]:\n{content}")
+ 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:
diff --git a/modules/features/codeeditor/responseParser.py b/modules/features/codeeditor/responseParser.py
index 998a0abb..8003e7b2 100644
--- a/modules/features/codeeditor/responseParser.py
+++ b/modules/features/codeeditor/responseParser.py
@@ -1,9 +1,10 @@
# 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)."""
+Parses AI responses into typed segments (text, code_block, file_edit, tool_call)."""
import logging
+import json
import re
from typing import List, Optional
@@ -48,6 +49,16 @@ def parseResponse(rawContent: str) -> List[ResponseSegment]:
content=blockContent,
language="text"
))
+ elif lang == "tool_call":
+ segment = _parseToolCallBlock(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,
@@ -66,6 +77,11 @@ def parseResponse(rawContent: str) -> List[ResponseSegment]:
return segments
+def hasToolCalls(segments: List[ResponseSegment]) -> bool:
+ """Check if any segments contain tool calls."""
+ return any(s.type == SegmentTypeEnum.TOOL_CALL for s in segments)
+
+
def _collectBlock(lines: List[str], startIdx: int) -> tuple:
"""Collect lines inside a fenced code block until closing ```."""
blockLines = []
@@ -137,3 +153,32 @@ def _parseFileEditBlock(blockContent: str) -> Optional[ResponseSegment]:
oldContent=fields["oldContent"],
newContent=fields["newContent"]
)
+
+
+def _parseToolCallBlock(blockContent: str) -> Optional[ResponseSegment]:
+ """Parse a tool_call block into a ResponseSegment with toolName and toolArgs."""
+ toolName = None
+ toolArgs = {}
+
+ for line in blockContent.split("\n"):
+ stripped = line.strip()
+ if stripped.startswith("tool:"):
+ toolName = stripped[len("tool:"):].strip()
+ elif stripped.startswith("args:"):
+ argsStr = stripped[len("args:"):].strip()
+ try:
+ toolArgs = json.loads(argsStr)
+ except json.JSONDecodeError:
+ logger.warning(f"Could not parse tool args as JSON: {argsStr}")
+ toolArgs = {"raw": argsStr}
+
+ if not toolName:
+ logger.warning("tool_call block missing tool name")
+ return None
+
+ return ResponseSegment(
+ type=SegmentTypeEnum.TOOL_CALL,
+ content=f"Tool: {toolName}",
+ toolName=toolName,
+ toolArgs=toolArgs
+ )
diff --git a/modules/features/codeeditor/routeFeatureCodeeditor.py b/modules/features/codeeditor/routeFeatureCodeeditor.py
index 421e9bbe..0d76389c 100644
--- a/modules/features/codeeditor/routeFeatureCodeeditor.py
+++ b/modules/features/codeeditor/routeFeatureCodeeditor.py
@@ -80,10 +80,11 @@ async def streamCodeeditorStart(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: Optional[str] = Query(None, description="Optional workflow ID to continue"),
+ mode: str = Query("simple", description="Processing mode: 'simple' (single AI call) or 'agent' (multi-step with tools)"),
userInput: UserInputRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
):
- """Start or continue a CodeEditor workflow with SSE streaming."""
+ """Start or continue a CodeEditor workflow with SSE streaming. Supports simple and agent mode."""
try:
mandateId = _validateInstanceAccess(instanceId, context)
chatInterface = _getServiceChat(context, featureInstanceId=instanceId)
@@ -116,6 +117,8 @@ async def streamCodeeditorStart(
selectedFileIds = userInput.listFileId or []
+ agentMode = mode.lower() == "agent"
+
asyncio.create_task(
codeEditorProcessor.processMessage(
workflowId=workflowId,
@@ -124,7 +127,8 @@ async def streamCodeeditorStart(
dbManagement=dbManagement,
interfaceAi=aiObjects,
chatInterface=chatInterface,
- eventManager=eventManager
+ eventManager=eventManager,
+ agentMode=agentMode
)
)
@@ -319,40 +323,52 @@ async def applyEdit(
proposalData: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
- """Accept a file edit proposal and create a new file version."""
+ """Accept a file edit proposal. Updates existing file or creates new one."""
try:
_validateInstanceAccess(instanceId, context)
dbManagement = _getDbManagement(context, featureInstanceId=instanceId)
- fileId = proposalData.get("fileId")
+ 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")
+ if newContent is None:
+ raise HTTPException(status_code=400, detail="newContent is required")
- fileItem = dbManagement.getFile(fileId)
- if not fileItem:
- raise HTTPException(status_code=404, detail=f"File {fileId} not found")
+ contentBytes = newContent.encode("utf-8")
+ isNewFile = not fileId or fileId.startswith("unknown-")
- success = dbManagement.createFileData(fileId, newContent.encode("utf-8"))
- if not success:
- raise HTTPException(status_code=500, detail="Failed to store updated file content")
+ if isNewFile:
+ mimeType = _guessMimeType(fileName)
+ fileItem = dbManagement.createFile(fileName, mimeType, contentBytes)
+ resultFileId = fileItem.id
+ resultFileName = fileItem.fileName
+ else:
+ fileItem = dbManagement.getFile(fileId)
+ if not fileItem:
+ raise HTTPException(status_code=404, detail=f"File {fileId} not found")
+ success = dbManagement.createFileData(fileId, contentBytes)
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to store updated file content")
+ resultFileId = fileId
+ resultFileName = fileName or fileItem.fileName
eventManager = get_event_manager()
await eventManager.emit_event(workflowId, "chatdata", {
"type": "file_version",
"item": {
- "fileId": fileId,
- "fileName": fileName or fileItem.fileName,
- "status": "accepted"
+ "fileId": resultFileId,
+ "fileName": resultFileName,
+ "status": "accepted",
+ "isNew": isNewFile
}
})
return {
"status": "accepted",
- "fileId": fileId,
- "fileName": fileName or fileItem.fileName
+ "fileId": resultFileId,
+ "fileName": resultFileName,
+ "isNew": isNewFile
}
except HTTPException:
@@ -360,3 +376,20 @@ async def applyEdit(
except Exception as e:
logger.error(f"Error applying edit: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
+
+
+_MIME_MAP = {
+ ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
+ ".yaml": "application/yaml", ".yml": "application/yaml", ".xml": "application/xml",
+ ".csv": "text/csv", ".py": "text/x-python", ".js": "text/javascript",
+ ".ts": "text/x-typescript", ".html": "text/html", ".css": "text/css",
+ ".sql": "text/x-sql", ".sh": "text/x-shellscript",
+}
+
+
+def _guessMimeType(fileName: str) -> str:
+ """Guess MIME type from file extension."""
+ if not fileName or "." not in fileName:
+ return "text/plain"
+ ext = "." + fileName.rsplit(".", 1)[-1].lower()
+ return _MIME_MAP.get(ext, "text/plain")
diff --git a/modules/features/codeeditor/toolRegistry.py b/modules/features/codeeditor/toolRegistry.py
new file mode 100644
index 00000000..69256671
--- /dev/null
+++ b/modules/features/codeeditor/toolRegistry.py
@@ -0,0 +1,157 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Tool registry and dispatcher for the CodeEditor agent loop.
+Defines available tools and executes them against the file context manager."""
+
+import logging
+import time
+import fnmatch
+from typing import Dict, Any, List
+
+from modules.features.codeeditor.datamodelCodeeditor import ToolResult
+
+logger = logging.getLogger(__name__)
+
+TOOL_DEFINITIONS = [
+ {
+ "name": "read_file",
+ "description": "Read the full content of a single file by its fileId.",
+ "parameters": {"fileId": "string (required)"}
+ },
+ {
+ "name": "list_files",
+ "description": "List all available text files with metadata (name, size, mimeType). Optionally filter by glob pattern.",
+ "parameters": {"filter": "string (optional, glob pattern e.g. '*.py')"}
+ },
+ {
+ "name": "search_files",
+ "description": "Search all file contents for a text query. Returns matching lines with file name and line number.",
+ "parameters": {"query": "string (required)", "fileType": "string (optional, extension e.g. 'py')"}
+ },
+]
+
+
+async def dispatch(toolName: str, toolArgs: Dict[str, Any], dbManagement) -> ToolResult:
+ """Execute a tool and return the result."""
+ startTime = time.time()
+ try:
+ if toolName == "read_file":
+ result = await _toolReadFile(toolArgs, dbManagement)
+ elif toolName == "list_files":
+ result = _toolListFiles(toolArgs, dbManagement)
+ elif toolName == "search_files":
+ result = await _toolSearchFiles(toolArgs, dbManagement)
+ else:
+ result = f"Unknown tool: {toolName}"
+ return ToolResult(toolName=toolName, result=result, success=False,
+ executionTime=time.time() - startTime)
+
+ return ToolResult(toolName=toolName, result=result, success=True,
+ executionTime=time.time() - startTime)
+ except Exception as e:
+ logger.error(f"Tool {toolName} failed: {e}", exc_info=True)
+ return ToolResult(toolName=toolName, result=f"Error: {str(e)}", success=False,
+ executionTime=time.time() - startTime)
+
+
+async def _toolReadFile(args: Dict[str, Any], dbManagement) -> str:
+ """Read a single file's content."""
+ fileId = args.get("fileId", "")
+ if not fileId:
+ return "Error: fileId is required"
+
+ fileItem = dbManagement.getFile(fileId)
+ if not fileItem:
+ return f"Error: File {fileId} not found"
+
+ fileData = dbManagement.getFileData(fileId)
+ if not fileData:
+ return f"Error: No data for file {fileId}"
+
+ try:
+ content = fileData.decode("utf-8")
+ except UnicodeDecodeError:
+ return f"Error: File {fileItem.fileName} is not valid UTF-8"
+
+ lines = content.split("\n")
+ numbered = "\n".join([f"{i + 1}|{line}" for i, line in enumerate(lines)])
+ return f"--- FILE: {fileItem.fileName} (id: {fileId}) ---\n{numbered}\n--- END FILE ---"
+
+
+def _toolListFiles(args: Dict[str, Any], dbManagement) -> str:
+ """List all text files, optionally filtered by glob pattern."""
+ from modules.features.codeeditor.datamodelCodeeditor import isTextFile
+
+ filterPattern = args.get("filter", "")
+ allFiles = dbManagement.getAllFiles()
+ if not allFiles:
+ return "No files found."
+
+ lines = []
+ for f in allFiles:
+ if not isTextFile(f.mimeType, f.fileName):
+ continue
+ if filterPattern and not fnmatch.fnmatch(f.fileName, filterPattern):
+ continue
+ lines.append(f"- {f.fileName} (id: {f.id}, size: {f.fileSize}B, type: {f.mimeType})")
+
+ if not lines:
+ return "No matching text files found."
+ return f"Available files ({len(lines)}):\n" + "\n".join(lines)
+
+
+async def _toolSearchFiles(args: Dict[str, Any], dbManagement) -> str:
+ """Search file contents for a query string."""
+ from modules.features.codeeditor.datamodelCodeeditor import isTextFile
+
+ query = args.get("query", "")
+ if not query:
+ return "Error: query is required"
+
+ fileType = args.get("fileType", "")
+ allFiles = dbManagement.getAllFiles()
+ if not allFiles:
+ return "No files to search."
+
+ hits = []
+ maxHits = 50
+ queryLower = query.lower()
+
+ for f in allFiles:
+ if not isTextFile(f.mimeType, f.fileName):
+ continue
+ if fileType and not f.fileName.endswith(f".{fileType}"):
+ continue
+
+ fileData = dbManagement.getFileData(f.id)
+ if not fileData:
+ continue
+
+ try:
+ content = fileData.decode("utf-8")
+ except UnicodeDecodeError:
+ continue
+
+ for lineNum, line in enumerate(content.split("\n"), 1):
+ if queryLower in line.lower():
+ hits.append(f"{f.fileName}:{lineNum}: {line.strip()}")
+ if len(hits) >= maxHits:
+ break
+ if len(hits) >= maxHits:
+ break
+
+ if not hits:
+ return f"No matches found for '{query}'."
+ result = f"Search results for '{query}' ({len(hits)} matches):\n" + "\n".join(hits)
+ if len(hits) >= maxHits:
+ result += f"\n... (truncated at {maxHits} matches)"
+ return result
+
+
+def formatToolDefinitions() -> str:
+ """Format tool definitions for inclusion in the system prompt."""
+ parts = []
+ for tool in TOOL_DEFINITIONS:
+ params = ", ".join([f"{k}: {v}" for k, v in tool["parameters"].items()])
+ parts.append(f"- **{tool['name']}**: {tool['description']}\n Parameters: {{{params}}}")
+ return "\n".join(parts)
diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py
index 919b77fa..fa468ff2 100644
--- a/modules/features/teamsbot/service.py
+++ b/modules/features/teamsbot/service.py
@@ -401,14 +401,22 @@ class TeamsbotService:
if len(audioBytes) < 1000:
return
+ # Detect silent/all-zeros audio early to avoid expensive STT calls
+ if len(set(audioBytes[100:min(500, len(audioBytes))])) < 3:
+ logger.debug(f"[AudioChunk] Skipping silent audio ({len(audioBytes)} bytes, low byte variation)")
+ return
+
if not voiceInterface:
logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}")
return
+ # Treat sampleRate=0 as unknown (triggers auto-detection)
+ effectiveSampleRate = sampleRate if sampleRate and sampleRate > 0 else None
+
sttResult = await voiceInterface.speechToText(
audioContent=audioBytes,
language=self.config.language or "de-DE",
- sampleRate=sampleRate,
+ sampleRate=effectiveSampleRate,
)
if sttResult and sttResult.get("success") and sttResult.get("text"):