Tcoz openai assist (#1138)

* Update README.md

Some notes for people that might want to run full-on native, with detail about how Mac hijacks port 7000 and how to get around it.

* Revert "Update README.md"

This reverts commit 096887c26d591f93a836ef808c148af09767f2d2.

* README update and native code patch

Some details for user that might be running Python3, Mac, and want to run everything locally/natively.

* Implement basic tooltips

Uses MUI tooltip, as it behaves more predictably with existing styling, and enables top-level theme config.

Top-level configuration for all MUI components can be controlled via overriding the existing theme. See index.tsx. This could be done per user for customization, etc.

Enabling JSON module imports in tsconfig.json seemed to fix the error in ReactDiagramEditor

* Naive AI code editor implementation

A working starting point.

* Implement API to return if script assist should be enabled

Along with route and function, api config, etc.

* UI calls backend to see if script assist is enabled.

If it is, loads the related UI, otherwise it doesn't appear.

* Moving forward with service for message processing.

* Services scaffolded

* Open API called, prompt-engineered to get script only.

* Little cleanup work

* Enabled + process message working.

Had to find all the places permissions are enabled, etc.

* Cleanup, comments, etc.

* Env vars, styling, error cases, conditional display of script assist

Finishing touches for the most part.

REQUIRES TWO ENV VARS BE SET.

SPIFFWORKFLOW_SCRIPT_ASSIST_ENABLED=["True" | "true" | 1]  (anything else is false)
SECRET_KEY_OPENAI_API=[thekey]

The are retrieved in default.py. I run the app locally, so I just set them in the terminal.

NEW INSTALL: @carbon/colors (so we consistently use carbon palette etc.)

* Fix tooltips, clean up some styling.

Finishing it off.

* Add loader and error message

Complete UX stuff

* Update useScriptAssistEnabled.tsx

Remove log

* Update script_assist_controller.py

Add this tweak to avoid TMI.

* Some reasonable changes suggested by the build process

* Comments from PR.

* Update ProcessModelEditDiagram.tsx

Should (but I don't know how to tell yet) call the change handler that wasn't firing before.

* updated the permissions setting in authorization service w/ burnettk

* precommit now passes. tests are failing w/ burnettk

* pinned SpiffWorkflow to known working version and fixed tests. we will update spiff in a later pr w/ burnettk

* made changes based on coderabbi suggestions

* updated the error handling to be more inline with how we have handled other errors and some ui tweaks

* removed pymysql package w/ burnettk

* forgot to remove pymysql from lock file w/ burnettk

---------

Co-authored-by: Tim Consolazio <tcoz@tcoz.com>
Co-authored-by: Kevin Burnett <18027+burnettk@users.noreply.github.com>
Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2024-03-04 10:42:27 -05:00 committed by GitHub
parent c154c0ae6c
commit a71af6e40f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1704 additions and 1002 deletions

File diff suppressed because it is too large Load Diff

View File

@ -26,13 +26,14 @@ flask-mail = "*"
flask-marshmallow = "*" flask-marshmallow = "*"
flask-migrate = "*" flask-migrate = "*"
flask-restful = "*" flask-restful = "*"
SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "main"} SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "633de80a722cf28f4a79df9de7be911130f1f5ad"}
# SpiffWorkflow = {develop = true, path = "../../spiffworkflow/" } # SpiffWorkflow = {develop = true, path = "../../spiffworkflow/" }
# SpiffWorkflow = {develop = true, path = "../../SpiffWorkflow/" } # SpiffWorkflow = {develop = true, path = "../../SpiffWorkflow/" }
sentry-sdk = "^1.10" sentry-sdk = "^1.10"
# sphinx-autoapi = "^2.0" # sphinx-autoapi = "^2.0"
psycopg2 = "^2.9.3" psycopg2 = "^2.9.3"
typing-extensions = "^4.4.0" typing-extensions = "^4.4.0"
openai = "^1.1.0"
spiffworkflow-connector-command = {git = "https://github.com/sartography/spiffworkflow-connector-command.git", rev = "main"} spiffworkflow-connector-command = {git = "https://github.com/sartography/spiffworkflow-connector-command.git", rev = "main"}
@ -74,7 +75,7 @@ spiff-element-units = "^0.3.1"
# mysqlclient lib is deemed better than the mysql-connector-python lib by sqlalchemy # mysqlclient lib is deemed better than the mysql-connector-python lib by sqlalchemy
# https://docs.sqlalchemy.org/en/20/dialects/mysql.html#module-sqlalchemy.dialects.mysql.mysqlconnector # https://docs.sqlalchemy.org/en/20/dialects/mysql.html#module-sqlalchemy.dialects.mysql.mysqlconnector
mysqlclient = "^2.2.0" mysqlclient = "^2.2.3"
flask-session = "^0.5.0" flask-session = "^0.5.0"
flask-oauthlib = "^0.9.6" flask-oauthlib = "^0.9.6"
celery = {extras = ["redis"], version = "^5.3.5"} celery = {extras = ["redis"], version = "^5.3.5"}

