ai workspace ui fixes
This commit is contained in:
parent
f51c313c2c
commit
b5dc643dac
5 changed files with 176 additions and 32 deletions
|
|
@ -1030,7 +1030,10 @@ class DatabaseConnector:
|
||||||
existingRecord.update(record)
|
existingRecord.update(record)
|
||||||
|
|
||||||
# Save updated 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
|
return existingRecord
|
||||||
|
|
||||||
def recordDelete(self, model_class: type, recordId: str) -> bool:
|
def recordDelete(self, model_class: type, recordId: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -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
|
# SSE Stream endpoint
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -121,12 +145,11 @@ async def streamWorkspaceStart(
|
||||||
if not workflow:
|
if not workflow:
|
||||||
raise HTTPException(status_code=404, detail=f"Workflow {userInput.workflowId} not found")
|
raise HTTPException(status_code=404, detail=f"Workflow {userInput.workflowId} not found")
|
||||||
else:
|
else:
|
||||||
existingWorkflows = chatInterface.getWorkflows() or []
|
autoName = _deriveWorkflowName(userInput.prompt)
|
||||||
nextNum = len(existingWorkflows) + 1
|
|
||||||
workflow = chatInterface.createWorkflow({
|
workflow = chatInterface.createWorkflow({
|
||||||
"featureInstanceId": instanceId,
|
"featureInstanceId": instanceId,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"name": f"Chat {nextNum}",
|
"name": autoName,
|
||||||
"workflowMode": "Dynamic",
|
"workflowMode": "Dynamic",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -219,8 +242,14 @@ async def _runWorkspaceAgent(
|
||||||
)
|
)
|
||||||
agentService = getService("agent", ctx)
|
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(
|
async for event in agentService.runAgent(
|
||||||
prompt=prompt,
|
prompt=enrichedPrompt,
|
||||||
fileIds=fileIds,
|
fileIds=fileIds,
|
||||||
workflowId=workflowId,
|
workflowId=workflowId,
|
||||||
userLanguage=userLanguage,
|
userLanguage=userLanguage,
|
||||||
|
|
@ -296,30 +325,35 @@ async def stopWorkspace(
|
||||||
async def listWorkspaceWorkflows(
|
async def listWorkspaceWorkflows(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(...),
|
instanceId: str = Path(...),
|
||||||
|
includeArchived: bool = Query(default=False, description="Include archived workflows"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""List all workspace workflows/conversations for this instance."""
|
"""List workspace workflows/conversations for this instance."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
||||||
workflows = chatInterface.getWorkflows() or []
|
workflows = chatInterface.getWorkflows() or []
|
||||||
items = []
|
items = []
|
||||||
for wf in workflows:
|
for wf in workflows:
|
||||||
if isinstance(wf, dict):
|
if isinstance(wf, dict):
|
||||||
items.append(wf)
|
item = wf
|
||||||
else:
|
else:
|
||||||
items.append({
|
item = {
|
||||||
"id": getattr(wf, "id", None),
|
"id": getattr(wf, "id", None),
|
||||||
"name": getattr(wf, "name", ""),
|
"name": getattr(wf, "name", ""),
|
||||||
"status": getattr(wf, "status", ""),
|
"status": getattr(wf, "status", ""),
|
||||||
"startedAt": getattr(wf, "startedAt", None),
|
"startedAt": getattr(wf, "startedAt", None),
|
||||||
"lastActivity": getattr(wf, "lastActivity", None),
|
"lastActivity": getattr(wf, "lastActivity", None),
|
||||||
})
|
}
|
||||||
|
if not includeArchived and item.get("status") == "archived":
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
return JSONResponse({"workflows": items})
|
return JSONResponse({"workflows": items})
|
||||||
|
|
||||||
|
|
||||||
class UpdateWorkflowRequest(BaseModel):
|
class UpdateWorkflowRequest(BaseModel):
|
||||||
"""Request body for updating a workflow (PATCH)."""
|
"""Request body for updating a workflow (PATCH)."""
|
||||||
name: Optional[str] = Field(default=None, description="New workflow name")
|
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}")
|
@router.patch("/{instanceId}/workflows/{workflowId}")
|
||||||
|
|
@ -340,6 +374,8 @@ async def patchWorkspaceWorkflow(
|
||||||
updateData = {}
|
updateData = {}
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
updateData["name"] = body.name
|
updateData["name"] = body.name
|
||||||
|
if body.status is not None:
|
||||||
|
updateData["status"] = body.status
|
||||||
if not updateData:
|
if not updateData:
|
||||||
updated = workflow
|
updated = workflow
|
||||||
else:
|
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")
|
@router.get("/{instanceId}/workflows/{workflowId}/messages")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getWorkspaceMessages(
|
async def getWorkspaceMessages(
|
||||||
|
|
@ -398,7 +475,23 @@ async def listWorkspaceFiles(
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
|
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
|
||||||
files = dbMgmt.getAllFiles()
|
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")
|
@router.get("/{instanceId}/files/{fileId}/content")
|
||||||
|
|
@ -735,11 +828,18 @@ async def getVoiceSettings(
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
dbMgmt = _getDbManagement(context, instanceId)
|
dbMgmt = _getDbManagement(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
try:
|
||||||
vs = dbMgmt.getVoiceSettings(userId)
|
vs = dbMgmt.getVoiceSettings(userId)
|
||||||
if not vs:
|
if not vs:
|
||||||
|
logger.info(f"GET voice settings: not found for user={userId}, creating defaults")
|
||||||
vs = dbMgmt.getOrCreateVoiceSettings(userId)
|
vs = dbMgmt.getOrCreateVoiceSettings(userId)
|
||||||
result = vs.model_dump() if vs else {}
|
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)
|
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")
|
@router.put("/{instanceId}/settings/voice")
|
||||||
|
|
@ -755,8 +855,11 @@ async def updateVoiceSettings(
|
||||||
dbMgmt = _getDbManagement(context, instanceId)
|
dbMgmt = _getDbManagement(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}")
|
||||||
vs = dbMgmt.getVoiceSettings(userId)
|
vs = dbMgmt.getVoiceSettings(userId)
|
||||||
if not vs:
|
if not vs:
|
||||||
|
logger.info(f"No existing voice settings, creating new for user={userId}")
|
||||||
createData = {
|
createData = {
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
"mandateId": str(context.mandateId) if context.mandateId else "",
|
"mandateId": str(context.mandateId) if context.mandateId else "",
|
||||||
|
|
@ -764,11 +867,17 @@ async def updateVoiceSettings(
|
||||||
}
|
}
|
||||||
createData.update(body)
|
createData.update(body)
|
||||||
created = dbMgmt.createVoiceSettings(createData)
|
created = dbMgmt.createVoiceSettings(createData)
|
||||||
|
logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}")
|
||||||
return JSONResponse(created)
|
return JSONResponse(created)
|
||||||
|
|
||||||
updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")}
|
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)
|
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)
|
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")
|
@router.get("/{instanceId}/voice/languages")
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ async def upload_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
workflowId: Optional[str] = Form(None),
|
workflowId: Optional[str] = Form(None),
|
||||||
|
featureInstanceId: Optional[str] = Form(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
# Add fileName property to UploadFile for consistency with backend model
|
# Add fileName property to UploadFile for consistency with backend model
|
||||||
|
|
@ -241,6 +242,10 @@ async def upload_file(
|
||||||
# Save file via LucyDOM interface in the database
|
# Save file via LucyDOM interface in the database
|
||||||
fileItem, duplicateType = managementInterface.saveUploadedFile(fileContent, file.filename)
|
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
|
# Determine response message based on duplicate type
|
||||||
if duplicateType == "exact_duplicate":
|
if duplicateType == "exact_duplicate":
|
||||||
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
|
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,23 @@ class ConversationManager:
|
||||||
if len(nonSystemMessages) <= keepRecent + 1:
|
if len(nonSystemMessages) <= keepRecent + 1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
messagesToSummarize = nonSystemMessages[:-keepRecent]
|
splitIdx = len(nonSystemMessages) - keepRecent
|
||||||
recentMessages = 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)
|
summaryInput = _formatMessagesForSummary(messagesToSummarize)
|
||||||
previousSummary = self._summaries[-1]["content"] if self._summaries else ""
|
previousSummary = self._summaries[-1]["content"] if self._summaries else ""
|
||||||
|
|
|
||||||
|
|
@ -478,10 +478,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
return ToolResult(toolCallId="", toolName="webSearch", success=False, error="query is required")
|
return ToolResult(toolCallId="", toolName="webSearch", success=False, error="query is required")
|
||||||
try:
|
try:
|
||||||
webService = services.getService("web")
|
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(
|
return ToolResult(
|
||||||
toolCallId="", toolName="webSearch", success=True,
|
toolCallId="", toolName="webSearch", success=True,
|
||||||
data=result if isinstance(result, str) else str(result)
|
data=summary or str(result)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult(toolCallId="", toolName="webSearch", success=False, error=str(e))
|
return ToolResult(toolCallId="", toolName="webSearch", success=False, error=str(e))
|
||||||
|
|
@ -542,6 +548,9 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(
|
||||||
content.encode("utf-8"), name
|
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"):
|
if args.get("folderId"):
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
||||||
if args.get("tags"):
|
if args.get("tags"):
|
||||||
|
|
@ -1455,6 +1464,9 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if fileItem:
|
if fileItem:
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
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})")
|
savedFiles.append(f"- {docName} (id: {fid})")
|
||||||
sideEvents.append({
|
sideEvents.append({
|
||||||
"type": "fileCreated",
|
"type": "fileCreated",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue