From b5dc643dacc928c9f0122550256e81454c4152a6 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 16 Mar 2026 11:38:18 +0100 Subject: [PATCH] ai workspace ui fixes --- modules/connectors/connectorDbPostgre.py | 5 +- .../workspace/routeFeatureWorkspace.py | 163 +++++++++++++++--- modules/routes/routeDataFiles.py | 5 + .../serviceAgent/conversationManager.py | 19 +- .../services/serviceAgent/mainServiceAgent.py | 16 +- 5 files changed, 176 insertions(+), 32 deletions(-) diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 8f003d57..77689ae5 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -1030,7 +1030,10 @@ class DatabaseConnector: existingRecord.update(record) # Save updated record - self._saveRecord(model_class, recordId, existingRecord) + saved = self._saveRecord(model_class, recordId, existingRecord) + if not saved: + table = model_class.__name__ + raise ValueError(f"Failed to save record {recordId} to table {table}") return existingRecord def recordDelete(self, model_class: type, recordId: str) -> bool: diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 5b1dc679..d5f85169 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -98,6 +98,30 @@ def _getDbManagement(context: RequestContext, featureInstanceId: str = None): ) +def _buildDataSourceContext(chatInterface, dataSourceIds: List[str]) -> str: + """Build a description of active data sources for the agent prompt.""" + parts = [] + for dsId in dataSourceIds: + try: + ds = chatInterface.db.recordGet("DataSource", dsId) + if ds: + label = ds.get("label", "") + sourceType = ds.get("sourceType", "") + path = ds.get("path", "/") + parts.append(f"- {label} ({sourceType}, path: {path})") + except Exception: + pass + return "\n".join(parts) if parts else "" + + +def _deriveWorkflowName(prompt: str, maxLen: int = 40) -> str: + """Derive a short workflow name from the user's first prompt.""" + clean = " ".join(prompt.split()) + if len(clean) <= maxLen: + return clean + return clean[:maxLen].rsplit(" ", 1)[0] + "..." + + # --------------------------------------------------------------------------- # SSE Stream endpoint # --------------------------------------------------------------------------- @@ -121,12 +145,11 @@ async def streamWorkspaceStart( if not workflow: raise HTTPException(status_code=404, detail=f"Workflow {userInput.workflowId} not found") else: - existingWorkflows = chatInterface.getWorkflows() or [] - nextNum = len(existingWorkflows) + 1 + autoName = _deriveWorkflowName(userInput.prompt) workflow = chatInterface.createWorkflow({ "featureInstanceId": instanceId, "status": "active", - "name": f"Chat {nextNum}", + "name": autoName, "workflowMode": "Dynamic", }) @@ -219,8 +242,14 @@ async def _runWorkspaceAgent( ) agentService = getService("agent", ctx) + enrichedPrompt = prompt + if dataSourceIds: + dsInfo = _buildDataSourceContext(chatInterface, dataSourceIds) + if dsInfo: + enrichedPrompt = f"{prompt}\n\n[Active Data Sources]\n{dsInfo}" + async for event in agentService.runAgent( - prompt=prompt, + prompt=enrichedPrompt, fileIds=fileIds, workflowId=workflowId, userLanguage=userLanguage, @@ -296,30 +325,35 @@ async def stopWorkspace( async def listWorkspaceWorkflows( request: Request, instanceId: str = Path(...), + includeArchived: bool = Query(default=False, description="Include archived workflows"), context: RequestContext = Depends(getRequestContext), ): - """List all workspace workflows/conversations for this instance.""" + """List workspace workflows/conversations for this instance.""" _validateInstanceAccess(instanceId, context) chatInterface = _getChatInterface(context, featureInstanceId=instanceId) workflows = chatInterface.getWorkflows() or [] items = [] for wf in workflows: if isinstance(wf, dict): - items.append(wf) + item = wf else: - items.append({ + item = { "id": getattr(wf, "id", None), "name": getattr(wf, "name", ""), "status": getattr(wf, "status", ""), "startedAt": getattr(wf, "startedAt", None), "lastActivity": getattr(wf, "lastActivity", None), - }) + } + if not includeArchived and item.get("status") == "archived": + continue + items.append(item) return JSONResponse({"workflows": items}) class UpdateWorkflowRequest(BaseModel): """Request body for updating a workflow (PATCH).""" name: Optional[str] = Field(default=None, description="New workflow name") + status: Optional[str] = Field(default=None, description="New status (active, archived)") @router.patch("/{instanceId}/workflows/{workflowId}") @@ -340,6 +374,8 @@ async def patchWorkspaceWorkflow( updateData = {} if body.name is not None: updateData["name"] = body.name + if body.status is not None: + updateData["status"] = body.status if not updateData: updated = workflow else: @@ -355,6 +391,47 @@ async def patchWorkspaceWorkflow( }) +@router.delete("/{instanceId}/workflows/{workflowId}") +@limiter.limit("30/minute") +async def deleteWorkspaceWorkflow( + request: Request, + instanceId: str = Path(...), + workflowId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Delete a workspace workflow and its messages.""" + _validateInstanceAccess(instanceId, context) + chatInterface = _getChatInterface(context, featureInstanceId=instanceId) + workflow = chatInterface.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") + chatInterface.deleteWorkflow(workflowId) + return JSONResponse({"status": "deleted", "workflowId": workflowId}) + + +@router.post("/{instanceId}/workflows") +@limiter.limit("30/minute") +async def createWorkspaceWorkflow( + request: Request, + instanceId: str = Path(...), + body: dict = Body(default={}), + context: RequestContext = Depends(getRequestContext), +): + """Create a new empty workspace workflow.""" + _validateInstanceAccess(instanceId, context) + chatInterface = _getChatInterface(context, featureInstanceId=instanceId) + name = body.get("name", "Neuer Chat") + workflow = chatInterface.createWorkflow({ + "featureInstanceId": instanceId, + "status": "active", + "name": name, + "workflowMode": "Dynamic", + }) + wfId = workflow.get("id") if isinstance(workflow, dict) else getattr(workflow, "id", None) + wfName = workflow.get("name") if isinstance(workflow, dict) else getattr(workflow, "name", name) + return JSONResponse({"id": wfId, "name": wfName, "status": "active"}) + + @router.get("/{instanceId}/workflows/{workflowId}/messages") @limiter.limit("60/minute") async def getWorkspaceMessages( @@ -398,7 +475,23 @@ async def listWorkspaceFiles( _validateInstanceAccess(instanceId, context) dbMgmt = _getDbManagement(context, featureInstanceId=instanceId) files = dbMgmt.getAllFiles() - return JSONResponse({"files": [f if isinstance(f, dict) else f.model_dump() for f in (files or [])]}) + + from modules.interfaces.interfaceDbApp import getRootInterface + rootInterface = getRootInterface() + instanceLabelCache: dict = {} + + result = [] + for f in (files or []): + item = f if isinstance(f, dict) else f.model_dump() + fiId = item.get("featureInstanceId") or "" + if fiId and fiId not in instanceLabelCache: + fi = rootInterface.getFeatureInstance(fiId) + instanceLabelCache[fiId] = fi.label if fi else fiId + item["featureInstanceId"] = fiId + item["featureInstanceLabel"] = instanceLabelCache.get(fiId, "(Global)") + result.append(item) + + return JSONResponse({"files": result}) @router.get("/{instanceId}/files/{fileId}/content") @@ -735,11 +828,18 @@ async def getVoiceSettings( _validateInstanceAccess(instanceId, context) dbMgmt = _getDbManagement(context, instanceId) userId = str(context.user.id) - vs = dbMgmt.getVoiceSettings(userId) - if not vs: - vs = dbMgmt.getOrCreateVoiceSettings(userId) - result = vs.model_dump() if vs else {} - return JSONResponse(result) + try: + vs = dbMgmt.getVoiceSettings(userId) + if not vs: + logger.info(f"GET voice settings: not found for user={userId}, creating defaults") + vs = dbMgmt.getOrCreateVoiceSettings(userId) + result = vs.model_dump() if vs else {} + mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else [] + logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}") + return JSONResponse(result) + except Exception as e: + logger.error(f"Failed to load voice settings for user={userId}: {e}", exc_info=True) + return JSONResponse({"ttsVoiceMap": {}}, status_code=200) @router.put("/{instanceId}/settings/voice") @@ -755,20 +855,29 @@ async def updateVoiceSettings( dbMgmt = _getDbManagement(context, instanceId) userId = str(context.user.id) - vs = dbMgmt.getVoiceSettings(userId) - if not vs: - createData = { - "userId": userId, - "mandateId": str(context.mandateId) if context.mandateId else "", - "featureInstanceId": instanceId, - } - createData.update(body) - created = dbMgmt.createVoiceSettings(createData) - return JSONResponse(created) + try: + logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}") + vs = dbMgmt.getVoiceSettings(userId) + if not vs: + logger.info(f"No existing voice settings, creating new for user={userId}") + createData = { + "userId": userId, + "mandateId": str(context.mandateId) if context.mandateId else "", + "featureInstanceId": instanceId, + } + createData.update(body) + created = dbMgmt.createVoiceSettings(createData) + logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}") + return JSONResponse(created) - updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")} - updated = dbMgmt.updateVoiceSettings(userId, updateData) - return JSONResponse(updated) + updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")} + logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}") + updated = dbMgmt.updateVoiceSettings(userId, updateData) + logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}") + return JSONResponse(updated) + except Exception as e: + logger.error(f"Failed to update voice settings for user={userId}: {e}", exc_info=True) + return JSONResponse({"error": str(e)}, status_code=500) @router.get("/{instanceId}/voice/languages") diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 3f6d66c7..46182f60 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -219,6 +219,7 @@ async def upload_file( request: Request, file: UploadFile = File(...), workflowId: Optional[str] = Form(None), + featureInstanceId: Optional[str] = Form(None), currentUser: User = Depends(getCurrentUser) ) -> JSONResponse: # Add fileName property to UploadFile for consistency with backend model @@ -240,6 +241,10 @@ async def upload_file( # Save file via LucyDOM interface in the database fileItem, duplicateType = managementInterface.saveUploadedFile(fileContent, file.filename) + + if featureInstanceId and not fileItem.featureInstanceId: + managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId}) + fileItem.featureInstanceId = featureInstanceId # Determine response message based on duplicate type if duplicateType == "exact_duplicate": diff --git a/modules/serviceCenter/services/serviceAgent/conversationManager.py b/modules/serviceCenter/services/serviceAgent/conversationManager.py index bd885ece..a5a8d6ea 100644 --- a/modules/serviceCenter/services/serviceAgent/conversationManager.py +++ b/modules/serviceCenter/services/serviceAgent/conversationManager.py @@ -138,8 +138,23 @@ class ConversationManager: if len(nonSystemMessages) <= keepRecent + 1: return None - messagesToSummarize = nonSystemMessages[:-keepRecent] - recentMessages = nonSystemMessages[-keepRecent:] + splitIdx = len(nonSystemMessages) - keepRecent + # Ensure the split doesn't orphan tool messages from their assistant. + # Walk backwards from splitIdx: if we're landing in the middle of a + # tool-call sequence (assistant+tool_calls → tool → tool …), include + # the entire sequence in recentMessages. + while splitIdx > 0 and nonSystemMessages[splitIdx].get("role") == "tool": + splitIdx -= 1 + # Also include the assistant message that triggered the tool calls. + if splitIdx > 0 and splitIdx < len(nonSystemMessages) and \ + nonSystemMessages[splitIdx].get("role") == "assistant" and \ + nonSystemMessages[splitIdx].get("tool_calls"): + pass # splitIdx already points at the assistant; keep it in recent + elif splitIdx == 0: + return None # nothing to summarize + + messagesToSummarize = nonSystemMessages[:splitIdx] + recentMessages = nonSystemMessages[splitIdx:] summaryInput = _formatMessagesForSummary(messagesToSummarize) previousSummary = self._summaries[-1]["content"] if self._summaries else "" diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 59655442..2f137f76 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -478,10 +478,16 @@ def _registerCoreTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="webSearch", success=False, error="query is required") try: webService = services.getService("web") - result = await webService.search(query) + result = await webService.performWebResearch( + prompt=query, + urls=[], + country=None, + language=args.get("language"), + ) + summary = result.get("summary", "") if isinstance(result, dict) else str(result) return ToolResult( toolCallId="", toolName="webSearch", success=True, - data=result if isinstance(result, str) else str(result) + data=summary or str(result) ) except Exception as e: return ToolResult(toolCallId="", toolName="webSearch", success=False, error=str(e)) @@ -542,6 +548,9 @@ def _registerCoreTools(registry: ToolRegistry, services): fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile( content.encode("utf-8"), name ) + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) if args.get("folderId"): chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]}) if args.get("tags"): @@ -1455,6 +1464,9 @@ def _registerCoreTools(registry: ToolRegistry, services): if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) savedFiles.append(f"- {docName} (id: {fid})") sideEvents.append({ "type": "fileCreated",