feat(chat): support other local llm #331

This commit is contained in:
Jacky 2024-05-01 19:40:38 +08:00
parent 08631437ee
commit 3b116b3654
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
4 changed files with 356 additions and 361 deletions

View file

@ -1,305 +1,305 @@
package nginx package nginx
import ( import (
"encoding/json" "encoding/json"
"github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/internal/logger" "github.com/0xJacky/Nginx-UI/internal/logger"
"github.com/0xJacky/Nginx-UI/internal/nginx" "github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/hpcloud/tail" "github.com/hpcloud/tail"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cast" "github.com/spf13/cast"
"io" "io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
) )
const ( const (
PageSize = 128 * 1024 PageSize = 128 * 1024
) )
type controlStruct struct { type controlStruct struct {
Type string `json:"type"` Type string `json:"type"`
ConfName string `json:"conf_name"` ConfName string `json:"conf_name"`
ServerIdx int `json:"server_idx"` ServerIdx int `json:"server_idx"`
DirectiveIdx int `json:"directive_idx"` DirectiveIdx int `json:"directive_idx"`
} }
type nginxLogPageResp struct { type nginxLogPageResp struct {
Content string `json:"content"` Content string `json:"content"`
Page int64 `json:"page"` Page int64 `json:"page"`
} }
func GetNginxLogPage(c *gin.Context) { func GetNginxLogPage(c *gin.Context) {
page := cast.ToInt64(c.Query("page")) page := cast.ToInt64(c.Query("page"))
if page < 0 { if page < 0 {
page = 0 page = 0
} }
var control controlStruct var control controlStruct
if !api.BindAndValid(c, &control) { if !api.BindAndValid(c, &control) {
return return
} }
logPath, err := getLogPath(&control) logPath, err := getLogPath(&control)
if err != nil { if err != nil {
logger.Error(err) logger.Error(err)
return return
} }
logFileStat, err := os.Stat(logPath) logFileStat, err := os.Stat(logPath)
if err != nil { if err != nil {
c.JSON(http.StatusOK, nginxLogPageResp{}) c.JSON(http.StatusOK, nginxLogPageResp{})
logger.Error(err) logger.Error(err)
return return
} }
if !logFileStat.Mode().IsRegular() { if !logFileStat.Mode().IsRegular() {
c.JSON(http.StatusOK, nginxLogPageResp{}) c.JSON(http.StatusOK, nginxLogPageResp{})
logger.Error("log file is not regular file:", logPath) logger.Error("log file is not regular file:", logPath)
return return
} }
f, err := os.Open(logPath) f, err := os.Open(logPath)
if err != nil { if err != nil {
c.JSON(http.StatusOK, nginxLogPageResp{}) c.JSON(http.StatusOK, nginxLogPageResp{})
logger.Error(err) logger.Error(err)
return return
} }
totalPage := logFileStat.Size() / PageSize totalPage := logFileStat.Size() / PageSize
if logFileStat.Size()%PageSize > 0 { if logFileStat.Size()%PageSize > 0 {
totalPage++ totalPage++
} }
var buf []byte var buf []byte
var offset int64 var offset int64
if page == 0 { if page == 0 {
page = totalPage page = totalPage
} }
buf = make([]byte, PageSize) buf = make([]byte, PageSize)
offset = (page - 1) * PageSize offset = (page - 1) * PageSize
// seek // seek
_, err = f.Seek(offset, io.SeekStart) _, err = f.Seek(offset, io.SeekStart)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
c.JSON(http.StatusOK, nginxLogPageResp{}) c.JSON(http.StatusOK, nginxLogPageResp{})
logger.Error(err) logger.Error(err)
return return
} }
n, err := f.Read(buf) n, err := f.Read(buf)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
c.JSON(http.StatusOK, nginxLogPageResp{}) c.JSON(http.StatusOK, nginxLogPageResp{})
logger.Error(err) logger.Error(err)
return return
} }
c.JSON(http.StatusOK, nginxLogPageResp{ c.JSON(http.StatusOK, nginxLogPageResp{
Page: page, Page: page,
Content: string(buf[:n]), Content: string(buf[:n]),
}) })
} }
func getLogPath(control *controlStruct) (logPath string, err error) { func getLogPath(control *controlStruct) (logPath string, err error) {
switch control.Type { switch control.Type {
case "site": case "site":
var config *nginx.NgxConfig var config *nginx.NgxConfig
path := nginx.GetConfPath("sites-available", control.ConfName) path := nginx.GetConfPath("sites-available", control.ConfName)
config, err = nginx.ParseNgxConfig(path) config, err = nginx.ParseNgxConfig(path)
if err != nil { if err != nil {
err = errors.Wrap(err, "error parsing ngx config") err = errors.Wrap(err, "error parsing ngx config")
return return
} }
if control.ServerIdx >= len(config.Servers) { if control.ServerIdx >= len(config.Servers) {
err = errors.New("serverIdx out of range") err = errors.New("serverIdx out of range")
return return
} }
if control.DirectiveIdx >= len(config.Servers[control.ServerIdx].Directives) { if control.DirectiveIdx >= len(config.Servers[control.ServerIdx].Directives) {
err = errors.New("DirectiveIdx out of range") err = errors.New("DirectiveIdx out of range")
return return
} }
directive := config.Servers[control.ServerIdx].Directives[control.DirectiveIdx] directive := config.Servers[control.ServerIdx].Directives[control.DirectiveIdx]
switch directive.Directive { switch directive.Directive {
case "access_log", "error_log": case "access_log", "error_log":
// ok // ok
default: default:
err = errors.New("directive.Params neither access_log nor error_log") err = errors.New("directive.Params neither access_log nor error_log")
return return
} }
if directive.Params == "" { if directive.Params == "" {
err = errors.New("directive.Params is empty") err = errors.New("directive.Params is empty")
return return
} }
// fix: access_log /var/log/test.log main; // fix: access_log /var/log/test.log main;
p := strings.Split(directive.Params, " ") p := strings.Split(directive.Params, " ")
if len(p) > 0 { if len(p) > 0 {
logPath = p[0] logPath = p[0]
} }
case "error": case "error":
path := nginx.GetErrorLogPath() path := nginx.GetErrorLogPath()
if path == "" { if path == "" {
err = errors.New("settings.NginxLogSettings.ErrorLogPath is empty," + err = errors.New("settings.NginxLogSettings.ErrorLogPath is empty," +
" refer to https://nginxui.com/guide/config-nginx.html for more information") " refer to https://nginxui.com/guide/config-nginx.html for more information")
return return
} }
logPath = path logPath = path
default: default:
path := nginx.GetAccessLogPath() path := nginx.GetAccessLogPath()
if path == "" { if path == "" {
err = errors.New("settings.NginxLogSettings.AccessLogPath is empty," + err = errors.New("settings.NginxLogSettings.AccessLogPath is empty," +
" refer to https://nginxui.com/guide/config-nginx.html for more information") " refer to https://nginxui.com/guide/config-nginx.html for more information")
return return
} }
logPath = path logPath = path
} }
return return
} }
func tailNginxLog(ws *websocket.Conn, controlChan chan controlStruct, errChan chan error) { func tailNginxLog(ws *websocket.Conn, controlChan chan controlStruct, errChan chan error) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
logger.Error(err) logger.Error(err)
return return
} }
}() }()
control := <-controlChan control := <-controlChan
for { for {
logPath, err := getLogPath(&control) logPath, err := getLogPath(&control)
if err != nil { if err != nil {
errChan <- err errChan <- err
return return
} }
seek := tail.SeekInfo{ seek := tail.SeekInfo{
Offset: 0, Offset: 0,
Whence: io.SeekEnd, Whence: io.SeekEnd,
} }
stat, err := os.Stat(logPath) stat, err := os.Stat(logPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
errChan <- errors.New("[error] log path not exists " + logPath) errChan <- errors.New("[error] log path not exists " + logPath)
return return
} }
if !stat.Mode().IsRegular() { if !stat.Mode().IsRegular() {
errChan <- errors.New("[error] " + logPath + " is not a regular file. " + errChan <- errors.New("[error] " + logPath + " is not a regular file. " +
"If you are using nginx-ui in docker container, please refer to " + "If you are using nginx-ui in docker container, please refer to " +
"https://nginxui.com/zh_CN/guide/config-nginx-log.html for more information.") "https://nginxui.com/zh_CN/guide/config-nginx-log.html for more information.")
return return
} }
// Create a tail // Create a tail
t, err := tail.TailFile(logPath, tail.Config{Follow: true, t, err := tail.TailFile(logPath, tail.Config{Follow: true,
ReOpen: true, Location: &seek}) ReOpen: true, Location: &seek})
if err != nil { if err != nil {
errChan <- errors.Wrap(err, "error tailing log") errChan <- errors.Wrap(err, "error tailing log")
return return
} }
for { for {
var next = false var next = false
select { select {
case line := <-t.Lines: case line := <-t.Lines:
// Print the text of each received line // Print the text of each received line
if line == nil { if line == nil {
continue continue
} }
err = ws.WriteMessage(websocket.TextMessage, []byte(line.Text)) err = ws.WriteMessage(websocket.TextMessage, []byte(line.Text))
if err != nil && websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) { if err != nil && websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) {
errChan <- errors.Wrap(err, "error tailNginxLog write message") errChan <- errors.Wrap(err, "error tailNginxLog write message")
return return
} }
case control = <-controlChan: case control = <-controlChan:
next = true next = true
break break
} }
if next { if next {
break break
} }
} }
} }
} }
func handleLogControl(ws *websocket.Conn, controlChan chan controlStruct, errChan chan error) { func handleLogControl(ws *websocket.Conn, controlChan chan controlStruct, errChan chan error) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
logger.Error(err) logger.Error(err)
return return
} }
}() }()
for { for {
msgType, payload, err := ws.ReadMessage() msgType, payload, err := ws.ReadMessage()
if err != nil && websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) { if err != nil && websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure) {
errChan <- errors.Wrap(err, "error handleLogControl read message") errChan <- errors.Wrap(err, "error handleLogControl read message")
return return
} }
if msgType != websocket.TextMessage { if msgType != websocket.TextMessage {
errChan <- errors.New("error handleLogControl message type") errChan <- errors.New("error handleLogControl message type")
return return
} }
var msg controlStruct var msg controlStruct
err = json.Unmarshal(payload, &msg) err = json.Unmarshal(payload, &msg)
if err != nil { if err != nil {
errChan <- errors.Wrap(err, "error ReadWsAndWritePty json.Unmarshal") errChan <- errors.Wrap(err, "error ReadWsAndWritePty json.Unmarshal")
return return
} }
controlChan <- msg controlChan <- msg
} }
} }
func Log(c *gin.Context) { func Log(c *gin.Context) {
var upGrader = websocket.Upgrader{ var upGrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
return true return true
}, },
} }
// upgrade http to websocket // upgrade http to websocket
ws, err := upGrader.Upgrade(c.Writer, c.Request, nil) ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
logger.Error(err) logger.Error(err)
return return
} }
defer ws.Close() defer ws.Close()
errChan := make(chan error, 1) errChan := make(chan error, 1)
controlChan := make(chan controlStruct, 1) controlChan := make(chan controlStruct, 1)
go tailNginxLog(ws, controlChan, errChan) go tailNginxLog(ws, controlChan, errChan)
go handleLogControl(ws, controlChan, errChan) go handleLogControl(ws, controlChan, errChan)
if err = <-errChan; err != nil { if err = <-errChan; err != nil {
logger.Error(err) logger.Error(err)
_ = ws.WriteMessage(websocket.TextMessage, []byte(err.Error())) _ = ws.WriteMessage(websocket.TextMessage, []byte(err.Error()))
return return
} }
} }

View file

@ -1,134 +1,123 @@
package openai package openai
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"github.com/0xJacky/Nginx-UI/api" "github.com/0xJacky/Nginx-UI/api"
"github.com/0xJacky/Nginx-UI/settings" "github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
) )
const ChatGPTInitPrompt = "You are a assistant who can help users write and optimise the configurations of Nginx, the first user message contains the content of the configuration file which is currently opened by the user and the current language code(CLC). You suppose to use the language corresponding to the CLC to give the first reply. Later the language environment depends on the user message. The first reply should involve the key information of the file and ask user what can you help them." const ChatGPTInitPrompt = "You are a assistant who can help users write and optimise the configurations of Nginx, the first user message contains the content of the configuration file which is currently opened by the user and the current language code(CLC). You suppose to use the language corresponding to the CLC to give the first reply. Later the language environment depends on the user message. The first reply should involve the key information of the file and ask user what can you help them."
func MakeChatCompletionRequest(c *gin.Context) { func MakeChatCompletionRequest(c *gin.Context) {
var json struct { var json struct {
Messages []openai.ChatCompletionMessage `json:"messages"` Messages []openai.ChatCompletionMessage `json:"messages"`
} }
if !api.BindAndValid(c, &json) { if !api.BindAndValid(c, &json) {
return return
} }
messages := []openai.ChatCompletionMessage{ messages := []openai.ChatCompletionMessage{
{ {
Role: openai.ChatMessageRoleSystem, Role: openai.ChatMessageRoleSystem,
Content: ChatGPTInitPrompt, Content: ChatGPTInitPrompt,
}, },
} }
messages = append(messages, json.Messages...) messages = append(messages, json.Messages...)
// sse server // sse server
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
if settings.OpenAISettings.Token == "" { config := openai.DefaultConfig(settings.OpenAISettings.Token)
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", gin.H{
"type": "error",
"content": "[Error] OpenAI token is empty",
})
return false
})
return
}
config := openai.DefaultConfig(settings.OpenAISettings.Token) if settings.OpenAISettings.Proxy != "" {
proxyUrl, err := url.Parse(settings.OpenAISettings.Proxy)
if err != nil {
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", gin.H{
"type": "error",
"content": err.Error(),
})
return false
})
return
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
config.HTTPClient = &http.Client{
Transport: transport,
}
}
if settings.OpenAISettings.Proxy != "" { if settings.OpenAISettings.BaseUrl != "" {
proxyUrl, err := url.Parse(settings.OpenAISettings.Proxy) config.BaseURL = settings.OpenAISettings.BaseUrl
if err != nil { }
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", gin.H{
"type": "error",
"content": err.Error(),
})
return false
})
return
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
config.HTTPClient = &http.Client{
Transport: transport,
}
}
if settings.OpenAISettings.BaseUrl != "" { openaiClient := openai.NewClientWithConfig(config)
config.BaseURL = settings.OpenAISettings.BaseUrl ctx := context.Background()
}
openaiClient := openai.NewClientWithConfig(config) req := openai.ChatCompletionRequest{
ctx := context.Background() Model: settings.OpenAISettings.Model,
Messages: messages,
Stream: true,
}
stream, err := openaiClient.CreateChatCompletionStream(ctx, req)
if err != nil {
fmt.Printf("CompletionStream error: %v\n", err)
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", gin.H{
"type": "error",
"content": err.Error(),
})
return false
})
return
}
defer stream.Close()
msgChan := make(chan string)
go func() {
defer close(msgChan)
for {
response, err := stream.Recv()
if errors.Is(err, io.EOF) {
fmt.Println()
return
}
req := openai.ChatCompletionRequest{ if err != nil {
Model: settings.OpenAISettings.Model, fmt.Printf("Stream error: %v\n", err)
Messages: messages, return
Stream: true, }
}
stream, err := openaiClient.CreateChatCompletionStream(ctx, req)
if err != nil {
fmt.Printf("CompletionStream error: %v\n", err)
c.Stream(func(w io.Writer) bool {
c.SSEvent("message", gin.H{
"type": "error",
"content": err.Error(),
})
return false
})
return
}
defer stream.Close()
msgChan := make(chan string)
go func() {
defer close(msgChan)
for {
response, err := stream.Recv()
if errors.Is(err, io.EOF) {
fmt.Println()
return
}
if err != nil { message := fmt.Sprintf("%s", response.Choices[0].Delta.Content)
fmt.Printf("Stream error: %v\n", err) fmt.Printf("%s", message)
return _ = os.Stdout.Sync()
}
message := fmt.Sprintf("%s", response.Choices[0].Delta.Content) msgChan <- message
fmt.Printf("%s", message) }
_ = os.Stdout.Sync() }()
msgChan <- message c.Stream(func(w io.Writer) bool {
} if m, ok := <-msgChan; ok {
}() c.SSEvent("message", gin.H{
"type": "message",
c.Stream(func(w io.Writer) bool { "content": m,
if m, ok := <-msgChan; ok { })
c.SSEvent("message", gin.H{ return true
"type": "message", }
"content": m, return false
}) })
return true
}
return false
})
} }

