Skip to content

Commit

Permalink
Add the /ai/open route
Browse files Browse the repository at this point in the history
  • Loading branch information
nono committed Sep 10, 2024
1 parent b83f49b commit c89116d
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 0 deletions.
9 changes: 9 additions & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ external_indexers:
beta:
- http://localhost:8001

# OpenWebUI is the app used for AI chatbot.
openwebui:
# It is configurable per context.
default:
url: https://openwebui.example.org/
admin:
email: [email protected]
password: p4$$w0rd

# mail service parameters for sending email via SMTP
mail:
# mail noreply address - flags: --mail-noreply-address
Expand Down
44 changes: 44 additions & 0 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,47 @@ can use the vector database to find relevant documents (technically, only some
parts of the documents called chunks). Those documents are sent back to
LibreChat that can be added to the prompt, so that the LLM can use them as a
context when answering.

### GET /ai/open

This route returns the parameters to open an iframe with OpenWebUI. It creates
an account on the OpenWebUI chat server if needed, creates a token an returns
the configured URL of the OpenWebUI chat server.

### Request

```http
GET /ai/open HTTP/1.1
```

### Response

```http
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
```

```json
{
"data": {
"type": "io.cozy.ai.url",
"id": "eeec30e8-02d9-4988-80a5-acdb238f8d10",
"attributes": {
"url": "https://openwebui.example.org/",
"token:": "eyJh...Md4o"
}
}
}
```

The webapp can then creates an iframe:

```html
<iframe src="https://openwebui.example.org/" data-token="eyJh...Md4o"></iframe>
```

and inside the iframe loads the token with:

```js
localStorage.token = window.frameElement.dataset.token
```
168 changes: 168 additions & 0 deletions model/openwebui/open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package openwebui

import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"time"

"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/hkdf"
)

type OpenURL struct {
DocID string `json:"id,omitempty"`
URL string `json:"url"`
Token string `json:"token"`
}

func (o *OpenURL) ID() string { return o.DocID }
func (o *OpenURL) Rev() string { return "" }
func (o *OpenURL) DocType() string { return consts.AIOpenURL }
func (o *OpenURL) Clone() couchdb.Doc { cloned := *o; return &cloned }
func (o *OpenURL) SetID(id string) { o.DocID = id }
func (o *OpenURL) SetRev(rev string) {}
func (o *OpenURL) Relationships() jsonapi.RelationshipMap { return nil }
func (o *OpenURL) Included() []jsonapi.Object { return nil }
func (o *OpenURL) Links() *jsonapi.LinksList { return nil }
func (o *OpenURL) Fetch(field string) []string { return nil }

var openwebuiClient = &http.Client{
Timeout: 60 * time.Second,
}

func Open(inst *instance.Instance) (*OpenURL, error) {
cfg := getConfig(inst.ContextName)
if cfg == nil || cfg.URL == "" {
return nil, errors.New("Not configured")
}
u, err := url.Parse(cfg.URL)
if err != nil {
return nil, err
}
admin, err := signin(u, cfg.Admin)
if err != nil {
return nil, err
}
open, err := fetchOpen(inst, admin.Token, u)
if err != nil {
return nil, err
}
open.URL = cfg.URL
return open, nil
}

func getConfig(contextName string) *config.OpenWebUI {
configuration := config.GetConfig().OpenWebUI
if c, ok := configuration[contextName]; ok {
return &c
} else if c, ok := configuration[config.DefaultInstanceContext]; ok {
return &c
}
return nil
}

func signin(u *url.URL, account map[string]interface{}) (*OpenURL, error) {
u.Path = "/api/v1/auths/signin"
payload, err := json.Marshal(account)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON)
res, err := openwebuiClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, errors.New("cannot authenticate")
}
openURL := &OpenURL{}
if err := json.NewDecoder(res.Body).Decode(openURL); err != nil {
return nil, err
}
return openURL, nil
}

func fetchOpen(inst *instance.Instance, adminToken string, u *url.URL) (*OpenURL, error) {
email := "me@" + inst.Domain
password, err := computePassword(inst)
if err != nil {
return nil, err
}
signinPayload := map[string]interface{}{
"email": email,
"password": password,
}
open, err := signin(u, signinPayload)
if err == nil {
return open, nil
}
name, err := inst.SettingsPublicName()
if err != nil || name == "" {
name = "Cozy"
}
image_url := inst.PageURL("/public/avatar", url.Values{
"fallback": {"initials"},
})
account := map[string]interface{}{
"email": email,
"name": name,
"password": password,
"role": "user",
"profile_image_url": image_url,
}
return createAccount(adminToken, u, account)
}

func createAccount(adminToken string, u *url.URL, account map[string]interface{}) (*OpenURL, error) {
u.Path = "/api/v1/auths/add"
payload, err := json.Marshal(account)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Add(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Add(echo.HeaderAuthorization, "Bearer "+adminToken)
res, err := openwebuiClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, errors.New("cannot create account")
}
openURL := &OpenURL{}
if err := json.NewDecoder(res.Body).Decode(openURL); err != nil {
return nil, err
}
return openURL, nil
}