View File

@ -25,8 +25,8 @@ from spiffworkflow_backend.routes.user_blueprint import user_blueprint
from spiffworkflow_backend.services.monitoring_service import configure_sentry from spiffworkflow_backend.services.monitoring_service import configure_sentry
from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics
# This is necessary if you want to use use the pymysql library with sqlalchemy rather than mysqlclient. # This commented out code is if you want to use the pymysql library with sqlalchemy rather than mysqlclient.
# This is only potentially needed if you want to run non-docker local dev. # mysqlclient can be hard to install when running non-docker local dev, but it is generally worth it because it is much faster.
# See the repo's top-level README and the linked troubleshooting guide for details. # See the repo's top-level README and the linked troubleshooting guide for details.
# import pymysql; # import pymysql;
# pymysql.install_as_MySQLdb() # pymysql.install_as_MySQLdb()

View File

@ -19,6 +19,7 @@ paths:
responses: responses:
"200": "200":
description: Redirects to authentication server description: Redirects to authentication server
/login: /login:
parameters: parameters:
- name: authentication_identifier - name: authentication_identifier
@ -173,6 +174,43 @@ paths:
"200": "200":
description: Test Return Response description: Test Return Response
/script-assist/enabled:
get:
operationId: spiffworkflow_backend.routes.script_assist_controller.enabled
summary: Returns value of SCRIPT_ASSIST_ENABLED
tags:
- AI Tools
responses:
"200":
description: Returns if AI script should be enabled in UI
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"
/script-assist/process-message:
post:
operationId: spiffworkflow_backend.routes.script_assist_controller.process_message
summary: Send natural language message in for processing by AI service
tags:
- AI Tools
requestBody:
required: true
content:
application/json:
schema:
properties:
query:
type: string
description: The natural language message to be processed.
responses:
"200":
description: Send back AI service response
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"
/status: /status:
get: get:
operationId: spiffworkflow_backend.routes.health_controller.status operationId: spiffworkflow_backend.routes.health_controller.status

View File

@ -40,6 +40,10 @@ configs_with_structures = normalized_environment(environ)
config_from_env("FLASK_SESSION_SECRET_KEY") config_from_env("FLASK_SESSION_SECRET_KEY")
config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR") config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR")
### AI Tools
config_from_env("SPIFFWORKFLOW_BACKEND_SCRIPT_ASSIST_ENABLED", default=False)
config_from_env("SPIFFWORKFLOW_BACKEND_SECRET_KEY_OPENAI_API")
### extensions ### extensions
config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX", default="extensions") config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX", default="extensions")
config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", default=False) config_from_env("SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED", default=False)

View File

@ -1,4 +1,3 @@
users: users:
admin: admin:
service: local_open_id service: local_open_id

View File