1
app/components.d.ts vendored
View file

@ -8,6 +8,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert'] AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
AAvatar: typeof import('ant-design-vue/es')['Avatar'] AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge'] ABadge: typeof import('ant-design-vue/es')['Badge']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb'] ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']

View file

@ -4,25 +4,30 @@ import type { Settings } from '@/views/preference/typedef'
const data: Settings = inject('data')! const data: Settings = inject('data')!
const errors: Record<string, Record<string, string>> = inject('errors') as Record<string, Record<string, string>> const errors: Record<string, Record<string, string>> = inject('errors') as Record<string, Record<string, string>>
const models = shallowRef([
{
value: 'gpt-4-1106-preview',
},
{
value: 'gpt-4',
},
{
value: 'gpt-4-32k',
},
{
value: 'gpt-3.5-turbo',
},
])
</script> </script>
<template> <template>
<AForm layout="vertical"> <AForm layout="vertical">
<AFormItem :label="$gettext('ChatGPT Model')"> <AFormItem :label="$gettext('Model')">
<ASelect v-model:value="data.openai.model"> <AAutoComplete
<ASelectOption value="gpt-4-1106-preview"> v-model:value="data.openai.model"
{{ $gettext('GPT-4-Turbo') }} :options="models"
</ASelectOption> />
<ASelectOption value="gpt-4">
{{ $gettext('GPT-4') }}
</ASelectOption>
<ASelectOption value="gpt-4-32k">
{{ $gettext('GPT-4-32K') }}
</ASelectOption>
<ASelectOption value="gpt-3.5-turbo">
{{ $gettext('GPT-3.5-Turbo') }}
</ASelectOption>
</ASelect>
</AFormItem> </AFormItem>
<AFormItem <AFormItem
:label="$gettext('API Base Url')" :label="$gettext('API Base Url')"