func computePassword(inst *instance.Instance) (string, error) {
salt := []byte("OpenWebUI")
h := hkdf.New(sha256.New, inst.SessionSecret(), salt, nil)
raw := make([]byte, 32)
if _, err := io.ReadFull(h, raw); err != nil {
return "", err
}
password := base64.StdEncoding.EncodeToString(raw)[0:32]
return password, nil
}
1 change: 1 addition & 0 deletions model/permission/doctype.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var blockList = map[string]bool{
consts.OfficeURL: none,
consts.NotesURL: none,
consts.AppsOpenParameters: none,
consts.AIOpenURL: none,

// Synthetic doctypes (realtime events only)
consts.AuthConfirmations: none,
Expand Down
38 changes: 38 additions & 0 deletions pkg/config/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ type Config struct {
Office map[string]Office
Registries map[string][]*url.URL
Clouderies map[string]ClouderyConfig
OpenWebUI map[string]OpenWebUI

RemoteAllowCustomPort bool

Expand Down Expand Up @@ -230,6 +231,12 @@ type Office struct {
OutboxSecret string
}

// OpenWebUI contains the configuration for OpenWebUI chat server.
type OpenWebUI struct {
URL string
Admin map[string]interface{}
}

// Notifications contains the configuration for the mobile push-notification
// center, for Android and iOS
type Notifications struct {
Expand Down Expand Up @@ -573,6 +580,11 @@ func UseViper(v *viper.Viper) error {
return err
}

openWebUI, err := makeOpenWebUI(v)
if err != nil {
return err
}

var subdomains SubdomainType
if subs := v.GetString("subdomains"); subs != "" {
switch subs {
Expand Down Expand Up @@ -895,6 +907,7 @@ func UseViper(v *viper.Viper) error {
Contexts: v.GetStringMap("contexts"),
Authentication: v.GetStringMap("authentication"),
Office: office,
OpenWebUI: openWebUI,
Registries: regs,
AuthorizedForConfirm: v.GetStringSlice("authorized_hosts_for_confirm_auth"),

Expand Down Expand Up @@ -1110,6 +1123,31 @@ func makeOffice(v *viper.Viper) (map[string]Office, error) {
return office, nil
}

func makeOpenWebUI(v *viper.Viper) (map[string]OpenWebUI, error) {
openWebUI := make(map[string]OpenWebUI)

for k, v := range v.GetStringMap("openWebUI") {
ctx, ok := v.(map[string]interface{})
if !ok {
return nil, errors.New("Bad format in the openWebUI section of the configuration file")
}
url, ok := ctx["url"].(string)
if !ok {
return nil, errors.New("Bad format in the openWebUI section of the configuration file")
}
admin, ok := ctx["admin"].(map[string]interface{})
if !ok {
return nil, errors.New("Bad format in the openWebUI section of the configuration file")
}
openWebUI[k] = OpenWebUI{
URL: url,
Admin: admin,
}
}

return openWebUI, nil
}

func makeSMS(raw map[string]interface{}) map[string]SMS {
sms := make(map[string]SMS)
for name, val := range raw {
Expand Down
2 changes: 2 additions & 0 deletions pkg/consts/doctype.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,6 @@ const (
// NextCloudFiles doc type is used when listing files from a NextCloud via
// WebDAV.
NextCloudFiles = "io.cozy.remote.nextcloud.files"
// AIOpenURL doc type is used to open a chatbot session.
AIOpenURL = "io.cozy.ai.url"
)
29 changes: 29 additions & 0 deletions web/ai/ai.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package ai

import (
"net/http"

"github.com/cozy/cozy-stack/model/openwebui"
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/cozy/cozy-stack/web/middlewares"
"github.com/labstack/echo/v4"
)

// Open returns the parameters to open an office document.
func Open(c echo.Context) error {
if !middlewares.IsLoggedIn(c) && !middlewares.HasWebAppToken(c) {
return middlewares.ErrForbidden
}
inst := middlewares.GetInstance(c)
doc, err := openwebui.Open(inst)
if err != nil {
return jsonapi.InternalServerError(err)
}

return jsonapi.Data(c, http.StatusOK, doc, nil)
}

// Routes sets the routing for the AI chatbot sessions.
func Routes(router *echo.Group) {
router.GET("/open", Open)
}
2 changes: 2 additions & 0 deletions web/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/cozy/cozy-stack/pkg/metrics"
"github.com/cozy/cozy-stack/web/accounts"
"github.com/cozy/cozy-stack/web/ai"
"github.com/cozy/cozy-stack/web/apps"
"github.com/cozy/cozy-stack/web/auth"
"github.com/cozy/cozy-stack/web/bitwarden"
Expand Down Expand Up @@ -235,6 +236,7 @@ func SetupRoutes(router *echo.Echo, services *stack.Services) error {
sharings.Routes(router.Group("/sharings", mws...))
bitwarden.Routes(router.Group("/bitwarden", mws...))
shortcuts.Routes(router.Group("/shortcuts", mws...))
ai.Routes(router.Group("/ai", mws...))

// The settings routes needs not to be blocked
apps.WebappsRoutes(router.Group("/apps", mwsNotBlocked...))
Expand Down

0 comments on commit c89116d

Please sign in to comment.