From 75dcf765e3d908c8f438239fe1e8d3d939b52f41 Mon Sep 17 00:00:00 2001 From: Ralph Soika Date: Mon, 7 Oct 2024 19:45:21 +0200 Subject: [PATCH] draft impl Issue #610 --- .../resources/bundle/messages_de.properties | 1 + .../resources/bundle/messages_en.properties | 1 + .../main/webapp/js/imixs-office.workitem.js | 15 + .../layout/css/office-theme-chronicle.css | 142 ++++---- .../webapp/pages/workitems/workitem_ai.xhtml | 30 ++ .../pages/workitems/workitem_chronicle.xhtml | 60 ++-- .../workflow/office/forms/AIController.java | 322 ++++++++++++++++++ src/site/markdown/forms/userinput.md | 11 +- 8 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_ai.xhtml create mode 100644 imixs-office-workflow-util/src/main/java/org/imixs/workflow/office/forms/AIController.java diff --git a/imixs-office-workflow-app/src/main/resources/bundle/messages_de.properties b/imixs-office-workflow-app/src/main/resources/bundle/messages_de.properties index 12228a53..7f5d0aab 100644 --- a/imixs-office-workflow-app/src/main/resources/bundle/messages_de.properties +++ b/imixs-office-workflow-app/src/main/resources/bundle/messages_de.properties @@ -148,6 +148,7 @@ daily=Täglich hourly=Stündlich type=Typ summary=Zusammenfassung +ai=KI #################### # Month diff --git a/imixs-office-workflow-app/src/main/resources/bundle/messages_en.properties b/imixs-office-workflow-app/src/main/resources/bundle/messages_en.properties index 0be7f3c3..a56aaa1a 100644 --- a/imixs-office-workflow-app/src/main/resources/bundle/messages_en.properties +++ b/imixs-office-workflow-app/src/main/resources/bundle/messages_en.properties @@ -148,6 +148,7 @@ daily=Daily hourly=Hourly type=Type summary=Summary +ai=AI #################### # Month diff --git a/imixs-office-workflow-app/src/main/webapp/js/imixs-office.workitem.js b/imixs-office-workflow-app/src/main/webapp/js/imixs-office.workitem.js index 432f7f37..cd158fbf 100644 --- a/imixs-office-workflow-app/src/main/webapp/js/imixs-office.workitem.js +++ b/imixs-office-workflow-app/src/main/webapp/js/imixs-office.workitem.js @@ -439,7 +439,9 @@ IMIXS.org.imixs.workflow.workitem = (function () { toggleChronicleHistory = function () { $('.chronicle-tab-history').parent().addClass('active'); $('.chronicle-tab-documents').parent().removeClass('active'); + $('.chronicle-tab-ai').parent().removeClass('active'); $('#imixs-workitem-chronicle-tab-documents').hide(); + $('#imixs-workitem-chronicle-tab-ai').hide(); $('#imixs-workitem-chronicle-tab-history').show(); // set a right margin for history view only $('.imixs-workitem-chronicle-content').css('width', 'calc(100% - 30px)'); @@ -448,11 +450,23 @@ IMIXS.org.imixs.workflow.workitem = (function () { toggleChronicleDocuments = function () { $('.chronicle-tab-documents').parent().addClass('active'); $('.chronicle-tab-history').parent().removeClass('active'); + $('.chronicle-tab-ai').parent().removeClass('active'); $('#imixs-workitem-chronicle-tab-history').hide(); + $('#imixs-workitem-chronicle-tab-ai').hide(); $('#imixs-workitem-chronicle-tab-documents').show(); // set a right margin for history view only $('.imixs-workitem-chronicle-content').css('width', 'calc(100% - 0px)'); }, + toggleChronicleAI = function () { + $('.chronicle-tab-ai').parent().addClass('active'); + $('.chronicle-tab-history').parent().removeClass('active'); + $('.chronicle-tab-documents').parent().removeClass('active'); + $('#imixs-workitem-chronicle-tab-history').hide(); + $('#imixs-workitem-chronicle-tab-documents').hide(); + $('#imixs-workitem-chronicle-tab-ai').show(); + // set a right margin for history view only + $('.imixs-workitem-chronicle-content').css('width', 'calc(100% - 0px)'); + }, registerSaveWorkitemListener = function (callback) { @@ -692,6 +706,7 @@ IMIXS.org.imixs.workflow.workitem = (function () { showDocument: showDocument, toggleChronicleHistory: toggleChronicleHistory, toggleChronicleDocuments: toggleChronicleDocuments, + toggleChronicleAI: toggleChronicleAI, minimizeDocumentPreview: minimizeDocumentPreview, maximizeDocumentPreview: maximizeDocumentPreview, closeDocumentPreview: closeDocumentPreview, diff --git a/imixs-office-workflow-app/src/main/webapp/layout/css/office-theme-chronicle.css b/imixs-office-workflow-app/src/main/webapp/layout/css/office-theme-chronicle.css index f8e1ec4f..a66c5514 100644 --- a/imixs-office-workflow-app/src/main/webapp/layout/css/office-theme-chronicle.css +++ b/imixs-office-workflow-app/src/main/webapp/layout/css/office-theme-chronicle.css @@ -2,83 +2,90 @@ ------ Workitem Form with Chronicle and Document Preview */ - + .imixs-workitem { - display: flex; + display: flex; flex-direction: row; min-height: 100vh; -} +} .imixs-workitem-form { flex-basis: 0; flex-grow: 1; min-width: 500px; } + .imixs-workitem-chronicle { display: flex; flex-wrap: nowrap; - flex-direction: column; + flex-direction: column; padding: 0px 20px 0px 10px; flex-basis: 340px; } -.imixs-workitem-form .imixs-form, .imixs-workitem-form .imixs-document { - width: 100%; - border-right: 1px solid #e0e4e7; +.imixs-workitem-form .imixs-form, +.imixs-workitem-form .imixs-document { + width: 100%; + border-right: 1px solid #e0e4e7; } .split { - width: 50% !important; - float: left; + width: 50% !important; + float: left; } .imixs-workitem-form .imixs-form { - padding-right: 20px; + padding-right: 20px; } .imixs-workitem-form .imixs-document { - padding-right: 20px; - padding-left: 20px; + padding-right: 20px; + padding-left: 20px; } .imixs-workitem-form .imixs-document h1 { - font-size: 1.8em; - margin-bottom: 0.3em; + font-size: 1.8em; + margin-bottom: 0.3em; } .imixs-workitem-chronicle-small { font-size: 0.7rem; } + .imixs-workitem-chronicle-actions { -flex-basis: 100px; + flex-basis: 100px; } -.imixs-workitem-chronicle-tabs { +.imixs-workitem-chronicle-tabs { margin-bottom: 10px; flex-basis: 40px; } -.imixs-workitem-chronicle-tabs ul { - padding:0; + +.imixs-workitem-chronicle-tabs ul { + padding: 0; } + .imixs-workitem-chronicle-tabs ul li { list-style: none; display: inline; border-bottom: 2px none; padding-bottom: 10px; } + .imixs-workitem-chronicle-tabs ul li:hover { border-bottom: 2px solid #206B87; } + .imixs-workitem-chronicle-tabs ul li.active { border-bottom: 2px solid #206B87; } .imixs-workitem-chronicle-tabs ul li a { color: #999; - text-decoration: none; + text-decoration: none; } .imixs-workitem-chronicle-tabs ul li.active a { @@ -86,14 +93,16 @@ flex-basis: 100px; } -.imixs-workitem-chronicle-tabs .chronicle-tab-history, .imixs-workitem-chronicle-tabs .chronicle-tab-documents { +.imixs-workitem-chronicle-tabs .chronicle-tab-history, +.imixs-workitem-chronicle-tabs .chronicle-tab-documents, +.imixs-workitem-chronicle-tabs .chronicle-tab-ai { margin: 10px 10px 10px 0px; font-size: 1.3rem; } .imixs-workitem-chronicle-content { overflow: auto; - margin-bottom:20px; + margin-bottom: 20px; flex-basis: 200px; flex-grow: 1; padding-right: 10px; @@ -101,27 +110,27 @@ flex-basis: 100px; .imixs-slider { - flex-basis: 16px; - padding: 0; - cursor: col-resize; + flex-basis: 16px; + padding: 0; + cursor: col-resize; } .imixs-slider-nav { - margin: 0; - padding: 6px 0; - position: relative; - top: 250px; - border-right: 1px dotted #e0e4e7; - border-top: 1px dotted #e0e4e7; - border-bottom: 1px dotted #e0e4e7; - background-color: #f7f7f7; + margin: 0; + padding: 6px 0; + position: relative; + top: 250px; + border-right: 1px dotted #e0e4e7; + border-top: 1px dotted #e0e4e7; + border-bottom: 1px dotted #e0e4e7; + background-color: #f7f7f7; } .imixs-slider .typcn { - color:#ababab; - font-size:16px; + color: #ababab; + font-size: 16px; line-height: 1.7em; cursor: default; } @@ -137,8 +146,8 @@ flex-basis: 100px; .imixs-document { - display:none; - padding:0 10px; + display: none; + padding: 0 10px; } .imixs-document .document-title { @@ -146,24 +155,26 @@ flex-basis: 100px; } .imixs-document .document-nav { - + cursor: pointer; float: right; margin-left: 20px; color: #428bca; - + } -.imixs-workitem-document-embedded .document-nav { +.imixs-workitem-document-embedded .document-nav { cursor: pointer; float: right; margin-left: 0px; color: #428bca; } + .imixs-workitem-document-embedded .imixs-form { - margin:0; + margin: 0; } + .imixs-workitem-document-embedded .document-title { display: none; } @@ -195,16 +206,17 @@ flex-basis: 100px; } -.imixs-workitem-chronicle .content-block a.attachmentlink, .imixs-workitem-chronicle .content-block a.file-open-link { +.imixs-workitem-chronicle .content-block a.attachmentlink, +.imixs-workitem-chronicle .content-block a.file-open-link { word-break: break-all; } .imixs-workitem-chronicle .date { - margin-left:10px; - font-size: 1.1em; - line-height:1.4em; - font-weight:bold; + margin-left: 10px; + font-size: 1.1em; + line-height: 1.4em; + font-weight: bold; } .imixs-workitem-chronicle .imixs-comments-entry { @@ -226,8 +238,8 @@ flex-basis: 100px; .imixs-workitem-chronicle .filter { position: fixed; - right:0; - padding-right:20px; + right: 0; + padding-right: 20px; } @@ -248,8 +260,9 @@ flex-basis: 100px; color: #206B87; } -.imixs-workitem-chronicle .filter a, .imixs-workitem-chronicle .nav a { - color: #206B87; +.imixs-workitem-chronicle .filter a, +.imixs-workitem-chronicle .nav a { + color: #206B87; } @@ -260,7 +273,7 @@ flex-basis: 100px; } .imixs-workitem-chronicle .filter a.inactive { - color:#999; + color: #999; } @@ -285,11 +298,11 @@ flex-basis: 100px; } .imixs-workitem-chronicle table tr.year td h2 { - margin:0; + margin: 0; } .imixs-workitem-chronicle table tr.month td { - padding-top:10px; + padding-top: 10px; padding-bottom: 0px; } @@ -300,9 +313,11 @@ flex-basis: 100px; color: #428bca; line-height: 1.3rem; } + .imixs-workitem-chronicle .dms-list .image-block { min-width: 30px; } + .imixs-workitem-chronicle .dms-list .content-block { margin-left: 10px; line-height: 1.8em; @@ -318,25 +333,28 @@ flex-basis: 100px; @media screen and (max-width: 1200px) { .imixs-slider { display: none; - } + } + .imixs-workitem { - display: flow-root; - } - .imixs-workitem-chronicle, .imixs-workitem-form { + display: flow-root; + } + + .imixs-workitem-chronicle, + .imixs-workitem-form { width: 100% !important; left: 0; position: inherit; - } + } + .split { - width: 100% !important; + width: 100% !important; } } @media screen and (max-width: 548px) { - + .imixs-workitem-chronicle { - padding-top:0; + padding-top: 0; } - -} +} \ No newline at end of file diff --git a/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_ai.xhtml b/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_ai.xhtml new file mode 100644 index 00000000..ed854b97 --- /dev/null +++ b/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_ai.xhtml @@ -0,0 +1,30 @@ + + + +
+
+ +
+
+ + + + + +
+
+ + + Chat History.... +
+ + #{answer} +
+
+
+ +
\ No newline at end of file diff --git a/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_chronicle.xhtml b/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_chronicle.xhtml index 1ea2f102..9ed6274e 100644 --- a/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_chronicle.xhtml +++ b/imixs-office-workflow-app/src/main/webapp/pages/workitems/workitem_chronicle.xhtml @@ -1,36 +1,43 @@ - +
-
- -
- -
-
- -
-
+
+ +
+ +
+
+ +
+
- + @@ -38,10 +45,15 @@
-
+
+ + +
- -
+
\ No newline at end of file diff --git a/imixs-office-workflow-util/src/main/java/org/imixs/workflow/office/forms/AIController.java b/imixs-office-workflow-util/src/main/java/org/imixs/workflow/office/forms/AIController.java new file mode 100644 index 00000000..def2ee6d --- /dev/null +++ b/imixs-office-workflow-util/src/main/java/org/imixs/workflow/office/forms/AIController.java @@ -0,0 +1,322 @@ +/******************************************************************************* + * Imixs Workflow Technology + * Copyright (C) 2003, 2008 Imixs Software Solutions GmbH, + * http://www.imixs.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You can receive a copy of the GNU General Public + * License at http://www.gnu.org/licenses/gpl.html + * + * Contributors: + * Imixs Software Solutions GmbH - initial API and implementation + * Ralph Soika + * + *******************************************************************************/ +package org.imixs.workflow.office.forms; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Serializable; +import java.io.StringReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.imixs.workflow.ItemCollection; +import org.imixs.workflow.exceptions.PluginException; +import org.imixs.workflow.faces.data.WorkflowController; +import org.imixs.workflow.faces.data.WorkflowEvent; + +import jakarta.enterprise.context.ConversationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonReader; +import jakarta.json.JsonValue; + +/** + * The AIController integrates the imixs-ai module + * + * + * @see workitem_chronicle.xhtml + * @author rsoika,gheinle + */ +@Named("aiController") +@ConversationScoped +public class AIController implements Serializable { + public static final String ERROR_PROMPT_TEMPLATE = "ERROR_PROMPT_TEMPLATE"; + public static final String ERROR_PROMPT_INFERENCE = "ERROR_PROMPT_INFERENCE"; + + public static final String LLM_SERVICE_ENDPOINT = "llm.service.endpoint"; + public static final String ENV_LLM_SERVICE_ENDPOINT_USER = "llm.service.endpoint.user"; + public static final String ENV_LLM_SERVICE_ENDPOINT_PASSWORD = "llm.service.endpoint.password"; + public static final String ENV_LLM_SERVICE_ENDPOINT_TIMEOUT = "LLM_SERVICE_TIMEOUT"; + + private static final long serialVersionUID = 1L; + private static Logger logger = Logger.getLogger(AIController.class.getName()); + + List chatHistory; + + @Inject + protected WorkflowController workflowController; + + @Inject + @ConfigProperty(name = LLM_SERVICE_ENDPOINT, defaultValue = "a") + String serviceEndpoint; + + @Inject + @ConfigProperty(name = ENV_LLM_SERVICE_ENDPOINT_USER, defaultValue = "b") + String serviceEndpointUser; + + @Inject + @ConfigProperty(name = ENV_LLM_SERVICE_ENDPOINT_PASSWORD, defaultValue = "c") + String serviceEndpointPassword; + + @Inject + @ConfigProperty(name = ENV_LLM_SERVICE_ENDPOINT_TIMEOUT, defaultValue = "120000") + int serviceTimeout; + + /** + * This helper method is called during the WorkflowEvent.WORKITEM_CHANGED to + * update the chronicle view for the current workitem. + */ + @SuppressWarnings("unchecked") + public void init() { + long l = System.currentTimeMillis(); + chatHistory = new ArrayList(); + + } + + public List getChatHistory() { + return chatHistory; + } + + /** + * WorkflowEvent listener + * + * If a new WorkItem was created or changed, the chronicle view will be + * initialized. + * + * @param workflowEvent + */ + public void onWorkflowEvent(@Observes WorkflowEvent workflowEvent) { + if (workflowEvent == null) { + return; + } + if (WorkflowEvent.WORKITEM_CREATED == workflowEvent.getEventType() + || WorkflowEvent.WORKITEM_CHANGED == workflowEvent.getEventType()) { + // reset data... + init(); + } + } + + /** + * Sends a new prompt item + * + * + * '{"prompt": "Building a website","n_predict": 128}' + */ + public void send() throws PluginException { + + String input = workflowController.getWorkitem().getItemValueString("ai.chat.prompt"); + logger.info("prompt...:" + input); + + JsonObject jsonPrompt = buildJsonPromptObject(input, null); + + String result = postPromptCompletion(serviceEndpoint, jsonPrompt); + logger.info("result=" + result); + + processPromptResult(result, workflowController.getWorkitem()); + + } + + /** + * This method POST a given prompt to the endpoint '/completion' and returns the + * predicted completion. + * The method returns the response body. + * + * The method optional test if the environment variables + * LLM_SERVICE_ENDPOINT_USER and LLM_SERVICE_ENDPOINT_PASSWORD are set. In this + * case a BASIC Authentication is used for the connection to the LLMService. + * + * See details: + * https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md#api-endpoints + * + * + * curl example: + * + * curl --request POST \ + * --url http://localhost:8080/completion \ + * --header "Content-Type: application/json" \ + * --data '{"prompt": "Building a website can be done in 10 simple + * steps:","n_predict": 128}' + * + * @param xmlPromptData + * @throws PluginException + */ + public String postPromptCompletion(String apiEndpoint, JsonObject jsonPromptObject) + throws PluginException { + String response = null; + try { + if (!apiEndpoint.endsWith("/")) { + apiEndpoint = apiEndpoint + "/"; + } + URL url = new URL(apiEndpoint + "completion"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + conn.setConnectTimeout(serviceTimeout); // set timeout to 5 seconds + conn.setReadTimeout(serviceTimeout); + // Set Basic Authentication? + if (serviceEndpointUser != null && !serviceEndpointUser.isEmpty() + && !serviceEndpointPassword.isEmpty()) { + String auth = serviceEndpointUser + ":" + serviceEndpointPassword; + byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes(StandardCharsets.UTF_8)); + String authHeaderValue = "Basic " + new String(encodedAuth); + conn.setRequestProperty("Authorization", authHeaderValue); + } + + // Set the appropriate HTTP method + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setDoOutput(true); + + // Write the JSON object to the output stream + String jsonString = jsonPromptObject.toString(); + logger.fine("JSON Object=" + jsonString); + + try (OutputStream os = conn.getOutputStream()) { + byte[] input = jsonString.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + // Reading the response + int responseCode = conn.getResponseCode(); + logger.fine("POST Response Code :: " + responseCode); + if (responseCode == HttpURLConnection.HTTP_OK) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder responseBody = new StringBuilder(); + String responseLine = null; + while ((responseLine = br.readLine()) != null) { + responseBody.append(responseLine.trim() + "\n"); + } + response = responseBody.toString(); + logger.fine("Response Body :: " + response); + } + } else { + throw new PluginException(AIController.class.getSimpleName(), + ERROR_PROMPT_INFERENCE, "Error during POST prompt: HTTP Result " + responseCode); + } + // Close the connection + conn.disconnect(); + logger.fine("===== postPromptCompletion completed"); + return response; + + } catch (IOException e) { + logger.severe(e.getMessage()); + throw new PluginException( + AIController.class.getSimpleName(), + ERROR_PROMPT_TEMPLATE, + "Exception during POST prompt - " + e.getClass().getName() + ": " + e.getMessage(), e); + } + + } + + /** + * This helper method builds a json prompt object including options params. + * + * See details: + * https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md#api-endpoints + * + * @param prompt + * @param prompt_options + * @return + */ + public JsonObject buildJsonPromptObject(String prompt, String prompt_options) { + + // Create a JsonObjectBuilder instance + JsonObjectBuilder jsonObjectBuilder = Json.createObjectBuilder(); + jsonObjectBuilder.add("prompt", prompt); + + // Do we have options? + if (prompt_options != null && !prompt_options.isEmpty()) { + // Create a JsonReader from the JSON string + JsonReader jsonReader = Json.createReader(new StringReader(prompt_options)); + JsonObject parsedJsonObject = jsonReader.readObject(); + jsonReader.close(); + // Add each key-value pair from the parsed JsonObject to the new + // JsonObjectBuilder + for (Map.Entry entry : parsedJsonObject.entrySet()) { + jsonObjectBuilder.add(entry.getKey(), entry.getValue()); + } + } + + // Build the JsonObject + JsonObject jsonObject = jsonObjectBuilder.build(); + + logger.fine("buildJsonPromptObject completed:"); + logger.fine(jsonObject.toString()); + return jsonObject; + } + + /** + * This method processes a OpenAI API prompt result in JSON format. The method + * expects a workitem* including the item 'ai.result' providing the LLM result + * string. + * + * The parameter 'resultItemName' defines the item to store the result string. + * This param can be empty. + * + * The parameter 'mode' defines a resolver method. + * + * @param workitem - the workitem holding the last AI result (stored in a + * value + * list) + * @param resultItemName - the item name to store the llm text result + * @param resultEventType - optional event type send to all CDI Event observers + * for the LLMResultEvent + * @throws PluginException + */ + public void processPromptResult(String completionResult, ItemCollection workitem) throws PluginException { + + // We expect a OpenAI API Json Result object + // Extract the field 'content' + // Create a JsonReader from the JSON string + JsonReader jsonReader = Json.createReader(new StringReader(completionResult)); + JsonObject parsedJsonObject = jsonReader.readObject(); + jsonReader.close(); + + // extract content + String promptResult = parsedJsonObject.getString("content"); + if (promptResult == null) { + throw new PluginException(AIController.class.getSimpleName(), + ERROR_PROMPT_INFERENCE, "Error during POST prompt - no result returned!"); + } + promptResult = promptResult.trim(); + + logger.info("Result=" + promptResult); + chatHistory.add(promptResult); + + } +} diff --git a/src/site/markdown/forms/userinput.md b/src/site/markdown/forms/userinput.md index ad967dfa..60372f2a 100644 --- a/src/site/markdown/forms/userinput.md +++ b/src/site/markdown/forms/userinput.md @@ -2,7 +2,7 @@ Imixs-Office-Workflow provides a set of custom input parts to enter usernames or select usernames from a organization unit. -# The User Input +## The User Input The item part `userinput` can be used to edit a single user name. The part provides a lookup feature for profile names @@ -25,11 +25,14 @@ Optional the User-List-Input allows to enter a list of user names. The part prov ``` -### User Input by Space +## User Input by Space - The custom part `userinputbyspace` can be used to display a Combobox with usernames from a space. The space and the member list can be defined by the 'options' + The custom part `userinputbyspace` can be used to display a Combobox with usernames from a space. The space and the member list can be defined by the 'options'. + + [SPACE_NAME];[MEMBER_TYPE] + + For example you can define to display only Managers from the Space 'Auditoren': - Example: ```xml