Files

1462 lines
38 KiB
Go

package chatlog
import (
"fmt"
"net"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/sjzar/chatlog/internal/chatlog/conf"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/ui/footer"
"github.com/sjzar/chatlog/internal/ui/form"
"github.com/sjzar/chatlog/internal/ui/help"
"github.com/sjzar/chatlog/internal/ui/infobar"
"github.com/sjzar/chatlog/internal/ui/menu"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/util"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
RefreshInterval = 1000 * time.Millisecond
)
type settingsKey string
const (
settingKeySpeechProvider settingsKey = "speech_provider"
settingKeyLocalServiceURL settingsKey = "local_service_url"
settingKeyHTTPAddr settingsKey = "http_addr"
settingKeyToggleListen settingsKey = "toggle_listen"
settingKeyWorkDir settingsKey = "work_dir"
settingKeyDataDir settingsKey = "data_dir"
settingKeyDataKey settingsKey = "data_key"
settingKeyImgKey settingsKey = "img_key"
settingKeyOpenAIAPIKey settingsKey = "openai_api_key"
settingKeyOpenAIBaseURL settingsKey = "openai_base_url"
settingKeyOpenAIProxy settingsKey = "openai_proxy"
settingKeyOpenAITimeout settingsKey = "openai_timeout"
settingKeyWhisperModel settingsKey = "whisper_model"
settingKeyWhisperThreads settingsKey = "whisper_threads"
)
type App struct {
*tview.Application
ctx *ctx.Context
m *Manager
stopRefresh chan struct{}
// page
mainPages *tview.Pages
infoBar *infobar.InfoBar
tabPages *tview.Pages
footer *footer.Footer
// tab
menu *menu.Menu
help *help.Help
settingsMenu *menu.SubMenu
settingsItems []*menu.Item
settingsItemMap map[settingsKey]*menu.Item
activeTab int
tabCount int
}
func NewApp(ctx *ctx.Context, m *Manager) *App {
app := &App{
ctx: ctx,
m: m,
Application: tview.NewApplication(),
mainPages: tview.NewPages(),
infoBar: infobar.New(),
tabPages: tview.NewPages(),
footer: footer.New(),
menu: menu.New("主菜单"),
help: help.New(),
settingsItemMap: make(map[settingsKey]*menu.Item),
}
app.initMenu()
app.initSettingsTab()
app.updateMenuItemsState()
return app
}
func (a *App) Run() error {
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(a.infoBar, infobar.InfoBarViewHeight, 0, false).
AddItem(a.tabPages, 0, 1, true).
AddItem(a.footer, 1, 1, false)
a.mainPages.AddPage("main", flex, true, true)
a.tabPages.
AddPage("0", a.menu, true, true).
AddPage("1", a.help, true, false)
if a.settingsMenu != nil {
a.tabPages.AddPage("2", a.settingsMenu, true, false)
a.tabCount = 3
} else {
a.tabCount = 2
}
a.SetInputCapture(a.inputCapture)
go a.refresh()
if err := a.SetRoot(a.mainPages, true).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}
func (a *App) Stop() {
// 添加一个通道用于停止刷新 goroutine
if a.stopRefresh != nil {
close(a.stopRefresh)
}
a.Application.Stop()
}
func (a *App) updateMenuItemsState() {
// 查找并更新自动解密菜单项
for _, item := range a.menu.GetItems() {
// 更新自动解密菜单项
if item.Index == 5 {
if a.ctx.AutoDecrypt {
item.Name = "停止自动解密"
item.Description = "停止监控数据目录更新,不再自动解密新增数据"
} else {
item.Name = "开启自动解密"
item.Description = "监控数据目录更新,自动解密新增数据"
}
}
// 更新HTTP服务菜单项
if item.Index == 4 {
if a.ctx.HTTPEnabled {
item.Name = "停止 HTTP 服务"
item.Description = "停止本地 HTTP & MCP 服务器"
} else {
item.Name = "启动 HTTP 服务"
item.Description = "启动本地 HTTP & MCP 服务器"
}
}
a.refreshSettingsMenu()
}
}
func (a *App) switchTab(step int) {
index := (a.activeTab + step) % a.tabCount
if index < 0 {
index = a.tabCount - 1
}
a.activeTab = index
a.tabPages.SwitchToPage(fmt.Sprint(a.activeTab))
switch a.activeTab {
case 0:
a.SetFocus(a.menu)
case 1:
a.SetFocus(a.help)
case 2:
if a.settingsMenu != nil {
a.SetFocus(a.settingsMenu)
}
}
}
func (a *App) focusSettingsTab() {
if a.settingsMenu == nil {
return
}
a.activeTab = 2
a.tabPages.SwitchToPage("2")
a.refreshSettingsMenu()
a.SetFocus(a.settingsMenu)
}
func (a *App) refresh() {
tick := time.NewTicker(RefreshInterval)
defer tick.Stop()
for {
select {
case <-a.stopRefresh:
return
case <-tick.C:
if a.ctx.AutoDecrypt || a.ctx.HTTPEnabled {
a.m.RefreshSession()
}
a.infoBar.UpdateAccount(a.ctx.Account)
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.FullVersion, a.ctx.ExePath)
a.infoBar.UpdateStatus(a.ctx.Status)
a.infoBar.UpdateDataKey(a.ctx.DataKey)
a.infoBar.UpdateImageKey(a.ctx.ImgKey)
a.infoBar.UpdatePlatform(a.ctx.Platform)
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
a.infoBar.UpdateWorkUsageDir(a.ctx.WorkUsage, a.ctx.WorkDir)
if a.ctx.LastSession.Unix() > 1000000000 {
a.infoBar.UpdateSession(a.ctx.LastSession.Format("2006-01-02 15:04:05"))
}
if a.ctx.HTTPEnabled {
addr := a.ctx.HTTPAddr
h, _, err := net.SplitHostPort(addr)
if err != nil { // Fallback if malformed
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", addr))
} else {
h = strings.TrimSpace(h)
if h == "0.0.0.0" || h == "::" || h == "[::]" || h == "" {
lan := util.ComposeLANURL(addr)
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", lan))
} else {
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", addr))
}
}
} else {
a.infoBar.UpdateHTTPServer("[未启动]")
}
if a.ctx.AutoDecrypt {
a.infoBar.UpdateAutoDecrypt("[green][已开启][white]")
} else {
a.infoBar.UpdateAutoDecrypt("[未开启]")
}
a.Draw()
}
}
}
func (a *App) inputCapture(event *tcell.EventKey) *tcell.EventKey {
// 如果当前页面不是主页面,ESC 键返回主页面
if a.mainPages.HasPage("submenu") && event.Key() == tcell.KeyEscape {
a.mainPages.RemovePage("submenu")
a.mainPages.SwitchToPage("main")
return nil
}
if a.tabPages.HasFocus() {
switch event.Key() {
case tcell.KeyLeft:
a.switchTab(-1)
return nil
case tcell.KeyRight:
a.switchTab(1)
return nil
}
}
switch event.Key() {
case tcell.KeyCtrlC:
a.Stop()
}
return event
}
func (a *App) initMenu() {
getDataKey := &menu.Item{
Index: 2,
Name: "获取密钥",
Description: "从进程获取数据密钥 & 图片密钥",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
if runtime.GOOS == "darwin" {
modal.SetText("获取密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
} else {
modal.SetText("获取密钥中...")
}
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
go func() {
err := a.m.GetDataKey()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 解密失败
modal.SetText("获取密钥失败: " + err.Error())
} else {
// 解密成功
modal.SetText("获取密钥成功")
a.refreshSettingsMenu()
}
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
},
}
decryptData := &menu.Item{
Index: 3,
Name: "解密数据",
Description: "解密数据文件",
Selected: func(i *menu.Item) {
// 创建一个没有按钮的模态框,显示"解密中..."
modal := tview.NewModal().
SetText("解密中...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台执行解密操作
go func() {
// 执行解密
err := a.m.DecryptDBFiles()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 解密失败
modal.SetText("解密失败: " + err.Error())
} else {
// 解密成功
modal.SetText("解密数据成功")
}
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
},
}
httpServer := &menu.Item{
Index: 4,
Name: "启动 HTTP 服务",
Description: "启动本地 HTTP & MCP 服务器",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
// 根据当前服务状态执行不同操作
if !a.ctx.HTTPEnabled {
// HTTP 服务未启动,启动服务
modal.SetText("正在启动 HTTP 服务...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台启动服务
go func() {
err := a.m.StartService()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 启动失败
modal.SetText("启动 HTTP 服务失败: " + err.Error())
} else {
// 启动成功
modal.SetText("已启动 HTTP 服务")
}
// 更改菜单项名称
a.updateMenuItemsState()
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
} else {
// HTTP 服务已启动,停止服务
modal.SetText("正在停止 HTTP 服务...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台停止服务
go func() {
err := a.m.StopService()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 停止失败
modal.SetText("停止 HTTP 服务失败: " + err.Error())
} else {
// 停止成功
modal.SetText("已停止 HTTP 服务")
}
// 更改菜单项名称
a.updateMenuItemsState()
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
}
},
}
autoDecrypt := &menu.Item{
Index: 5,
Name: "开启自动解密",
Description: "自动解密新增的数据文件",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
// 根据当前自动解密状态执行不同操作
if !a.ctx.AutoDecrypt {
// 自动解密未开启,开启自动解密
modal.SetText("正在开启自动解密...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台开启自动解密
go func() {
err := a.m.StartAutoDecrypt()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 开启失败
modal.SetText("开启自动解密失败: " + err.Error())
} else {
// 开启成功
if a.ctx.Version == 3 {
modal.SetText("已开启自动解密\n3.x版本数据文件更新不及时,有低延迟需求请使用4.0版本")
} else {
modal.SetText("已开启自动解密")
}
}
// 更改菜单项名称
a.updateMenuItemsState()
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
} else {
// 自动解密已开启,停止自动解密
modal.SetText("正在停止自动解密...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台停止自动解密
go func() {
err := a.m.StopAutoDecrypt()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 停止失败
modal.SetText("停止自动解密失败: " + err.Error())
} else {
// 停止成功
modal.SetText("已停止自动解密")
}
// 更改菜单项名称
a.updateMenuItemsState()
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
}
},
}
setting := &menu.Item{
Index: 6,
Name: "设置",
Description: "设置应用程序选项",
Selected: func(*menu.Item) {
a.focusSettingsTab()
},
}
selectAccount := &menu.Item{
Index: 7,
Name: "切换账号",
Description: "切换当前操作的账号,可以选择进程或历史账号",
Selected: a.selectAccountSelected,
}
a.menu.AddItem(getDataKey)
a.menu.AddItem(decryptData)
a.menu.AddItem(httpServer)
a.menu.AddItem(autoDecrypt)
a.menu.AddItem(setting)
a.menu.AddItem(selectAccount)
a.menu.AddItem(&menu.Item{
Index: 8,
Name: "退出",
Description: "退出程序",
Selected: func(i *menu.Item) {
a.Stop()
},
})
}
func (a *App) initSettingsTab() {
a.settingsMenu = menu.NewSubMenu("设置")
if a.settingsMenu == nil {
return
}
a.settingsMenu.SetCancelFunc(nil)
a.settingsItems = []*menu.Item{
a.newSettingsItem(1, "设置语音服务提供商", settingKeySpeechProvider, a.settingSpeechProvider),
a.newSettingsItem(2, "设置 Whisper.cpp 模型路径", settingKeyWhisperModel, a.settingWhisperModelPath),
a.newSettingsItem(3, "设置 Whisper.cpp 线程数", settingKeyWhisperThreads, a.settingWhisperThreads),
a.newSettingsItem(4, "设置本地语音服务地址", settingKeyLocalServiceURL, a.settingLocalServiceURL),
a.newSettingsItem(5, "设置 HTTP 服务地址", settingKeyHTTPAddr, a.settingHTTPPort),
a.newSettingsItem(6, "切换局域网监听", settingKeyToggleListen, a.toggleListen),
a.newSettingsItem(7, "设置工作目录", settingKeyWorkDir, a.settingWorkDir),
a.newSettingsItem(8, "设置数据目录", settingKeyDataDir, a.settingDataDir),
a.newSettingsItem(9, "设置数据密钥", settingKeyDataKey, a.settingDataKey),
a.newSettingsItem(10, "设置图片密钥", settingKeyImgKey, a.settingImgKey),
a.newSettingsItem(11, "设置 OpenAI API Key", settingKeyOpenAIAPIKey, a.settingOpenAIAPIKey),
a.newSettingsItem(12, "设置 OpenAI Base URL", settingKeyOpenAIBaseURL, a.settingOpenAIBaseURL),
a.newSettingsItem(13, "设置 OpenAI 代理", settingKeyOpenAIProxy, a.settingOpenAIProxy),
a.newSettingsItem(14, "设置 OpenAI 请求超时", settingKeyOpenAITimeout, a.settingOpenAITimeout),
}
a.settingsMenu.SetItems(a.settingsItems)
a.refreshSettingsMenu()
}
func (a *App) newSettingsItem(index int, name string, key settingsKey, action func()) *menu.Item {
item := &menu.Item{
Index: index,
Name: name,
Selected: func(*menu.Item) {
if action != nil {
action()
}
},
}
if a.settingsItemMap == nil {
a.settingsItemMap = make(map[settingsKey]*menu.Item)
}
a.settingsItemMap[key] = item
return item
}
func (a *App) refreshSettingsMenu() {
if a.settingsMenu == nil || len(a.settingsItems) == 0 {
return
}
speechCfg := a.ctx.GetSpeech()
providerLabel := "OpenAI 官方服务"
isWebService := false
if speechCfg != nil {
switch strings.ToLower(strings.TrimSpace(speechCfg.Provider)) {
case "webservice", "local", "docker", "http", "whisper-asr":
providerLabel = "本地 Docker Whisper"
isWebService = true
case "openai", "":
providerLabel = "OpenAI 官方服务"
case "whispercpp":
providerLabel = "Whisper.cpp 本地模型"
default:
providerLabel = speechCfg.Provider
}
}
if item := a.settingsItemMap[settingKeySpeechProvider]; item != nil {
item.Description = fmt.Sprintf("当前提供商: %s", providerLabel)
}
if item := a.settingsItemMap[settingKeyWhisperModel]; item != nil {
current := "未设置"
if speechCfg != nil {
trimmed := strings.TrimSpace(speechCfg.Model)
if trimmed != "" {
current = trimmed
}
if strings.ToLower(strings.TrimSpace(speechCfg.Provider)) != "whispercpp" {
current = current + " (当前提供商未启用)"
}
}
item.Description = fmt.Sprintf("当前模型路径: %s", current)
}
if item := a.settingsItemMap[settingKeyWhisperThreads]; item != nil {
threadsLabel := "默认"
if speechCfg != nil && speechCfg.Threads > 0 {
threadsLabel = strconv.Itoa(speechCfg.Threads)
}
if speechCfg != nil && strings.ToLower(strings.TrimSpace(speechCfg.Provider)) != "whispercpp" {
threadsLabel = threadsLabel + " (当前提供商未启用)"
}
item.Description = fmt.Sprintf("当前线程数: %s", threadsLabel)
}
if item := a.settingsItemMap[settingKeyLocalServiceURL]; item != nil {
fallback := "http://127.0.0.1:9000"
current := fallback
if speechCfg != nil {
current = formatPathWithFallback(speechCfg.ServiceURL, fallback)
}
suffix := ""
if speechCfg != nil && !isWebService {
suffix = " (备用)"
}
item.Description = fmt.Sprintf("当前服务地址: %s%s", current, suffix)
}
if item := a.settingsItemMap[settingKeyHTTPAddr]; item != nil {
current := formatPathWithFallback(a.ctx.GetHTTPAddr(), "127.0.0.1:5030")
item.Description = fmt.Sprintf("当前监听地址: %s", current)
}
if item := a.settingsItemMap[settingKeyToggleListen]; item != nil {
host := a.ctx.GetHTTPAddr()
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if strings.TrimSpace(host) == "" {
host = "127.0.0.1"
}
item.Description = fmt.Sprintf("当前监听主机: %s", host)
}
if item := a.settingsItemMap[settingKeyWorkDir]; item != nil {
item.Description = fmt.Sprintf("当前工作目录: %s", formatPathWithFallback(a.ctx.WorkDir, "未设置"))
}
if item := a.settingsItemMap[settingKeyDataDir]; item != nil {
item.Description = fmt.Sprintf("当前数据目录: %s", formatPathWithFallback(a.ctx.DataDir, "未设置"))
}
if item := a.settingsItemMap[settingKeyDataKey]; item != nil {
item.Description = fmt.Sprintf("当前数据密钥: %s", formatSecretSummary(a.ctx.DataKey))
}
if item := a.settingsItemMap[settingKeyImgKey]; item != nil {
item.Description = fmt.Sprintf("当前图片密钥: %s", formatSecretSummary(a.ctx.ImgKey))
}
if item := a.settingsItemMap[settingKeyOpenAIAPIKey]; item != nil {
openAIKey := "未设置"
if speechCfg != nil {
openAIKey = formatSecretSummary(speechCfg.APIKey)
}
item.Description = fmt.Sprintf("当前 API Key: %s", openAIKey)
}
if item := a.settingsItemMap[settingKeyOpenAIBaseURL]; item != nil {
baseURL := "未设置"
if speechCfg != nil {
baseURL = formatPathWithFallback(speechCfg.BaseURL, "未设置")
}
item.Description = fmt.Sprintf("当前 Base URL: %s", baseURL)
}
if item := a.settingsItemMap[settingKeyOpenAIProxy]; item != nil {
proxy := "未设置"
if speechCfg != nil {
proxy = formatPathWithFallback(speechCfg.Proxy, "未设置")
}
item.Description = fmt.Sprintf("当前代理: %s", proxy)
}
if item := a.settingsItemMap[settingKeyOpenAITimeout]; item != nil {
timeoutValue := 0
if speechCfg != nil {
timeoutValue = speechCfg.RequestTimeoutSeconds
}
item.Description = fmt.Sprintf("当前请求超时: %s", formatTimeoutSummary(timeoutValue))
}
a.settingsMenu.SetItems(a.settingsItems)
}
func (a *App) updateSpeechConfig(mutator func(*conf.SpeechConfig)) error {
current := a.ctx.GetSpeech()
cfg := conf.SpeechConfig{Enabled: true}
if current != nil {
cfg = *current
} else {
cfg.Provider = "openai"
}
if cfg.Provider == "" {
cfg.Provider = "openai"
}
if mutator != nil {
mutator(&cfg)
}
cfg.Normalize()
return a.m.SaveSpeechConfig(&cfg)
}
func (a *App) settingSpeechProvider() {
buttons := []string{"OpenAI 官方服务", "本地 Docker Whisper", "Whisper.cpp 本地模型", "取消"}
a.showModal("选择语音服务提供商", buttons, func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
var (
provider string
message string
)
switch buttonLabel {
case "OpenAI 官方服务":
provider = "openai"
message = "语音服务已切换到 OpenAI 官方服务"
case "本地 Docker Whisper":
provider = "webservice"
message = "语音服务已切换到本地 Docker Whisper"
case "Whisper.cpp 本地模型":
provider = "whispercpp"
message = "语音服务已切换到 Whisper.cpp 本地模型"
default:
return
}
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.Provider = provider
if provider == "webservice" && strings.TrimSpace(cfg.ServiceURL) == "" {
cfg.ServiceURL = "http://127.0.0.1:9000"
}
}); err != nil {
a.showError(err)
return
}
a.refreshSettingsMenu()
if message != "" {
a.showInfo(message)
}
})
}
func (a *App) settingWhisperModelPath() {
formView := form.NewForm("设置 Whisper.cpp 模型路径")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil {
currentValue = strings.TrimSpace(speech.Model)
}
tempValue := currentValue
formView.AddInputField("模型文件路径", tempValue, 0, nil, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
trimmed := strings.TrimSpace(tempValue)
if trimmed != "" {
trimmed = filepath.Clean(trimmed)
}
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.Model = trimmed
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("Whisper.cpp 模型路径已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingWhisperThreads() {
formView := form.NewForm("设置 Whisper.cpp 线程数")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil && speech.Threads > 0 {
currentValue = strconv.Itoa(speech.Threads)
}
tempValue := currentValue
acceptNumeric := func(text string, lastChar rune) bool {
if lastChar == 0 {
return true
}
return lastChar >= '0' && lastChar <= '9'
}
formView.AddInputField("线程数", tempValue, 0, acceptNumeric, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
trimmed := strings.TrimSpace(tempValue)
threads := 0
if trimmed != "" {
v, err := strconv.Atoi(trimmed)
if err != nil || v < 0 {
a.showError(fmt.Errorf("请输入合法的非负整数"))
return
}
threads = v
}
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.Threads = threads
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("Whisper.cpp 线程数已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingLocalServiceURL() {
formView := form.NewForm("设置本地语音服务地址")
speech := a.ctx.GetSpeech()
currentValue := "http://127.0.0.1:9000"
if speech != nil {
currentValue = formatPathWithFallback(speech.ServiceURL, currentValue)
}
tempValue := currentValue
formView.AddInputField("服务地址", tempValue, 0, nil, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.ServiceURL = tempValue
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("本地语音服务地址已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingOpenAIAPIKey() {
formView := form.NewForm("设置 OpenAI API Key")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil {
currentValue = speech.APIKey
}
tempValue := currentValue
formView.AddInputField("API Key", tempValue, 0, nil, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.APIKey = tempValue
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("OpenAI API Key 已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingOpenAIBaseURL() {
formView := form.NewForm("设置 OpenAI Base URL")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil {
currentValue = speech.BaseURL
}
tempValue := currentValue
formView.AddInputField("Base URL", tempValue, 0, nil, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.BaseURL = tempValue
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("OpenAI Base URL 已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingOpenAIProxy() {
formView := form.NewForm("设置 OpenAI 代理")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil {
currentValue = speech.Proxy
}
tempValue := currentValue
formView.AddInputField("代理地址", tempValue, 0, nil, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.Proxy = tempValue
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("OpenAI 代理已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
func (a *App) settingOpenAITimeout() {
formView := form.NewForm("设置 OpenAI 请求超时")
speech := a.ctx.GetSpeech()
currentValue := ""
if speech != nil && speech.RequestTimeoutSeconds > 0 {
currentValue = strconv.Itoa(speech.RequestTimeoutSeconds)
}
tempValue := currentValue
acceptNumeric := func(text string, lastChar rune) bool {
if lastChar == 0 {
return true
}
return lastChar >= '0' && lastChar <= '9'
}
formView.AddInputField("超时(秒)", tempValue, 0, acceptNumeric, func(text string) {
tempValue = text
})
formView.AddButton("保存", func() {
trimmed := strings.TrimSpace(tempValue)
seconds := 0
if trimmed != "" {
v, err := strconv.Atoi(trimmed)
if err != nil {
a.showError(fmt.Errorf("请输入合法的非负整数"))
return
}
seconds = v
}
if err := a.updateSpeechConfig(func(cfg *conf.SpeechConfig) {
cfg.RequestTimeoutSeconds = seconds
}); err != nil {
a.showError(err)
return
}
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("OpenAI 请求超时已更新")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// settingHTTPPort 设置 HTTP 端口
func (a *App) settingHTTPPort() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置 HTTP 地址")
// 临时存储用户输入的值
tempHTTPAddr := a.ctx.HTTPAddr
// 添加输入字段 - 不再直接设置HTTP地址,而是更新临时变量
formView.AddInputField("地址", tempHTTPAddr, 0, nil, func(text string) {
tempHTTPAddr = text // 只更新临时变量
})
// 添加按钮 - 点击保存时才设置HTTP地址
formView.AddButton("保存", func() {
a.m.SetHTTPAddr(tempHTTPAddr) // 在这里设置HTTP地址
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("HTTP 地址已设置为 " + a.ctx.HTTPAddr)
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// toggleListen 在 127.0.0.1 与 0.0.0.0 之间切换监听主机,保持端口不变
func (a *App) toggleListen() {
// 计算新的地址
cur := a.ctx.GetHTTPAddr()
host, port, err := net.SplitHostPort(cur)
if err != nil || port == "" {
// 回退到默认端口
host = "127.0.0.1"
port = "5030"
}
h := strings.TrimSpace(host)
var newHost string
if h == "0.0.0.0" || h == "::" || h == "[::]" || h == "" {
newHost = "127.0.0.1"
} else {
newHost = "0.0.0.0"
}
newAddr := net.JoinHostPort(newHost, port)
// 若服务正在运行,则重启服务以应用新监听
if a.ctx.HTTPEnabled {
modal := tview.NewModal().SetText("正在切换监听地址...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
go func() {
// 停止服务
stopErr := a.m.StopService()
if stopErr == nil {
// 设置新地址
_ = a.m.SetHTTPAddr(newAddr)
// 启动服务
startErr := a.m.StartService()
a.QueueUpdateDraw(func() {
a.mainPages.RemovePage("modal")
if startErr != nil {
a.showError(fmt.Errorf("切换失败: %v", startErr))
} else {
a.refreshSettingsMenu()
a.showInfo("已切换监听地址为 " + newAddr)
}
})
return
}
// 停止失败时直接报错
a.QueueUpdateDraw(func() {
a.mainPages.RemovePage("modal")
a.showError(fmt.Errorf("切换失败: %v", stopErr))
})
}()
return
}
// 服务未运行,仅更新配置
_ = a.m.SetHTTPAddr(newAddr)
a.refreshSettingsMenu()
a.showInfo("已切换监听地址为 " + newAddr)
}
// settingWorkDir 设置工作目录
func (a *App) settingWorkDir() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置工作目录")
// 临时存储用户输入的值
tempWorkDir := a.ctx.WorkDir
// 添加输入字段 - 不再直接设置工作目录,而是更新临时变量
formView.AddInputField("工作目录", tempWorkDir, 0, nil, func(text string) {
tempWorkDir = text // 只更新临时变量
})
// 添加按钮 - 点击保存时才设置工作目录
formView.AddButton("保存", func() {
a.ctx.SetWorkDir(tempWorkDir) // 在这里设置工作目录
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("工作目录已设置为 " + a.ctx.WorkDir)
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// settingDataKey 设置数据密钥
func (a *App) settingDataKey() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置数据密钥")
// 临时存储用户输入的值
tempDataKey := a.ctx.DataKey
// 添加输入字段 - 不直接设置数据密钥,而是更新临时变量
formView.AddInputField("数据密钥", tempDataKey, 0, nil, func(text string) {
tempDataKey = text // 只更新临时变量
})
// 添加按钮 - 点击保存时才设置数据密钥
formView.AddButton("保存", func() {
a.ctx.DataKey = tempDataKey // 设置数据密钥
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("数据密钥已设置")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// settingImgKey 设置图片密钥 (ImgKey)
func (a *App) settingImgKey() {
formView := form.NewForm("设置图片密钥")
tempImgKey := a.ctx.ImgKey
formView.AddInputField("图片密钥", tempImgKey, 0, nil, func(text string) {
tempImgKey = text
})
formView.AddButton("保存", func() {
a.ctx.SetImgKey(tempImgKey)
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("图片密钥已设置")
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// settingDataDir 设置数据目录
func (a *App) settingDataDir() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置数据目录")
// 临时存储用户输入的值
tempDataDir := a.ctx.DataDir
// 添加输入字段 - 不直接设置数据目录,而是更新临时变量
formView.AddInputField("数据目录", tempDataDir, 0, nil, func(text string) {
tempDataDir = text // 只更新临时变量
})
// 添加按钮 - 点击保存时才设置数据目录
formView.AddButton("保存", func() {
a.ctx.DataDir = tempDataDir // 设置数据目录
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("数据目录已设置为 " + a.ctx.DataDir)
})
formView.AddButton("取消", func() {
a.mainPages.RemovePage("submenu2")
})
a.mainPages.AddPage("submenu2", formView, true, true)
a.SetFocus(formView)
}
// selectAccountSelected 处理切换账号菜单项的选择事件
func (a *App) selectAccountSelected(i *menu.Item) {
// 创建子菜单
subMenu := menu.NewSubMenu("切换账号")
// 添加微信进程
instances := a.m.wechat.GetWeChatInstances()
if len(instances) > 0 {
// 添加实例标题
subMenu.AddItem(&menu.Item{
Index: 0,
Name: "--- 微信进程 ---",
Description: "",
Hidden: false,
Selected: nil,
})
// 添加实例列表
for idx, instance := range instances {
// 创建一个实例描述
description := fmt.Sprintf("版本: %s 目录: %s", instance.FullVersion, instance.DataDir)
// 标记当前选中的实例
name := fmt.Sprintf("%s [%d]", instance.Name, instance.PID)
if a.ctx.Current != nil && a.ctx.Current.PID == instance.PID {
name = name + " [当前]"
}
// 创建菜单项
instanceItem := &menu.Item{
Index: idx + 1,
Name: name,
Description: description,
Hidden: false,
Selected: func(instance *wechat.Account) func(*menu.Item) {
return func(*menu.Item) {
// 如果是当前账号,则无需切换
if a.ctx.Current != nil && a.ctx.Current.PID == instance.PID {
a.mainPages.RemovePage("submenu")
a.showInfo("已经是当前账号")
return
}
// 显示切换中的模态框
modal := tview.NewModal().SetText("正在切换账号...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台执行切换操作
go func() {
err := a.m.Switch(instance, "")
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
a.mainPages.RemovePage("modal")
a.mainPages.RemovePage("submenu")
if err != nil {
// 切换失败
a.showError(fmt.Errorf("切换账号失败: %v", err))
} else {
// 切换成功
a.showInfo("切换账号成功")
// 更新菜单状态
a.updateMenuItemsState()
}
})
}()
}
}(instance),
}
subMenu.AddItem(instanceItem)
}
}
// 添加历史账号
if len(a.ctx.History) > 0 {
// 添加历史账号标题
subMenu.AddItem(&menu.Item{
Index: 100,
Name: "--- 历史账号 ---",
Description: "",
Hidden: false,
Selected: nil,
})
// 添加历史账号列表
idx := 101
for account, hist := range a.ctx.History {
// 创建一个账号描述
description := fmt.Sprintf("版本: %s 目录: %s", hist.FullVersion, hist.DataDir)
// 标记当前选中的账号
name := account
if name == "" {
name = filepath.Base(hist.DataDir)
}
if a.ctx.DataDir == hist.DataDir {
name = name + " [当前]"
}
// 创建菜单项
histItem := &menu.Item{
Index: idx,
Name: name,
Description: description,
Hidden: false,
Selected: func(account string) func(*menu.Item) {
return func(*menu.Item) {
// 如果是当前账号,则无需切换
if a.ctx.Current != nil && a.ctx.DataDir == a.ctx.History[account].DataDir {
a.mainPages.RemovePage("submenu")
a.showInfo("已经是当前账号")
return
}
// 显示切换中的模态框
modal := tview.NewModal().SetText("正在切换账号...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台执行切换操作
go func() {
err := a.m.Switch(nil, account)
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
a.mainPages.RemovePage("modal")
a.mainPages.RemovePage("submenu")
if err != nil {
// 切换失败
a.showError(fmt.Errorf("切换账号失败: %v", err))
} else {
// 切换成功
a.showInfo("切换账号成功")
// 更新菜单状态
a.updateMenuItemsState()
}
})
}()
}
}(account),
}
idx++
subMenu.AddItem(histItem)
}
}
// 如果没有账号可选择
if len(a.ctx.History) == 0 && len(instances) == 0 {
subMenu.AddItem(&menu.Item{
Index: 1,
Name: "无可用账号",
Description: "未检测到微信进程或历史账号",
Hidden: false,
Selected: nil,
})
}
// 显示子菜单
a.mainPages.AddPage("submenu", subMenu, true, true)
a.SetFocus(subMenu)
}
// showModal 显示一个模态对话框
func (a *App) showModal(text string, buttons []string, doneFunc func(buttonIndex int, buttonLabel string)) {
modal := tview.NewModal().
SetText(text).
AddButtons(buttons).
SetDoneFunc(doneFunc)
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
}
// showError 显示错误对话框
func (a *App) showError(err error) {
a.showModal(err.Error(), []string{"OK"}, func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
}
// showInfo 显示信息对话框
func (a *App) showInfo(text string) {
a.showModal(text, []string{"OK"}, func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
}
func formatPathWithFallback(value, fallback string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return fallback
}
return trimmed
}
func formatSecretSummary(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "未设置"
}
if len(trimmed) <= 6 {
return "已设置"
}
return fmt.Sprintf("已设置(长度 %d)", len(trimmed))
}
func formatTimeoutSummary(seconds int) string {
if seconds <= 0 {
return "默认"
}
return fmt.Sprintf("%d 秒", seconds)
}