ai workspace ui fixes

This commit is contained in:
ValueOn AG 2026-03-16 11:38:18 +01:00
parent f51c313c2c
commit b5dc643dac
5 changed files with 176 additions and 32 deletions

View file

@ -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:

View file

@ -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")

View file

@ -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":

View file

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

View file

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