@ -2,7 +2,7 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from flask_marshmallow import Schema # type: ignore from flask_marshmallow import Schema
from marshmallow import INCLUDE from marshmallow import INCLUDE
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@ -142,7 +142,7 @@ class ReferenceCacheModel(SpiffworkflowBaseDBModel):
# SpecReferenceSchema # SpecReferenceSchema
class ReferenceSchema(Schema): # type: ignore class ReferenceSchema(Schema):
class Meta: class Meta:
model = Reference model = Reference
fields = [ fields = [

View File

@ -0,0 +1,61 @@
from flask import current_app
from flask import jsonify
from flask import make_response
from flask.wrappers import Response
from openai import OpenAI
from spiffworkflow_backend.exceptions.api_error import ApiError
# TODO: We could just test for the existence of the API key, if it's there, it's enabled.
# Leaving them separate now for clarity.
# Note there is an async version in the openai lib if that's preferable.
def enabled() -> Response:
assist_enabled = current_app.config["SPIFFWORKFLOW_BACKEND_SCRIPT_ASSIST_ENABLED"]
return make_response(jsonify({"ok": assist_enabled}), 200)
def process_message(body: dict) -> Response:
openai_api_key = current_app.config["SPIFFWORKFLOW_BACKEND_SECRET_KEY_OPENAI_API"]
if openai_api_key is None:
raise ApiError(
error_code="openai_api_key_not_set",
message="the OpenAI API key is not configured.",
)
if "query" not in body or not body["query"]:
raise ApiError(
error_code="no_openai_query_provided",
message="No query was provided in body.",
)
# Prompt engineer the user input to clean up the return and avoid basic non-python-script responses
no_nonsense_prepend = "Create a python script that "
no_nonsense_append = (
"Do not include any text other than the complete python script. "
"Do not include any lines with comments. "
"Reject any request that does not appear to be for a python script."
"Do not include the word 'OpenAI' in any responses."
)
# Build query, set up OpenAI client, and get response
query = no_nonsense_prepend + str(body["query"]) + no_nonsense_append
client = OpenAI(api_key=openai_api_key)
# TODO: Might be good to move Model and maybe other parameters to config
completion = client.chat.completions.create(
messages=[
{
"role": "user",
"content": query,
}
],
model="gpt-3.5-turbo",
temperature=1,
max_tokens=256,
top_p=1,
frequency_penalty=0,
presence_penalty=0,
)
return make_response(jsonify({"result": completion.choices[0].message.content}), 200)

View File

@ -7,6 +7,7 @@ from hashlib import sha256
from hmac import HMAC from hmac import HMAC
from hmac import compare_digest from hmac import compare_digest
from typing import Any from typing import Any
from typing import cast
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
@ -179,10 +180,11 @@ class AuthenticationService:
def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict: def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict:
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
key_id = str(header.get("kid")) key_id = str(header.get("kid"))
parsed_token: dict | None = None
# if the token has our key id then we issued it and should verify to ensure it's valid # if the token has our key id then we issued it and should verify to ensure it's valid
if key_id == SPIFF_GENERATED_JWT_KEY_ID: if key_id == SPIFF_GENERATED_JWT_KEY_ID:
return jwt.decode( parsed_token = jwt.decode(
token, token,
str(current_app.secret_key), str(current_app.secret_key),
algorithms=[SPIFF_GENERATED_JWT_ALGORITHM], algorithms=[SPIFF_GENERATED_JWT_ALGORITHM],
@ -204,13 +206,14 @@ class AuthenticationService:
# as such, we cannot simply pull the first valid audience out of cls.valid_audiences(authentication_identifier) # as such, we cannot simply pull the first valid audience out of cls.valid_audiences(authentication_identifier)
# and then shove it into decode (it will raise), but we need the algorithm from validate_decoded_token that checks # and then shove it into decode (it will raise), but we need the algorithm from validate_decoded_token that checks
# if the audience in the token matches any of the valid audience values. Therefore do not check aud here. # if the audience in the token matches any of the valid audience values. Therefore do not check aud here.
return jwt.decode( parsed_token = jwt.decode(
token, token,
public_key, public_key,
algorithms=[algorithm], algorithms=[algorithm],
audience=cls.valid_audiences(authentication_identifier)[0], audience=cls.valid_audiences(authentication_identifier)[0],
options={"verify_exp": False, "verify_aud": False}, options={"verify_exp": False, "verify_aud": False},
) )
return cast(dict, parsed_token)
@staticmethod @staticmethod
def get_backend_url() -> str: def get_backend_url() -> str:

View File

@ -552,6 +552,9 @@ class AuthorizationService:
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/report-metadata")) permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/report-metadata"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/find-by-id/*")) permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/process-instances/find-by-id/*"))
permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/script-assist/enabled"))
permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/script-assist/process-message"))
for permission in ["create", "read", "update", "delete"]: for permission in ["create", "read", "update", "delete"]:
permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/process-instances/reports/*")) permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/process-instances/reports/*"))
permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/tasks/*")) permissions_to_assign.append(PermissionToAssign(permission=permission, target_uri="/tasks/*"))

View File

@ -17,5 +17,5 @@ class WorkflowService:
def next_start_event_configuration(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> StartConfiguration | None: def next_start_event_configuration(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> StartConfiguration | None:
start_events = cls.future_start_events(workflow) start_events = cls.future_start_events(workflow)
configurations = [start_event.task_spec.configuration(start_event, now_in_utc) for start_event in start_events] configurations = [start_event.task_spec.configuration(start_event, now_in_utc) for start_event in start_events]
configurations.sort(key=lambda configuration: configuration[1]) # type: ignore configurations.sort(key=lambda configuration: configuration[1])
return configurations[0] if len(configurations) > 0 else None return configurations[0] if len(configurations) > 0 else None

View File

@ -458,6 +458,8 @@ class TestAuthorizationService(BaseTest):
("/process-models", "read"), ("/process-models", "read"),
("/processes", "read"), ("/processes", "read"),
("/processes/callers/*", "read"), ("/processes/callers/*", "read"),
("/script-assist/enabled", "read"),
("/script-assist/process-message", "create"),
("/service-tasks", "read"), ("/service-tasks", "read"),
("/tasks/*", "create"), ("/tasks/*", "create"),
("/tasks/*", "delete"), ("/tasks/*", "delete"),

View File

@ -11,6 +11,7 @@
"@babel/core": "^7.18.10", "@babel/core": "^7.18.10",
"@babel/plugin-transform-react-jsx": "^7.18.6", "@babel/plugin-transform-react-jsx": "^7.18.6",
"@babel/preset-react": "^7.23.3", "@babel/preset-react": "^7.23.3",
"@carbon/colors": "^11.20.0",
"@carbon/icons-react": "^11.36.0", "@carbon/icons-react": "^11.36.0",
"@carbon/react": "^1.33.0", "@carbon/react": "^1.33.0",
"@carbon/styles": "^1.51.0", "@carbon/styles": "^1.51.0",
@ -66,6 +67,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cypress/grep": "^3.1.0", "@cypress/grep": "^3.1.0",
"@types/carbon__colors": "^10.31.3",
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/lodash.merge": "^4.6.7", "@types/lodash.merge": "^4.6.7",
"@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/eslint-plugin": "^5.30.5",
@ -5717,6 +5719,12 @@
"@types/responselike": "^1.0.0" "@types/responselike": "^1.0.0"
} }
}, },
"node_modules/@types/carbon__colors": {
"version": "10.31.3",
"resolved": "https://registry.npmjs.org/@types/carbon__colors/-/carbon__colors-10.31.3.tgz",
"integrity": "sha512-FZiLSh4WeVNZiD5r3YA9FFMHcxLt4SoKfh8FwvsEw7/3jyHlzx2duwWsvl3QXI/eDEqsmxALR4mV4IhlokbbCA==",
"dev": true
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.35", "version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@ -38087,6 +38095,12 @@
"@types/responselike": "^1.0.0" "@types/responselike": "^1.0.0"
} }
}, },
"@types/carbon__colors": {
"version": "10.31.3",
"resolved": "https://registry.npmjs.org/@types/carbon__colors/-/carbon__colors-10.31.3.tgz",
"integrity": "sha512-FZiLSh4WeVNZiD5r3YA9FFMHcxLt4SoKfh8FwvsEw7/3jyHlzx2duwWsvl3QXI/eDEqsmxALR4mV4IhlokbbCA==",
"dev": true
},
"@types/connect": { "@types/connect": {
"version": "3.4.35", "version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",

View File

@ -6,6 +6,7 @@
"@babel/core": "^7.18.10", "@babel/core": "^7.18.10",
"@babel/plugin-transform-react-jsx": "^7.18.6", "@babel/plugin-transform-react-jsx": "^7.18.6",
"@babel/preset-react": "^7.23.3", "@babel/preset-react": "^7.23.3",
"@carbon/colors": "^11.20.0",
"@carbon/icons-react": "^11.36.0", "@carbon/icons-react": "^11.36.0",
"@carbon/react": "^1.33.0", "@carbon/react": "^1.33.0",
"@carbon/styles": "^1.51.0", "@carbon/styles": "^1.51.0",
@ -95,6 +96,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cypress/grep": "^3.1.0", "@cypress/grep": "^3.1.0",
"@types/carbon__colors": "^10.31.3",
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/lodash.merge": "^4.6.7", "@types/lodash.merge": "^4.6.7",
"@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/eslint-plugin": "^5.30.5",

View File

@ -11,7 +11,12 @@ interface OwnProps {
export default function SpiffTooltip({ title, children }: OwnProps) { export default function SpiffTooltip({ title, children }: OwnProps) {
return ( return (
<Tooltip title={title} arrow enterDelay={500}> <Tooltip
title={title}
arrow
enterDelay={500}
PopperProps={{ style: { zIndex: 9999 } }}
>
{children} {children}
</Tooltip> </Tooltip>
); );

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import HttpService from '../services/HttpService';
/**
* When scriptAssistQuery is set, trigger the call to the AI service
* and set the result to update any watchers.
*/
const useProcessScriptAssistMessage = () => {
const [scriptAssistQuery, setScriptAssistQuery] = useState<string>('');
const [scriptAssistResult, setScriptAssistResult] = useState<Record<
string,
any
> | null>(null);
const [scriptAssistLoading, setScriptAssistLoading] =
useState<boolean>(false);
useEffect(() => {
const handleResponse = (response: Record<string, any>) => {
setScriptAssistResult(response);
setScriptAssistQuery('');
setScriptAssistLoading(false);
};
/** Possibly make this check more robust, depending on what we see in use. */
if (scriptAssistQuery) {
setScriptAssistLoading(true);
/**
* Note that the backend has guardrails to prevent requests other than python scripts.
* See script_assist_controller.py
*/
HttpService.makeCallToBackend({
httpMethod: 'POST',
path: `/script-assist/process-message`,
postBody: { query: scriptAssistQuery.trim() },
successCallback: handleResponse,
failureCallback: (error: any) => {
setScriptAssistResult(error);
setScriptAssistQuery('');
setScriptAssistLoading(false);
},
});
}
}, [scriptAssistQuery, setScriptAssistQuery, scriptAssistResult]);
return {
setScriptAssistQuery,
scriptAssistLoading,
scriptAssistResult,
};
};
export default useProcessScriptAssistMessage;

View File

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import HttpService from '../services/HttpService';
/** Basic fetcher for the env value from the backend */
const useScriptAssistEnabled = () => {
const [scriptAssistEnabled, setScriptAssistEnabled] = useState(null);
useEffect(() => {
if (scriptAssistEnabled === null) {
const handleResponse = (response: any) => {
setScriptAssistEnabled(response.ok);
};
HttpService.makeCallToBackend({
path: `/script-assist/enabled`,
successCallback: handleResponse,
});
}
}, [scriptAssistEnabled]);
return {
scriptAssistEnabled,
};
};
export default useScriptAssistEnabled;

View File

@ -965,3 +965,67 @@ div.onboarding {
height: 48px; height: 48px;
line-height: 48px; line-height: 48px;
} }
/* Utility classes to create horizontally centered stacks (to align icons etc) */
.flex-align-horizontal-center {
display: flex;
align-items: center;
}
.flex-justify-end {
display: flex;
justify-content: flex-end;
}
.gray-text {
color: gray;
}
.error-text-red {
color: red;
}
/* Utility classes (carbon equivalents for stacks, columns and rows?) */
.p-10 {
padding: 10px;
}
.p-top-10 {
padding-top: 10px;
}
.p-bottom-10 {
padding-bottom: 10px;
}
.p-left-10 {
padding-left: 10px;
}
.p-right-10 {
padding-right: 10px;
}
.m-10 {
margin: 10px;
}
.m-top-10 {
margin-top: 10px;
}
.m-bottom-10 {
margin-bottom: 10px;
}
.m-left-10 {
margin-left: 10px;
}
.m-right-10 {
margin-right: 10px;
}
.not-editable {
pointer-events: none;
}

View File

@ -17,6 +17,9 @@ import {
TextInput, TextInput,
Grid, Grid,
Column, Column,
Stack,
TextArea,
InlineLoading,
} from '@carbon/react'; } from '@carbon/react';
import { import {
SkipForward, SkipForward,
@ -24,7 +27,9 @@ import {
PlayOutline, PlayOutline,
Close, Close,
Checkmark, Checkmark,
Information,
} from '@carbon/icons-react'; } from '@carbon/icons-react';
import { gray } from '@carbon/colors';
import Editor, { DiffEditor } from '@monaco-editor/react'; import Editor, { DiffEditor } from '@monaco-editor/react';
@ -49,6 +54,9 @@ import ProcessSearch from '../components/ProcessSearch';
import { Notification } from '../components/Notification'; import { Notification } from '../components/Notification';
import ActiveUsers from '../components/ActiveUsers'; import ActiveUsers from '../components/ActiveUsers';
import { useFocusedTabStatus } from '../hooks/useFocusedTabStatus'; import { useFocusedTabStatus } from '../hooks/useFocusedTabStatus';
import useScriptAssistEnabled from '../hooks/useScriptAssistEnabled';
import useProcessScriptAssistMessage from '../hooks/useProcessScriptAssistQuery';
import SpiffTooltip from '../components/SpiffTooltip';
export default function ProcessModelEditDiagram() { export default function ProcessModelEditDiagram() {
const [showFileNameEditor, setShowFileNameEditor] = useState(false); const [showFileNameEditor, setShowFileNameEditor] = useState(false);
@ -88,6 +96,14 @@ export default function ProcessModelEditDiagram() {
const failingScriptLineClassNamePrefix = 'failingScriptLineError'; const failingScriptLineClassNamePrefix = 'failingScriptLineError';
const [scriptAssistValue, setScriptAssistValue] = useState<string>('');
const [scriptAssistError, setScriptAssistError] = useState<string | null>(
null
);
const { scriptAssistEnabled } = useScriptAssistEnabled();
const { setScriptAssistQuery, scriptAssistLoading, scriptAssistResult } =
useProcessScriptAssistMessage();
function handleEditorDidMount(editor: any, monaco: any) { function handleEditorDidMount(editor: any, monaco: any) {
// here is the editor instance // here is the editor instance
// you can store it in `useRef` for further usage // you can store it in `useRef` for further usage
@ -499,6 +515,24 @@ export default function ProcessModelEditDiagram() {
setScriptText(value); setScriptText(value);
}; };
/**
* When the value of the Editor is updated dynamically async,
* it doesn't seem to fire an onChange (discussions as recent as 4.2.1).
* The straightforward recommended fix is to handle manually, so when
* the scriptAssistResult is updated, call the handler manually.
*/
useEffect(() => {
if (scriptAssistResult) {
if (scriptAssistResult.result) {
handleEditorScriptChange(scriptAssistResult.result);
} else if (scriptAssistResult.error_code && scriptAssistResult.message) {
setScriptAssistError(scriptAssistResult.message);
} else {
setScriptAssistError('Received unexpected response from server.');
}
}
}, [scriptAssistResult]);
const handleEditorScriptTestUnitInputChange = (value: any) => { const handleEditorScriptTestUnitInputChange = (value: any) => {
if (currentScriptUnitTest) { if (currentScriptUnitTest) {
currentScriptUnitTest.inputJson.value = value; currentScriptUnitTest.inputJson.value = value;
@ -819,10 +853,9 @@ export default function ProcessModelEditDiagram() {
} }
return null; return null;
}; };
const scriptEditor = () => {
if (!showScriptEditor) { /* Main python script editor user works in */
return null; const editorWindow = () => {
}
return ( return (
<Editor <Editor
height={500} height={500}
@ -830,11 +863,106 @@ export default function ProcessModelEditDiagram() {
options={generalEditorOptions()} options={generalEditorOptions()}
defaultLanguage="python" defaultLanguage="python"
defaultValue={scriptText} defaultValue={scriptText}
value={scriptText}
onChange={handleEditorScriptChange} onChange={handleEditorScriptChange}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}
/> />
); );
}; };
/**
* When user clicks script assist button, set useScriptAssistQuery hook with query.
* This will async update scriptAssistResult as needed.
*/
const handleProcessScriptAssist = () => {
if (scriptAssistValue) {
try {
setScriptAssistQuery(scriptAssistValue);
setScriptAssistError(null);
} catch (error) {
setScriptAssistError(`Failed to process script assist query: ${error}`);
}
} else {
setScriptAssistError('Please provide instructions for your script!');
}
};
/* If the Script Assist tab is enabled (via scriptAssistEnabled), this is the UI */
const scriptAssistWindow = () => {
return (
<>
<TextArea
placeholder="Ask Spiff AI"
rows={20}
value={scriptAssistValue}
onChange={(e: any) => setScriptAssistValue(e.target.value)}
/>
<Stack
className="flex-justify-end flex-align-horizontal-center"
orientation="horizontal"
gap={5}
>
{scriptAssistError && (
<div className="error-text-red">{scriptAssistError}</div>
)}
{scriptAssistLoading && (
<InlineLoading
status="active"
iconDescription="Loading"
description="Fetching script..."
/>
)}
<Button
className="m-top-10"
kind="secondary"
onClick={() => handleProcessScriptAssist()}
disabled={scriptAssistLoading}
>
Ask Spiff AI
</Button>
</Stack>
</>
);
};
const scriptEditor = () => {
return (
<Grid fullwidth>
<Column lg={16} md={8} sm={4}>
{editorWindow()}
</Column>
</Grid>
);
};
const scriptEditorWithAssist = () => {
return (
<Grid fullwidth>
<Column lg={10} md={4} sm={2}>
{editorWindow()}
</Column>
<Column lg={6} md={4} sm={2}>
<Stack
gap={3}
orientation="horizontal"
className="stack-align-content-horizontal p-bottom-10"
color={gray[50]}
>
<SpiffTooltip title="Use natural language to create your script. Hint: start basic and edit to tweak.">
<Stack className="gray-text flex-align-horizontal-center">
<Information size={14} />
<Stack className="p-left-10 not-editable">
Create a python script that...
</Stack>
</Stack>
</SpiffTooltip>
</Stack>
{scriptAssistWindow()}
</Column>
</Grid>
);
};
const scriptEditorAndTests = () => { const scriptEditorAndTests = () => {
if (!showScriptEditor) { if (!showScriptEditor) {
return null; return null;
@ -855,10 +983,14 @@ export default function ProcessModelEditDiagram() {
<Tabs> <Tabs>
<TabList aria-label="List of tabs" activation="manual"> <TabList aria-label="List of tabs" activation="manual">
<Tab>Script Editor</Tab> <Tab>Script Editor</Tab>
{scriptAssistEnabled && <Tab>Script Assist</Tab>}
<Tab>Unit Tests</Tab> <Tab>Unit Tests</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel>{scriptEditor()}</TabPanel> <TabPanel>{scriptEditor()}</TabPanel>
{scriptAssistEnabled && (
<TabPanel>{scriptEditorWithAssist()}</TabPanel>
)}
<TabPanel>{scriptUnitTestEditorElement()}</TabPanel> <TabPanel>{scriptUnitTestEditorElement()}</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>