Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AI support for playbook run status #270

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 33 additions & 0 deletions server/ai/prompts/playbook_run_status.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{{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. Do not include any introductory text, write the summary directly.
{{if .CustomInstructions}}
{{.CustomInstructions}}
{{end}}
{{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}}
{{if .PromptParameters.Instructions}}
For building the status udate, use the following instructions to given below delimited by ```. Do not include ```.
```
{{.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}}
14 changes: 9 additions & 5 deletions server/api.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package main

import (
"errors"
"fmt"
"net/http"

"errors"

"github.com/gin-gonic/gin"
"github.com/mattermost/mattermost-plugin-ai/server/ai"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)

const (
ContextPostKey = "post"
ContextChannelKey = "channel"
ContextBotKey = "bot"
ContextPostKey = "post"
ContextChannelKey = "channel"
ContextBotKey = "bot"
ContextPlaybookRunKey = "playbookrun"

requestBodyMaxSizeBytes = 1024 * 1024 // 1MB
)
Expand Down Expand Up @@ -47,6 +47,10 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
channelRouter.Use(p.channelAuthorizationRequired)
channelRouter.POST("/since", p.handleSince)

playbookRunRouter := botRequiredRouter.Group("/playbook_run/:playbookrunid")
playbookRunRouter.Use(p.playbookRunAuthorizationRequired)
playbookRunRouter.POST("/generate_status", p.handleGenerateStatus)

adminRouter := router.Group("/admin")
adminRouter.Use(p.mattermostAdminAuthorizationRequired)

Expand Down
139 changes: 139 additions & 0 deletions server/api_playbook_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package main

import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"

"github.com/gin-gonic/gin"
"github.com/mattermost/mattermost-plugin-ai/server/ai"
"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

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
}

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)

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(generateRequest.Instructions, "\n")),
"PreviousMessages": strings.TrimSpace(strings.Join(generateRequest.Messages, "\n-----\n")),
}

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(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(context.Background(), resultStream, &model.Post{
ChannelId: channelID,
Id: "playbooks_post_update",
Message: "",
}, locale)

// result := resultStream.ReadAll()

// c.JSON(http.StatusOK, result)
}
38 changes: 24 additions & 14 deletions server/post_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@
"@mattermost/client": "10.0.0",
"@mattermost/compass-icons": "0.1.38",
"@mattermost/eslint-plugin": "1.1.0-0",
"classnames": "2.3.2",
"core-js": "3.38.1",
"debounce": "2.1.1",
"luxon": "3.5.0",
"process": "0.11.10",
"react": "^16.14.0",
"react-bootstrap": "github:mattermost/react-bootstrap#d821e2b1db1059bd36112d7587fd1b0912b27626",
"react-dom": "^16.14.0",
"react-intl": "5.25.1",
"react-redux": "8.0.5",
Expand Down
21 changes: 21 additions & 0 deletions webapp/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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({
Expand Down Expand Up @@ -193,6 +197,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 getAIBots() {
const url = `${baseRoute()}/ai_bots`;
const response = await fetch(url, Client4.getOptions({
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/components/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {

const FloatingContainer = styled.div`
min-width: 16rem;
z-index: 50;
z-index: 1050;
`;

type DropdownProps = {
Expand Down
Loading
Loading