From bc90ffe620c6e03b12576d6c2483d3335f8b508b Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 13 Feb 2024 10:52:17 -0800 Subject: [PATCH 1/6] Prototpye playbook run status generation. --- server/ai/prompts/playbook_run_status.tmpl | 18 ++ server/api.go | 9 +- server/api_playbook_run.go | 117 ++++++++ webapp/package-lock.json | 301 +++++++++++++++++++++ webapp/package.json | 2 + webapp/src/client.tsx | 21 ++ webapp/src/components/generic_modal.tsx | 278 +++++++++++++++++++ webapp/src/index.tsx | 2 +- webapp/src/playbooks_button.tsx | 91 +++++++ webapp/src/redux.tsx | 19 +- 10 files changed, 854 insertions(+), 4 deletions(-) create mode 100644 server/ai/prompts/playbook_run_status.tmpl create mode 100644 server/api_playbook_run.go create mode 100644 webapp/src/components/generic_modal.tsx create mode 100644 webapp/src/playbooks_button.tsx diff --git a/server/ai/prompts/playbook_run_status.tmpl b/server/ai/prompts/playbook_run_status.tmpl new file mode 100644 index 00000000..f7e066e7 --- /dev/null +++ b/server/ai/prompts/playbook_run_status.tmpl @@ -0,0 +1,18 @@ +{{define "playbook_run_status.system"}} +A playbook is a repeatable process that consists of a sequence of tasks. +A playbook run is an execution of a playbook. It includes tasks and a channel to talk about those tasks within. +Use the following context to write a useful status update for a playbook run. If a template is provided follow it exactly. +{{end}} +{{define "playbook_run_status.user"}} +Playbook run name: "{{.PromptParameters.RunName}}" +The posts in the playbook run channel are given below: +---- Channel Posts Start ---- +{{.PromptParameters.Posts}} +---- Channel Posts End ---- +{{if .PromptParameters.Template}} +The template you should follow when creating the status update is given below delimited by ```. Do not include ```. +``` +{{.PromptParameters.Template}} +``` +{{end}} +{{end}} diff --git a/server/api.go b/server/api.go index e27bac61..2741b238 100644 --- a/server/api.go +++ b/server/api.go @@ -10,8 +10,9 @@ import ( ) const ( - ContextPostKey = "post" - ContextChannelKey = "channel" + ContextPostKey = "post" + ContextChannelKey = "channel" + ContextPlaybookRunKey = "playbookrun" ) // ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world. @@ -35,6 +36,10 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req channelRouter.Use(p.channelAuthorizationRequired) channelRouter.POST("/since", p.handleSince) + playbookRunRouter := router.Group("/playbook_run/:playbookrunid") + playbookRunRouter.Use(p.playbookRunAuthorizationRequired) + playbookRunRouter.GET("/generate_status", p.handleGenerateStatus) + adminRouter := router.Group("/admin") adminRouter.Use(p.mattermostAdminAuthorizationRequired) diff --git a/server/api_playbook_run.go b/server/api_playbook_run.go new file mode 100644 index 00000000..3d8e16f2 --- /dev/null +++ b/server/api_playbook_run.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/gin-gonic/gin" + "github.com/mattermost/mattermost/server/public/model" + "github.com/pkg/errors" +) + +type PlaybookRun struct { + ID string `json:"id"` + Name string `json:"name"` + ChannelID string `json:"channel_id"` + StatusPosts []struct { + ID string `json:"id"` + } `json:"status_posts"` + StatusUpdateTemplate string `json:"reminder_message_template"` +} + +func (p *Plugin) playbookRunAuthorizationRequired(c *gin.Context) { + playbookRunID := c.Param("playbookrunid") + userID := c.GetHeader("Mattermost-User-Id") + + req, err := http.NewRequest("GET", fmt.Sprintf("/playbooks/api/v0/runs/%s", playbookRunID), nil) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "could not create request")) + return + } + req.Header.Set("Mattermost-User-Id", userID) + + resp := p.pluginAPI.Plugin.HTTP(req) + if resp == nil { + c.AbortWithError(http.StatusInternalServerError, errors.New("failed to get playbook run, response was nil")) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + c.AbortWithError(resp.StatusCode, errors.New("failed to get playbook run")) + return + } + + var playbookRun PlaybookRun + err = json.NewDecoder(resp.Body).Decode(&playbookRun) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to decode response")) + return + } + c.Set(ContextPlaybookRunKey, playbookRun) +} + +func (p *Plugin) handleGenerateStatus(c *gin.Context) { + userID := c.GetHeader("Mattermost-User-Id") + playbookRun := c.MustGet(ContextPlaybookRunKey).(PlaybookRun) + channelID := playbookRun.ChannelID + + if !p.pluginAPI.User.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { + c.AbortWithError(http.StatusForbidden, errors.New("user doesn't have permission to read channel")) + return + } + + user, err := p.pluginAPI.User.Get(userID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + posts, err := p.pluginAPI.Post.GetPostsForChannel(channelID, 0, 100) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to get posts for channel")) + return + } + + postsData, err := p.getMetadataForPosts(posts) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to get metadata for posts")) + return + } + // Remove deleted posts + postsData.Posts = slices.DeleteFunc(postsData.Posts, func(post *model.Post) bool { + return post.DeleteAt != 0 + }) + fomattedPosts := formatThread(postsData) + + context := p.MakeConversationContext(user, nil, nil) + context.PromptParameters = map[string]string{ + "Posts": fomattedPosts, + "Template": playbookRun.StatusUpdateTemplate, + "RunName": playbookRun.Name, + } + + prompt, err := p.prompts.ChatCompletion("playbook_run_status", context) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to generate prompt")) + return + } + + resultStream, err := p.getLLM().ChatCompletion(prompt) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to get completion")) + return + } + + // Hack into current streaming solution. TODO: generalize this + p.streamResultToPost(resultStream, &model.Post{ + ChannelId: channelID, + Id: "playbooks_post_update", + Message: "", + }) + + //result := resultStream.ReadAll() + + //c.JSON(http.StatusOK, result) +} diff --git a/webapp/package-lock.json b/webapp/package-lock.json index dcf1db19..99f8b0f5 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -8,10 +8,12 @@ "@floating-ui/react-dom-interactions": "0.13.3", "@mattermost/client": "7.10.0", "@mattermost/compass-icons": "0.1.38", + "classnames": "2.3.2", "core-js": "3.30.2", "luxon": "3.3.0", "process": "0.11.10", "react": "^16.14.0", + "react-bootstrap": "github:mattermost/react-bootstrap#d821e2b1db1059bd36112d7587fd1b0912b27626", "react-dom": "^16.14.0", "react-redux": "8.0.5", "react-use": "17.4.0", @@ -1787,6 +1789,25 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs2": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.23.9.tgz", + "integrity": "sha512-lwwDy5QfMkO2rmSz9AvLj6j2kWt5a4ulMi1t21vWQEO0kNCFslHoszat8reU/uigIQSUDF31zraZG/qMkcqAlw==", + "dependencies": { + "core-js": "^2.6.12", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs2/node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true + }, "node_modules/@babel/runtime/node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -3526,6 +3547,11 @@ "node": ">=6.0" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -3764,6 +3790,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.554", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.554.tgz", @@ -5172,6 +5206,14 @@ "node": ">= 0.10" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -5599,6 +5641,11 @@ "node": ">=4.0" } }, + "node_modules/keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -6195,6 +6242,26 @@ "react-is": "^16.8.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/prop-types-extra/node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -6246,6 +6313,30 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap": { + "version": "0.32.4", + "resolved": "git+ssh://git@github.com/mattermost/react-bootstrap.git#d821e2b1db1059bd36112d7587fd1b0912b27626", + "integrity": "sha512-wbF5OMCNlGg3z4SRlUxiD81nfPo2a8Y/CIGoQ2L/64PmsAZeceuoORdOhmU5/VeyjO2MEhxk8P8aOV6IdVk/Ig==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs2": "^7.0.0", + "classnames": "^2.2.5", + "dom-helpers": "^3.2.0", + "invariant": "^2.2.4", + "keycode": "^2.2.0", + "prop-types": "^15.6.1", + "prop-types-extra": "^1.0.1", + "react-overlays": "^0.9.3", + "react-prop-types": "^0.4.0", + "react-transition-group": "^2.0.0", + "uncontrollable": "^5.0.0", + "warning": "^3.0.0" + }, + "peerDependencies": { + "react": "^0.14.9 || >=15.3.0", + "react-dom": "^0.14.9 || >=15.3.0" + } + }, "node_modules/react-dom": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", @@ -6265,6 +6356,39 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-overlays": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.3.tgz", + "integrity": "sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ==", + "dependencies": { + "classnames": "^2.2.5", + "dom-helpers": "^3.2.1", + "prop-types": "^15.5.10", + "prop-types-extra": "^1.0.1", + "react-transition-group": "^2.2.1", + "warning": "^3.0.0" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-prop-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", + "integrity": "sha512-IyjsJhDX9JkoOV9wlmLaS7z+oxYoIWhfzDcFy7inwoAKTu+VcVNrVpPmLeioJ94y6GeDRsnwarG1py5qofFQMg==", + "dependencies": { + "warning": "^3.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-redux": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", @@ -6308,6 +6432,21 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "dependencies": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0", + "react-dom": ">=15.0.0" + } + }, "node_modules/react-universal-interface": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", @@ -6393,6 +6532,11 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "node_modules/regenerator-transform": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", @@ -7209,6 +7353,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-5.1.0.tgz", + "integrity": "sha512-5FXYaFANKaafg4IVZXUNtGyzsnYEvqlr9wQ3WpZxFpEUxl29A3H6Q4G1Dnnorvq9TGOGATBApWR4YpLAh+F5hw==", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -7296,6 +7451,14 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -8767,6 +8930,22 @@ } } }, + "@babel/runtime-corejs2": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.23.9.tgz", + "integrity": "sha512-lwwDy5QfMkO2rmSz9AvLj6j2kWt5a4ulMi1t21vWQEO0kNCFslHoszat8reU/uigIQSUDF31zraZG/qMkcqAlw==", + "requires": { + "core-js": "^2.6.12", + "regenerator-runtime": "^0.14.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" + } + } + }, "@babel/template": { "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", @@ -10090,6 +10269,11 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, + "classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -10280,6 +10464,14 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "electron-to-chromium": { "version": "1.4.554", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.554.tgz", @@ -11335,6 +11527,14 @@ "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -11636,6 +11836,11 @@ "object.assign": "^4.1.2" } }, + "keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -12085,6 +12290,25 @@ "react-is": "^16.8.1" } }, + "prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "requires": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "dependencies": { + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -12116,6 +12340,25 @@ "prop-types": "^15.6.2" } }, + "react-bootstrap": { + "version": "git+ssh://git@github.com/mattermost/react-bootstrap.git#d821e2b1db1059bd36112d7587fd1b0912b27626", + "integrity": "sha512-wbF5OMCNlGg3z4SRlUxiD81nfPo2a8Y/CIGoQ2L/64PmsAZeceuoORdOhmU5/VeyjO2MEhxk8P8aOV6IdVk/Ig==", + "from": "react-bootstrap@github:mattermost/react-bootstrap#d821e2b1db1059bd36112d7587fd1b0912b27626", + "requires": { + "@babel/runtime-corejs2": "^7.0.0", + "classnames": "^2.2.5", + "dom-helpers": "^3.2.0", + "invariant": "^2.2.4", + "keycode": "^2.2.0", + "prop-types": "^15.6.1", + "prop-types-extra": "^1.0.1", + "react-overlays": "^0.9.3", + "react-prop-types": "^0.4.0", + "react-transition-group": "^2.0.0", + "uncontrollable": "^5.0.0", + "warning": "^3.0.0" + } + }, "react-dom": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", @@ -12132,6 +12375,32 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-overlays": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.3.tgz", + "integrity": "sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ==", + "requires": { + "classnames": "^2.2.5", + "dom-helpers": "^3.2.1", + "prop-types": "^15.5.10", + "prop-types-extra": "^1.0.1", + "react-transition-group": "^2.2.1", + "warning": "^3.0.0" + } + }, + "react-prop-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", + "integrity": "sha512-IyjsJhDX9JkoOV9wlmLaS7z+oxYoIWhfzDcFy7inwoAKTu+VcVNrVpPmLeioJ94y6GeDRsnwarG1py5qofFQMg==", + "requires": { + "warning": "^3.0.0" + } + }, "react-redux": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", @@ -12152,6 +12421,17 @@ } } }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, "react-universal-interface": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", @@ -12221,6 +12501,11 @@ "regenerate": "^1.4.2" } }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "regenerator-transform": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", @@ -12826,6 +13111,14 @@ "which-boxed-primitive": "^1.0.2" } }, + "uncontrollable": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-5.1.0.tgz", + "integrity": "sha512-5FXYaFANKaafg4IVZXUNtGyzsnYEvqlr9wQ3WpZxFpEUxl29A3H6Q4G1Dnnorvq9TGOGATBApWR4YpLAh+F5hw==", + "requires": { + "invariant": "^2.2.4" + } + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -12879,6 +13172,14 @@ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "requires": {} }, + "warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/webapp/package.json b/webapp/package.json index b306b027..02e1f1db 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -57,10 +57,12 @@ "@floating-ui/react-dom-interactions": "0.13.3", "@mattermost/client": "7.10.0", "@mattermost/compass-icons": "0.1.38", + "classnames": "2.3.2", "core-js": "3.30.2", "luxon": "3.3.0", "process": "0.11.10", "react": "^16.14.0", + "react-bootstrap": "github:mattermost/react-bootstrap#d821e2b1db1059bd36112d7587fd1b0912b27626", "react-dom": "^16.14.0", "react-redux": "8.0.5", "react-use": "17.4.0", diff --git a/webapp/src/client.tsx b/webapp/src/client.tsx index 905910f4..842e7e81 100644 --- a/webapp/src/client.tsx +++ b/webapp/src/client.tsx @@ -16,6 +16,10 @@ function channelRoute(channelid: string): string { return `${baseRoute()}/channel/${channelid}`; } +function playbookRunRoute(playbookRunID: string): string { + return `${baseRoute()}/playbook_run/${playbookRunID}`; +} + export async function doReaction(postid: string) { const url = `${postRoute(postid)}/react`; const response = await fetch(url, Client4.getOptions({ @@ -166,6 +170,23 @@ export async function getAIThreads() { }); } +export async function generateStatusUpdate(playbookRunID: string) { + const url = `${playbookRunRoute(playbookRunID)}/generate_status`; + const response = await fetch(url, Client4.getOptions({ + method: 'GET', + })); + + if (response.ok) { + return; + } + + throw new ClientError(Client4.url, { + message: '', + status_code: response.status, + url, + }); +} + export async function createPost(post: any) { const created = await Client4.createPost(post); return created; diff --git a/webapp/src/components/generic_modal.tsx b/webapp/src/components/generic_modal.tsx new file mode 100644 index 00000000..50eac409 --- /dev/null +++ b/webapp/src/components/generic_modal.tsx @@ -0,0 +1,278 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import styled from 'styled-components'; +import classNames from 'classnames'; +import React from 'react'; +import {Modal} from 'react-bootstrap'; + +//import {FormattedMessage} from 'react-intl'; + +import {DestructiveButton, PrimaryButton, TertiaryButton} from 'src/components/assets/buttons'; + +type Props = { + className?: string; + onHide: () => void; + onExited?: () => void; + modalHeaderText?: React.ReactNode; + modalHeaderSideText?: React.ReactNode; + modalHeaderIcon?: React.ReactNode; + show?: boolean; + showCancel?: boolean; + handleCancel?: (() => void) | null; + handleConfirm?: (() => void) | null; + confirmButtonText?: React.ReactNode; + confirmButtonClassName?: string; + cancelButtonText?: React.ReactNode; + isConfirmDisabled?: boolean; + isConfirmDestructive?: boolean; + id: string; + autoCloseOnCancelButton?: boolean; + autoCloseOnConfirmButton?: boolean; + enforceFocus?: boolean; + footer?: React.ReactNode; + components?: Partial<{ + Header: typeof Modal.Header; + FooterContainer: typeof DefaultFooterContainer; + }>; + adjustTop?: number; + children: React.ReactNode; +}; + +type State = { + show: boolean; +}; + +export default class GenericModal extends React.PureComponent { + static defaultProps: Partial = { + id: 'genericModal', + autoCloseOnCancelButton: true, + autoCloseOnConfirmButton: true, + enforceFocus: true, + }; + + state = {show: true}; + + onHide = () => { + this.setState({show: false}, () => { + setTimeout(this.props.onHide, 150); + }); + }; + + handleCancel = (event: React.MouseEvent) => { + event.preventDefault(); + if (this.props.autoCloseOnCancelButton) { + this.onHide(); + } + this.props.handleCancel?.(); + }; + + handleConfirm = (event: React.MouseEvent) => { + event.preventDefault(); + if (this.props.autoCloseOnConfirmButton) { + this.onHide(); + } + + this.props.handleConfirm?.(); + }; + + render() { + let confirmButton; + if (this.props.handleConfirm) { + //let confirmButtonText: React.ReactNode = ; + let confirmButtonText: React.ReactNode = 'Confirm'; + if (this.props.confirmButtonText) { + confirmButtonText = this.props.confirmButtonText; + } + + const ButtonComponent = this.props.isConfirmDestructive ? DestructiveButton : PrimaryButton; + + confirmButton = ( + + {confirmButtonText} + + ); + } + + let cancelButton; + if (this.props.handleCancel || this.props.showCancel) { + //let cancelButtonText: React.ReactNode = ; + let cancelButtonText: React.ReactNode = 'Cancel'; + if (this.props.cancelButtonText) { + cancelButtonText = this.props.cancelButtonText; + } + + cancelButton = ( + + {cancelButtonText} + + ); + } + + const Header = this.props.components?.Header || Modal.Header; + const FooterContainer = this.props.components?.FooterContainer || DefaultFooterContainer; + const showFooter = Boolean(confirmButton || cancelButton || this.props.footer !== undefined); + + return ( + +
+ {Boolean(this.props.modalHeaderText) && ( + {this.props.modalHeaderText} + )} +
+ <> + {this.props.children} + {showFooter ? ( + + + + {cancelButton} + {confirmButton} + + {this.props.footer} + + + ) : null} + +
+ ); + } +} + +export const StyledModal = styled(Modal)` + &&& { + display: grid !important; + grid-template-rows: 1fr auto 2fr; + place-content: start center; + padding: 8px; + /* content-spacing */ + .modal-header { + margin-bottom: 8px; + } + .modal-body { + overflow: visible; + } + .modal-content { + padding: 24px; + } + .modal-footer { + padding: 24px 0 0 0; + } + .close { + margin: 12px 12px 0 0; + } + .modal-dialog { + margin: 0px !important; + max-width: 100%; + grid-row: 2; + } + } + + z-index: 1040; + + &&&& { + /* control correction-overrides */ + .form-control { + border: none; + } + input.form-control { + padding-left: 16px; + } + } +`; + +export const Buttons = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + gap: 10px; +`; + +export const DefaultFooterContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +export const ModalHeading = styled.h1` + font-size: 22px; + line-height: 28px; + color: var(--center-channel-color); +`; + +export const ModalSideheading = styled.h6` + font-size: 12px; + line-height: 20px; + color: rgba(var(--center-channel-color-rgb), 0.56); + padding-left: 8px; + margin: 0 0 0 8px; + border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.56); +`; + +export const ModalSubheading = styled.h6` + font-size: 12px; + line-height: 16px; + margin-top: 6px; + font-family: 'Open Sans'; + color: rgba(var(--center-channel-color-rgb), 0.72); +`; + +export const Description = styled.p` + font-size: 14px; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); + + a { + font-weight: bold; + } +`; + +export const Label = styled.label` + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: var(--center-channel-color); + margin-top: 24px; + margin-bottom: 8px; +`; + +export const InlineLabel = styled.label` + z-index: 1; + + width: max-content; + margin: 0 0 -8px 12px; + padding: 0 3px; + background: var(--center-channel-bg); + + font-weight: normal; + font-size: 10px; + line-height: 14px; + color: rgba(var(--center-channel-color-rgb), 0.64); +`; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index a7f5ac47..c0512b06 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -73,7 +73,7 @@ export default class Plugin { // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function public async initialize(registry: any, store: WebappStore) { - setupRedux(registry, store); + setupRedux(registry, store, this.postEventListener); let rhs: any = null; if ((window as any).Components.CreatePost) { diff --git a/webapp/src/playbooks_button.tsx b/webapp/src/playbooks_button.tsx new file mode 100644 index 00000000..1c8146e6 --- /dev/null +++ b/webapp/src/playbooks_button.tsx @@ -0,0 +1,91 @@ +import {WebSocketMessage} from '@mattermost/client'; +import React, {ChangeEvent, useEffect, useState} from 'react'; +import styled from 'styled-components'; + +import {generateStatusUpdate} from './client'; + +import GenericModal from './components/generic_modal'; +import {PostUpdateWebsocketMessage} from './components/llmbot_post'; +import PostEventListener from './websocket'; + +const modals = window.WebappUtils.modals; +const Textbox = window.Components.Textbox; + +export function makePlaybookRunStatusUpdateHandler(dispatch: any, postEventListener: PostEventListener) { + return (playbookRunId: string) => { + dispatch(modals.openModal({ + modalId: Date.now(), + dialogType: PlaybooksPostUpdateWithAIModal, + dialogProps: { + playbookRunId, + websocketRegister: postEventListener.registerPostUpdateListener, + websocketUnregister: postEventListener.unregisterPostUpdateListener, + }, + })); + }; +} + +type Props = { + playbookRunId: string + websocketRegister: (postID: string, handler: (msg: WebSocketMessage) => void) => void; + websocketUnregister: (postID: string) => void; +} + +const PlaybooksPostUpdateWithAIModal = (props: Props) => { + const [update, setUpdate] = useState(''); + const [generating, setGenerating] = useState(false); + + useEffect(() => { + props.websocketRegister('playbooks_post_update', (msg: WebSocketMessage) => { + const data = msg.data; + if (!data.control) { + setGenerating(true); + setUpdate(data.next); + } else if (data.control === 'end') { + setGenerating(false); + } + }); + + generateStatusUpdate(props.playbookRunId); + + return () => { + props.websocketUnregister('playbooks_post_update'); + }; + }, []); + + return ( + { + console.log('do the thing ' + props.playbookRunId); + }} + showCancel={true} + onHide={() => null} + > + ) => setUpdate(e.target.value)} + characterLimit={10000} + createMessage={''} + onKeyPress={() => true} + openWhenEmpty={true} + channelId={''} + disabled={false} + /> + + + ); +}; + +const SizedGenericModal = styled(GenericModal)` + width: 768px; + height: 600px; + padding: 0; +`; diff --git a/webapp/src/redux.tsx b/webapp/src/redux.tsx index 40f18ed0..8df0530b 100644 --- a/webapp/src/redux.tsx +++ b/webapp/src/redux.tsx @@ -2,14 +2,18 @@ import {combineReducers, Store, Action} from 'redux'; import {GlobalState} from '@mattermost/types/lib/store'; import {makeCallsPostButtonClickedHandler} from './calls_button'; +import {makePlaybookRunStatusUpdateHandler} from './playbooks_button'; +import PostEventListener from './websocket'; type WebappStore = Store>> const CallsClickHandler = 'calls_post_button_clicked_handler'; +const PlaybooksRunStatusUpdateClickHandler = 'playbooks_run_status_update_click_handler'; -export async function setupRedux(registry: any, store: WebappStore) { +export async function setupRedux(registry: any, store: WebappStore, postEventListener: PostEventListener) { const reducer = combineReducers({ callsPostButtonClickedTranscription, + aiStatusUpdateClicked, }); registry.registerReducer(reducer); @@ -17,6 +21,10 @@ export async function setupRedux(registry: any, store: WebappStore) { type: CallsClickHandler as any, handler: makeCallsPostButtonClickedHandler(store.dispatch), }); + store.dispatch({ + type: PlaybooksRunStatusUpdateClickHandler as any, + handler: makePlaybookRunStatusUpdateHandler(store.dispatch, postEventListener), + }); // This is a workaround for a bug where the the RHS was inaccessable to // users that where not system admins. This is unable to be fixed properly @@ -46,3 +54,12 @@ function callsPostButtonClickedTranscription(state = false, action: any) { return state; } } + +function aiStatusUpdateClicked(state = false, action: any) { + switch (action.type) { + case PlaybooksRunStatusUpdateClickHandler: + return action.handler || false; + default: + return state; + } +} From 358fc0103d02699aaab35a288e6ea0a1a8adf87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 15 Oct 2024 19:31:24 +0200 Subject: [PATCH 2/6] Fixing code --- go.mod | 2 +- server/api.go | 2 +- server/api_playbook_run.go | 20 ++++++++++++-------- server/post_processing.go | 38 ++++++++++++++++++++++++-------------- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 4e80853a..daa998fe 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mattermost/mattermost-plugin-ai -go 1.22 +go 1.22.8 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/server/api.go b/server/api.go index 6e2735c4..66a300bc 100644 --- a/server/api.go +++ b/server/api.go @@ -47,7 +47,7 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req channelRouter.Use(p.channelAuthorizationRequired) channelRouter.POST("/since", p.handleSince) - playbookRunRouter := router.Group("/playbook_run/:playbookrunid") + playbookRunRouter := botRequiredRouter.Group("/playbook_run/:playbookrunid") playbookRunRouter.Use(p.playbookRunAuthorizationRequired) playbookRunRouter.GET("/generate_status", p.handleGenerateStatus) diff --git a/server/api_playbook_run.go b/server/api_playbook_run.go index 3d8e16f2..77403f9b 100644 --- a/server/api_playbook_run.go +++ b/server/api_playbook_run.go @@ -1,12 +1,14 @@ package main import ( + "context" "encoding/json" "fmt" "net/http" "slices" "github.com/gin-gonic/gin" + "github.com/mattermost/mattermost-plugin-ai/server/ai" "github.com/mattermost/mattermost/server/public/model" "github.com/pkg/errors" ) @@ -56,6 +58,7 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { userID := c.GetHeader("Mattermost-User-Id") playbookRun := c.MustGet(ContextPlaybookRunKey).(PlaybookRun) channelID := playbookRun.ChannelID + bot := c.MustGet(ContextBotKey).(*Bot) if !p.pluginAPI.User.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { c.AbortWithError(http.StatusForbidden, errors.New("user doesn't have permission to read channel")) @@ -85,33 +88,34 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { }) fomattedPosts := formatThread(postsData) - context := p.MakeConversationContext(user, nil, nil) - context.PromptParameters = map[string]string{ + ccontext := p.MakeConversationContext(bot, user, nil, nil) + ccontext.PromptParameters = map[string]string{ "Posts": fomattedPosts, "Template": playbookRun.StatusUpdateTemplate, "RunName": playbookRun.Name, } - prompt, err := p.prompts.ChatCompletion("playbook_run_status", context) + prompt, err := p.prompts.ChatCompletion("playbook_run_status", ccontext, ai.NewNoTools()) if err != nil { c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to generate prompt")) return } - resultStream, err := p.getLLM().ChatCompletion(prompt) + resultStream, err := p.getLLM(bot.cfg).ChatCompletion(prompt) if err != nil { c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to get completion")) return } + locale := *p.API.GetConfig().LocalizationSettings.DefaultServerLocale // Hack into current streaming solution. TODO: generalize this - p.streamResultToPost(resultStream, &model.Post{ + p.streamResultToPost(context.Background(), resultStream, &model.Post{ ChannelId: channelID, Id: "playbooks_post_update", Message: "", - }) + }, locale) - //result := resultStream.ReadAll() + // result := resultStream.ReadAll() - //c.JSON(http.StatusOK, result) + // c.JSON(http.StatusOK, result) } diff --git a/server/post_processing.go b/server/post_processing.go index 785fdfa6..43038182 100644 --- a/server/post_processing.go +++ b/server/post_processing.go @@ -99,8 +99,10 @@ func formatThread(data *ThreadData) string { return result } -const LLMRequesterUserID = "llm_requester_user_id" -const UnsafeLinksPostProp = "unsafe_links" +const ( + LLMRequesterUserID = "llm_requester_user_id" + UnsafeLinksPostProp = "unsafe_links" +) func (p *Plugin) modifyPostForBot(botid string, requesterUserID string, post *model.Post) { post.UserId = botid @@ -214,9 +216,11 @@ func (p *Plugin) sendPostStreamingUpdateEvent(post *model.Post, message string) }) } -const PostStreamingControlCancel = "cancel" -const PostStreamingControlEnd = "end" -const PostStreamingControlStart = "start" +const ( + PostStreamingControlCancel = "cancel" + PostStreamingControlEnd = "end" + PostStreamingControlStart = "start" +) func (p *Plugin) sendPostStreamingControlEvent(post *model.Post, control string) { p.API.PublishWebSocketEvent("postupdate", map[string]interface{}{ @@ -287,9 +291,11 @@ func (p *Plugin) streamResultToPost(ctx context.Context, stream *ai.TextStreamRe post.Message = T("copilot.stream_to_post_llm_not_return", "Sorry! The LLM did not return a result.") p.sendPostStreamingUpdateEvent(post, post.Message) } - if err = p.pluginAPI.Post.UpdatePost(post); err != nil { - p.API.LogError("Streaming failed to update post", "error", err) - return + if post.Id != "playbooks_post_update" { + if err = p.pluginAPI.Post.UpdatePost(post); err != nil { + p.API.LogError("Streaming failed to update post", "error", err) + return + } } return } @@ -302,16 +308,20 @@ func (p *Plugin) streamResultToPost(ctx context.Context, stream *ai.TextStreamRe p.API.LogError("Streaming result to post failed partway", "error", err) post.Message = T("copilot.stream_to_post_access_llm_error", "Sorry! An error occurred while accessing the LLM. See server logs for details.") - if err := p.pluginAPI.Post.UpdatePost(post); err != nil { - p.API.LogError("Error recovering from streaming error", "error", err) - return + if post.Id != "playbooks_post_update" { + if err := p.pluginAPI.Post.UpdatePost(post); err != nil { + p.API.LogError("Error recovering from streaming error", "error", err) + return + } } p.sendPostStreamingUpdateEvent(post, post.Message) return case <-ctx.Done(): - if err := p.pluginAPI.Post.UpdatePost(post); err != nil { - p.API.LogError("Error updating post on stop signaled", "error", err) - return + if post.Id != "playbooks_post_update" { + if err := p.pluginAPI.Post.UpdatePost(post); err != nil { + p.API.LogError("Error updating post on stop signaled", "error", err) + return + } } p.sendPostStreamingControlEvent(post, PostStreamingControlCancel) return From 2a4afe7672074a7e951803f7d6a8d017ad52cb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 11 Dec 2024 11:04:05 +0100 Subject: [PATCH 3/6] Add support for adding instructions --- server/ai/prompts/playbook_run_status.tmpl | 6 ++++++ server/api.go | 2 +- server/api_playbook_run.go | 14 +++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/server/ai/prompts/playbook_run_status.tmpl b/server/ai/prompts/playbook_run_status.tmpl index f7e066e7..88f9eccb 100644 --- a/server/ai/prompts/playbook_run_status.tmpl +++ b/server/ai/prompts/playbook_run_status.tmpl @@ -15,4 +15,10 @@ The template you should follow when creating the status update is given below de {{.PromptParameters.Template}} ``` {{end}} +{{if .PromptParameters.Instructions}} +For building the status udate, use the following instructions to given below delimited by ```. Do not include ```. +``` +{{.PromptParameters.Instructions}} +``` +{{end}} {{end}} diff --git a/server/api.go b/server/api.go index 79bda60b..8a698a9f 100644 --- a/server/api.go +++ b/server/api.go @@ -49,7 +49,7 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req playbookRunRouter := botRequiredRouter.Group("/playbook_run/:playbookrunid") playbookRunRouter.Use(p.playbookRunAuthorizationRequired) - playbookRunRouter.GET("/generate_status", p.handleGenerateStatus) + playbookRunRouter.POST("/generate_status", p.handleGenerateStatus) adminRouter := router.Group("/admin") adminRouter.Use(p.mattermostAdminAuthorizationRequired) diff --git a/server/api_playbook_run.go b/server/api_playbook_run.go index 77403f9b..6d232ba4 100644 --- a/server/api_playbook_run.go +++ b/server/api_playbook_run.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "slices" + "strings" "github.com/gin-gonic/gin" "github.com/mattermost/mattermost-plugin-ai/server/ai" @@ -60,6 +61,12 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { channelID := playbookRun.ChannelID bot := c.MustGet(ContextBotKey).(*Bot) + var instructions []string + if err := json.NewDecoder(c.Request.Body).Decode(&instructions); err != nil { + c.AbortWithError(http.StatusBadRequest, errors.New("You need to pass a list of instructions, it can be an empty list")) + return + } + if !p.pluginAPI.User.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { c.AbortWithError(http.StatusForbidden, errors.New("user doesn't have permission to read channel")) return @@ -90,9 +97,10 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { ccontext := p.MakeConversationContext(bot, user, nil, nil) ccontext.PromptParameters = map[string]string{ - "Posts": fomattedPosts, - "Template": playbookRun.StatusUpdateTemplate, - "RunName": playbookRun.Name, + "Posts": fomattedPosts, + "Template": playbookRun.StatusUpdateTemplate, + "RunName": playbookRun.Name, + "Instructions": strings.TrimSpace(strings.Join(instructions, "\n")), } prompt, err := p.prompts.ChatCompletion("playbook_run_status", ccontext, ai.NewNoTools()) From 427d251578e68e49565537fc3ea8e65c7d618a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 11 Dec 2024 13:01:21 +0100 Subject: [PATCH 4/6] Expose the bot selector component --- webapp/src/redux.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/webapp/src/redux.tsx b/webapp/src/redux.tsx index cbcb37f0..284ca366 100644 --- a/webapp/src/redux.tsx +++ b/webapp/src/redux.tsx @@ -5,11 +5,13 @@ import {makeCallsPostButtonClickedHandler} from './calls_button'; import {makePlaybookRunStatusUpdateHandler} from './playbooks_button'; import PostEventListener from './websocket'; import manifest from './manifest'; +import {DropdownBotSelector} from './components/bot_slector' type WebappStore = Store>> const CallsClickHandler = 'calls_post_button_clicked_handler'; const PlaybooksRunStatusUpdateClickHandler = 'playbooks_run_status_update_click_handler'; +const AIBotSelectorComponent = 'ai_bots_selector_component'; export const BotsHandler = manifest.id + '_bots'; export async function setupRedux(registry: any, store: WebappStore, postEventListener: PostEventListener) { @@ -17,6 +19,7 @@ export async function setupRedux(registry: any, store: WebappStore, postEventLis callsPostButtonClickedTranscription, aiStatusUpdateClicked, bots, + botSelector, botChannelId, selectedPostId, }); @@ -30,6 +33,10 @@ export async function setupRedux(registry: any, store: WebappStore, postEventLis type: PlaybooksRunStatusUpdateClickHandler as any, handler: makePlaybookRunStatusUpdateHandler(store.dispatch, postEventListener), }); + store.dispatch({ + type: AIBotSelectorComponent as any, + component: DropdownBotSelector, + }); // This is a workaround for a bug where the RHS was inaccessible to // users that where not system admins. This is unable to be fixed properly @@ -69,6 +76,15 @@ function aiStatusUpdateClicked(state = false, action: any) { } } +function botSelector(state = false, action: any) { + switch (action.type) { + case AIBotSelectorComponent: + return action.component; + default: + return state; + } +} + function bots(state = null, action: any) { switch (action.type) { case BotsHandler: From d064feec0a0d830f8a30a52bd8337e883c2dd3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 11 Dec 2024 16:20:40 +0100 Subject: [PATCH 5/6] Proplery making the different bots work here --- server/ai/prompts/playbook_run_status.tmpl | 9 ++++++++ server/api_playbook_run.go | 24 +++++++++++++++------- webapp/src/components/dropdown.tsx | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/server/ai/prompts/playbook_run_status.tmpl b/server/ai/prompts/playbook_run_status.tmpl index 88f9eccb..891ec965 100644 --- a/server/ai/prompts/playbook_run_status.tmpl +++ b/server/ai/prompts/playbook_run_status.tmpl @@ -2,6 +2,9 @@ A playbook is a repeatable process that consists of a sequence of tasks. A playbook run is an execution of a playbook. It includes tasks and a channel to talk about those tasks within. Use the following context to write a useful status update for a playbook run. If a template is provided follow it exactly. +{{if .CustomInstructions}} +{{.CustomInstructions}} +{{end}} {{end}} {{define "playbook_run_status.user"}} Playbook run name: "{{.PromptParameters.RunName}}" @@ -21,4 +24,10 @@ For building the status udate, use the following instructions to given below del {{.PromptParameters.Instructions}} ``` {{end}} +{{if .PromptParameters.PreviousMessages}} +This is a list of the previous messages that you proposed me ordered by time and separated by a line with -----. Use them to follow the instruction, taking into account that the instrunctions probably refers to the last message you shared. The list is given below delimited by ```. Do not include ```. +``` +{{.PromptParameters.PreviousMessages}} +``` +{{end}} {{end}} diff --git a/server/api_playbook_run.go b/server/api_playbook_run.go index 6d232ba4..e09b4ac9 100644 --- a/server/api_playbook_run.go +++ b/server/api_playbook_run.go @@ -59,14 +59,23 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { userID := c.GetHeader("Mattermost-User-Id") playbookRun := c.MustGet(ContextPlaybookRunKey).(PlaybookRun) channelID := playbookRun.ChannelID - bot := c.MustGet(ContextBotKey).(*Bot) - var instructions []string - if err := json.NewDecoder(c.Request.Body).Decode(&instructions); err != nil { + var generateRequest struct { + Instructions []string `json:"instructions"` + Messages []string `json:"messages"` + Bot string `json:"bot"` + } + + if err := json.NewDecoder(c.Request.Body).Decode(&generateRequest); err != nil { c.AbortWithError(http.StatusBadRequest, errors.New("You need to pass a list of instructions, it can be an empty list")) return } + bot := p.GetBotByID(generateRequest.Bot) + if bot == nil { + bot = c.MustGet(ContextBotKey).(*Bot) + } + if !p.pluginAPI.User.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { c.AbortWithError(http.StatusForbidden, errors.New("user doesn't have permission to read channel")) return @@ -97,10 +106,11 @@ func (p *Plugin) handleGenerateStatus(c *gin.Context) { ccontext := p.MakeConversationContext(bot, user, nil, nil) ccontext.PromptParameters = map[string]string{ - "Posts": fomattedPosts, - "Template": playbookRun.StatusUpdateTemplate, - "RunName": playbookRun.Name, - "Instructions": strings.TrimSpace(strings.Join(instructions, "\n")), + "Posts": fomattedPosts, + "Template": playbookRun.StatusUpdateTemplate, + "RunName": playbookRun.Name, + "Instructions": strings.TrimSpace(strings.Join(generateRequest.Instructions, "\n")), + "PreviousMessages": strings.TrimSpace(strings.Join(generateRequest.Messages, "\n-----\n")), } prompt, err := p.prompts.ChatCompletion("playbook_run_status", ccontext, ai.NewNoTools()) diff --git a/webapp/src/components/dropdown.tsx b/webapp/src/components/dropdown.tsx index 6417c62a..65d60e86 100644 --- a/webapp/src/components/dropdown.tsx +++ b/webapp/src/components/dropdown.tsx @@ -20,7 +20,7 @@ import { const FloatingContainer = styled.div` min-width: 16rem; - z-index: 50; + z-index: 1050; `; type DropdownProps = { From 6ccdac88f925a754ce650e355cfb5cc4e7951eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 18 Dec 2024 13:50:11 +0100 Subject: [PATCH 6/6] Avoid introductory text on the summaries --- server/ai/prompts/playbook_run_status.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/ai/prompts/playbook_run_status.tmpl b/server/ai/prompts/playbook_run_status.tmpl index 891ec965..21a4f8dd 100644 --- a/server/ai/prompts/playbook_run_status.tmpl +++ b/server/ai/prompts/playbook_run_status.tmpl @@ -1,7 +1,7 @@ {{define "playbook_run_status.system"}} A playbook is a repeatable process that consists of a sequence of tasks. A playbook run is an execution of a playbook. It includes tasks and a channel to talk about those tasks within. -Use the following context to write a useful status update for a playbook run. If a template is provided follow it exactly. +Use the following context to write a useful status update for a playbook run. If a template is provided follow it exactly. Do not include any introductory text, write the summary directly. {{if .CustomInstructions}} {{.CustomInstructions}} {{end}}