diff --git a/app.go b/app.go index 9207300..b1d3d56 100644 --- a/app.go +++ b/app.go @@ -2,35 +2,29 @@ package main import ( "context" + _ "embed" "encoding/base64" "encoding/json" "errors" "github.com/PuerkitoBio/goquery" "github.com/go-resty/resty/v2" + goversion "github.com/hashicorp/go-version" "github.com/life4/genesis/slices" "github.com/microcosm-cc/bluemonday" "github.com/pkoukk/tiktoken-go" - "github.com/sashabaranov/go-openai" "github.com/wailsapp/wails/v2/pkg/runtime" - "io" "log/slog" "os" "path/filepath" "regexp" "strings" - "sydneyqt/sydney" "sydneyqt/util" "sync" "time" ) -const ( - _ = iota - AskTypeSydney - AskTypeOpenAI -) - -type AskType int +//go:embed version.txt +var version string // App struct type App struct { @@ -50,226 +44,6 @@ func (a *App) startup(ctx context.Context) { a.ctx = ctx } -type AskOptions struct { - Type AskType `json:"type"` - OpenAIBackend string `json:"openai_backend"` - ChatContext string `json:"chat_context"` - Prompt string `json:"prompt"` - ImageURL string `json:"image_url"` -} - -const ( - ChatFinishResultErrTypeMessageRevoke = "message_revoke" - ChatFinishResultErrTypeMessageFiltered = "message_filtered" - ChatFinishResultErrTypeOthers = "others" -) - -type ChatFinishResult struct { - Success bool `json:"success"` - ErrType string `json:"err_type"` - ErrMsg string `json:"err_msg"` -} - -const ( - EventConversationCreated = "chat_conversation_created" - EventChatAppend = "chat_append" - EventChatFinish = "chat_finish" - EventChatSuggestedResponses = "chat_suggested_responses" - EventChatToken = "chat_token" -) - -const ( - EventChatStop = "chat_stop" -) - -func (a *App) Dummy1() ChatFinishResult { - return ChatFinishResult{} -} -func (a *App) createSydney() (*sydney.Sydney, error) { - currentWorkspace, err := a.settings.config.GetCurrentWorkspace() - if err != nil { - return nil, err - } - cookies, err := util.ReadCookiesFile() - if err != nil { - return nil, err - } - return sydney.NewSydney(a.debug, cookies, a.settings.config.Proxy, - currentWorkspace.ConversationStyle, currentWorkspace.Locale, a.settings.config.WssDomain, - a.settings.config.CreateConversationURL, - currentWorkspace.NoSearch), nil -} - -func (a *App) askSydney(options AskOptions) { - slog.Info("askSydney called", "options", options) - chatFinishResult := ChatFinishResult{ - Success: true, - ErrType: "", - ErrMsg: "", - } - defer func() { - slog.Info("invoke EventChatFinish", "result", chatFinishResult) - runtime.EventsEmit(a.ctx, EventChatFinish, chatFinishResult) - }() - sydneyIns, err := a.createSydney() - if err != nil { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeOthers, - ErrMsg: err.Error(), - } - return - } - conversation, err := sydneyIns.CreateConversation() - if err != nil { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeOthers, - ErrMsg: err.Error(), - } - return - } - runtime.EventsEmit(a.ctx, EventConversationCreated) - stopCtx, cancel := util.CreateCancelContext() - defer cancel() - runtime.EventsOn(a.ctx, EventChatStop, func(optionalData ...interface{}) { - slog.Info("Received EventChatStop") - cancel() - }) - ch := sydneyIns.AskStream(sydney.AskStreamOptions{ - StopCtx: stopCtx, - Conversation: conversation, - Prompt: options.Prompt, - WebpageContext: options.ChatContext, - ImageURL: options.ImageURL, - }) - chatAppend := func(text string) { - runtime.EventsEmit(a.ctx, EventChatAppend, text) - } - fullMessageText := "" - lastMessageType := "" - for msg := range ch { - textToAppend := "" - switch msg.Type { - case sydney.MessageTypeSuggestedResponses: - runtime.EventsEmit(a.ctx, EventChatSuggestedResponses, msg.Text) - case sydney.MessageTypeError: - if errors.Is(msg.Error, sydney.ErrMessageRevoke) { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeMessageRevoke, - ErrMsg: msg.Error.Error(), - } - } else if errors.Is(msg.Error, sydney.ErrMessageFiltered) { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeMessageFiltered, - ErrMsg: msg.Error.Error(), - } - } else { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeOthers, - ErrMsg: msg.Error.Error(), - } - } - return - case sydney.MessageTypeMessageText: - fullMessageText += msg.Text - runtime.EventsEmit(a.ctx, EventChatToken, a.CountToken(fullMessageText)) - textToAppend = msg.Text - default: - textToAppend = msg.Text + "\n\n" - } - if textToAppend != "" { - if lastMessageType != msg.Type { - textToAppend = "[assistant](#" + msg.Type + ")\n" + textToAppend - } - chatAppend(textToAppend) - } - lastMessageType = msg.Type - } -} -func (a *App) askOpenAI(options AskOptions) { - chatFinishResult := ChatFinishResult{ - Success: true, - ErrType: "", - ErrMsg: "", - } - handleErr := func(err error) { - chatFinishResult = ChatFinishResult{ - Success: false, - ErrType: ChatFinishResultErrTypeOthers, - ErrMsg: err.Error(), - } - } - defer func() { - slog.Info("invoke EventChatFinish", "result", chatFinishResult) - runtime.EventsEmit(a.ctx, EventChatFinish, chatFinishResult) - }() - backend, err := slices.Find(a.settings.config.OpenAIBackends, func(el OpenAIBackend) bool { - return el.Name == options.OpenAIBackend - }) - if err != nil { - handleErr(err) - return - } - hClient, err := util.MakeHTTPClient(a.settings.config.Proxy, 0) - if err != nil { - handleErr(err) - return - } - config := openai.DefaultConfig(backend.OpenaiKey) - config.BaseURL = backend.OpenaiEndpoint - config.HTTPClient = hClient - client := openai.NewClientWithConfig(config) - messages := util.GetOpenAIChatMessages(options.ChatContext) - slog.Info("Get chat messages", "messages", messages) - messages = append(messages, openai.ChatCompletionMessage{ - Role: "user", - Content: options.Prompt, - }) - stream, err := client.CreateChatCompletionStream(context.Background(), openai.ChatCompletionRequest{ - Model: backend.OpenaiShortModel, - Messages: messages, - Temperature: backend.OpenaiTemperature, - }) - if err != nil { - handleErr(err) - return - } - runtime.EventsEmit(a.ctx, EventConversationCreated) - defer stream.Close() - fullMessage := "" - replied := false - for { - response, err := stream.Recv() - if errors.Is(err, io.EOF) { - slog.Info("openai chat completed") - return - } - if err != nil { - handleErr(err) - return - } - textToAppend := response.Choices[0].Delta.Content - fullMessage += textToAppend - runtime.EventsEmit(a.ctx, EventChatToken, a.CountToken(fullMessage)) - if !replied { - textToAppend = "[assistant](#message)\n" + textToAppend - replied = true - } - runtime.EventsEmit(a.ctx, EventChatAppend, textToAppend) - } -} -func (a *App) AskAI(options AskOptions) { - if options.Type == AskTypeSydney { - a.askSydney(options) - } else if options.Type == AskTypeOpenAI { - a.askOpenAI(options) - } -} - var tk *tiktoken.Tiktoken var initTkFunc = sync.OnceFunc(func() { slog.Info("Init tiktoken") @@ -422,3 +196,55 @@ func (a *App) GetUser() (string, error) { } return sydneyIns.GetUser() } + +type CheckUpdateResult struct { + NeedUpdate bool `json:"need_update"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseURL string `json:"release_url"` + ReleaseNote string `json:"release_note"` +} + +func (a *App) CheckUpdate() (CheckUpdateResult, error) { + empty := CheckUpdateResult{} + hClient, err := util.MakeHTTPClient(a.settings.config.Proxy, 0) + if err != nil { + return empty, err + } + client := resty.New().SetTimeout(15 * time.Second).SetTransport(hClient.Transport) + resp, err := client.R().Get("https://api.github.com/repos/juzeon/SydneyQt/releases") + if err != nil { + return empty, err + } + var githubRelease []GithubReleaseResponse + err = json.Unmarshal(resp.Body(), &githubRelease) + if err != nil { + return empty, err + } + if len(githubRelease) == 0 { + return empty, errors.New("no release found") + } + currentVersion, err := goversion.NewVersion(strings.TrimSpace(version)) + if err != nil { + return empty, err + } + latestVersionStr := githubRelease[0].TagName + if strings.HasPrefix(latestVersionStr, "v") { + latestVersionStr = latestVersionStr[1:] + } + latestVersion, err := goversion.NewVersion(latestVersionStr) + if err != nil { + return empty, err + } + needUpdate := false + if latestVersion.GreaterThan(currentVersion) { + needUpdate = true + } + return CheckUpdateResult{ + NeedUpdate: needUpdate, + CurrentVersion: currentVersion.String(), + LatestVersion: latestVersion.String(), + ReleaseURL: githubRelease[0].HtmlUrl, + ReleaseNote: githubRelease[0].Body, + }, nil +} diff --git a/app_chatbot.go b/app_chatbot.go new file mode 100644 index 0000000..02dc7fb --- /dev/null +++ b/app_chatbot.go @@ -0,0 +1,241 @@ +package main + +import ( + "context" + "errors" + "github.com/life4/genesis/slices" + "github.com/sashabaranov/go-openai" + "github.com/wailsapp/wails/v2/pkg/runtime" + "io" + "log/slog" + "sydneyqt/sydney" + "sydneyqt/util" +) + +const ( + _ = iota + AskTypeSydney + AskTypeOpenAI +) + +type AskType int + +type AskOptions struct { + Type AskType `json:"type"` + OpenAIBackend string `json:"openai_backend"` + ChatContext string `json:"chat_context"` + Prompt string `json:"prompt"` + ImageURL string `json:"image_url"` +} + +const ( + ChatFinishResultErrTypeMessageRevoke = "message_revoke" + ChatFinishResultErrTypeMessageFiltered = "message_filtered" + ChatFinishResultErrTypeOthers = "others" +) + +type ChatFinishResult struct { + Success bool `json:"success"` + ErrType string `json:"err_type"` + ErrMsg string `json:"err_msg"` +} + +const ( + EventConversationCreated = "chat_conversation_created" + EventChatAppend = "chat_append" + EventChatFinish = "chat_finish" + EventChatSuggestedResponses = "chat_suggested_responses" + EventChatToken = "chat_token" +) + +const ( + EventChatStop = "chat_stop" +) + +func (a *App) Dummy1() ChatFinishResult { + return ChatFinishResult{} +} +func (a *App) createSydney() (*sydney.Sydney, error) { + currentWorkspace, err := a.settings.config.GetCurrentWorkspace() + if err != nil { + return nil, err + } + cookies, err := util.ReadCookiesFile() + if err != nil { + return nil, err + } + return sydney.NewSydney(a.debug, cookies, a.settings.config.Proxy, + currentWorkspace.ConversationStyle, currentWorkspace.Locale, a.settings.config.WssDomain, + a.settings.config.CreateConversationURL, + currentWorkspace.NoSearch), nil +} + +func (a *App) askSydney(options AskOptions) { + slog.Info("askSydney called", "options", options) + chatFinishResult := ChatFinishResult{ + Success: true, + ErrType: "", + ErrMsg: "", + } + defer func() { + slog.Info("invoke EventChatFinish", "result", chatFinishResult) + runtime.EventsEmit(a.ctx, EventChatFinish, chatFinishResult) + }() + sydneyIns, err := a.createSydney() + if err != nil { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeOthers, + ErrMsg: err.Error(), + } + return + } + conversation, err := sydneyIns.CreateConversation() + if err != nil { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeOthers, + ErrMsg: err.Error(), + } + return + } + runtime.EventsEmit(a.ctx, EventConversationCreated) + stopCtx, cancel := util.CreateCancelContext() + defer cancel() + runtime.EventsOn(a.ctx, EventChatStop, func(optionalData ...interface{}) { + slog.Info("Received EventChatStop") + cancel() + }) + ch := sydneyIns.AskStream(sydney.AskStreamOptions{ + StopCtx: stopCtx, + Conversation: conversation, + Prompt: options.Prompt, + WebpageContext: options.ChatContext, + ImageURL: options.ImageURL, + }) + chatAppend := func(text string) { + runtime.EventsEmit(a.ctx, EventChatAppend, text) + } + fullMessageText := "" + lastMessageType := "" + for msg := range ch { + textToAppend := "" + switch msg.Type { + case sydney.MessageTypeSuggestedResponses: + runtime.EventsEmit(a.ctx, EventChatSuggestedResponses, msg.Text) + case sydney.MessageTypeError: + if errors.Is(msg.Error, sydney.ErrMessageRevoke) { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeMessageRevoke, + ErrMsg: msg.Error.Error(), + } + } else if errors.Is(msg.Error, sydney.ErrMessageFiltered) { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeMessageFiltered, + ErrMsg: msg.Error.Error(), + } + } else { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeOthers, + ErrMsg: msg.Error.Error(), + } + } + return + case sydney.MessageTypeMessageText: + fullMessageText += msg.Text + runtime.EventsEmit(a.ctx, EventChatToken, a.CountToken(fullMessageText)) + textToAppend = msg.Text + default: + textToAppend = msg.Text + "\n\n" + } + if textToAppend != "" { + if lastMessageType != msg.Type { + textToAppend = "[assistant](#" + msg.Type + ")\n" + textToAppend + } + chatAppend(textToAppend) + } + lastMessageType = msg.Type + } +} +func (a *App) askOpenAI(options AskOptions) { + chatFinishResult := ChatFinishResult{ + Success: true, + ErrType: "", + ErrMsg: "", + } + handleErr := func(err error) { + chatFinishResult = ChatFinishResult{ + Success: false, + ErrType: ChatFinishResultErrTypeOthers, + ErrMsg: err.Error(), + } + } + defer func() { + slog.Info("invoke EventChatFinish", "result", chatFinishResult) + runtime.EventsEmit(a.ctx, EventChatFinish, chatFinishResult) + }() + backend, err := slices.Find(a.settings.config.OpenAIBackends, func(el OpenAIBackend) bool { + return el.Name == options.OpenAIBackend + }) + if err != nil { + handleErr(err) + return + } + hClient, err := util.MakeHTTPClient(a.settings.config.Proxy, 0) + if err != nil { + handleErr(err) + return + } + config := openai.DefaultConfig(backend.OpenaiKey) + config.BaseURL = backend.OpenaiEndpoint + config.HTTPClient = hClient + client := openai.NewClientWithConfig(config) + messages := util.GetOpenAIChatMessages(options.ChatContext) + slog.Info("Get chat messages", "messages", messages) + messages = append(messages, openai.ChatCompletionMessage{ + Role: "user", + Content: options.Prompt, + }) + stream, err := client.CreateChatCompletionStream(context.Background(), openai.ChatCompletionRequest{ + Model: backend.OpenaiShortModel, + Messages: messages, + Temperature: backend.OpenaiTemperature, + }) + if err != nil { + handleErr(err) + return + } + runtime.EventsEmit(a.ctx, EventConversationCreated) + defer stream.Close() + fullMessage := "" + replied := false + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + slog.Info("openai chat completed") + return + } + if err != nil { + handleErr(err) + return + } + textToAppend := response.Choices[0].Delta.Content + fullMessage += textToAppend + runtime.EventsEmit(a.ctx, EventChatToken, a.CountToken(fullMessage)) + if !replied { + textToAppend = "[assistant](#message)\n" + textToAppend + replied = true + } + runtime.EventsEmit(a.ctx, EventChatAppend, textToAppend) + } +} +func (a *App) AskAI(options AskOptions) { + if options.Type == AskTypeSydney { + a.askSydney(options) + } else if options.Type == AskTypeOpenAI { + a.askOpenAI(options) + } +} diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index c027fbb..393f608 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -8,8 +8,12 @@ import {computed, onMounted, ref} from "vue" import {useTheme} from "vuetify" import {main} from "../../wailsjs/go/models" import {shadeColor} from "../helper" +import {CheckUpdate} from "../../wailsjs/go/main/App" +import {marked} from "marked" +import {BrowserOpenURL} from "../../wailsjs/runtime" import Preset = main.Preset import OpenAIBackend = main.OpenAIBackend +import CheckUpdateResult = main.CheckUpdateResult let theme = useTheme() let router = useRouter() @@ -22,6 +26,7 @@ onMounted(() => { activePreset.value = config.value.presets[0] activeOpenaiBackendName.value = config.value.open_ai_backends[0].name }) + checkUpdate() }) let fontStyle = computed(() => { return { @@ -206,6 +211,27 @@ function onChangeThemeColor(val: string) { function checkThemeColor(val: string): boolean { return /^#[0-9A-Fa-f]{6}$/i.test(val) } + +let versionResult = ref(undefined) +let versionError = ref('') +let versionLoading = ref(false) +let versionDialog = ref(false) + +function checkUpdate() { + versionLoading.value = true + versionError.value = '' + versionResult.value = undefined + CheckUpdate().then(res => { + versionResult.value = res + if (versionResult.value.need_update) { + versionDialog.value = true + } + }).catch(err => { + versionError.value = err.toString() + }).finally(() => { + versionLoading.value = false + }) +}