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",