diff --git a/internal/chatlog/app.go b/internal/chatlog/app.go index 2a0ae7d..d30d41f 100644 --- a/internal/chatlog/app.go +++ b/internal/chatlog/app.go @@ -2,13 +2,14 @@ package chatlog import ( "fmt" + "net" "path/filepath" "runtime" + "strconv" + "strings" "time" - "net" - "strings" - + "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" @@ -26,6 +27,21 @@ const ( RefreshInterval = 1000 * time.Millisecond ) +type settingsKey string + +const ( + 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" +) + type App struct { *tview.Application @@ -40,26 +56,31 @@ type App struct { footer *footer.Footer // tab - menu *menu.Menu - help *help.Help - activeTab int - tabCount int + 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(), + 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() @@ -79,7 +100,12 @@ func (a *App) Run() error { a.tabPages. AddPage("0", a.menu, true, true). AddPage("1", a.help, true, false) - a.tabCount = 2 + if a.settingsMenu != nil { + a.tabPages.AddPage("2", a.settingsMenu, true, false) + a.tabCount = 3 + } else { + a.tabCount = 2 + } a.SetInputCapture(a.inputCapture) @@ -124,6 +150,8 @@ func (a *App) updateMenuItemsState() { item.Description = "启动本地 HTTP & MCP 服务器" } } + + a.refreshSettingsMenu() } } @@ -134,6 +162,26 @@ func (a *App) switchTab(step int) { } 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() { @@ -241,6 +289,7 @@ func (a *App) initMenu() { } else { // 解密成功 modal.SetText("获取密钥成功") + a.refreshSettingsMenu() } // 添加确认按钮 @@ -448,7 +497,9 @@ func (a *App) initMenu() { Index: 6, Name: "设置", Description: "设置应用程序选项", - Selected: a.settingSelected, + Selected: func(*menu.Item) { + a.focusSettingsTab() + }, } selectAccount := &menu.Item{ @@ -475,65 +526,271 @@ func (a *App) initMenu() { }) } -// settingItem 表示一个设置项 -type settingItem struct { - name string - description string - action func() +func (a *App) initSettingsTab() { + a.settingsMenu = menu.NewSubMenu("设置") + if a.settingsMenu == nil { + return + } + a.settingsMenu.SetCancelFunc(nil) + + a.settingsItems = []*menu.Item{ + a.newSettingsItem(1, "设置 HTTP 服务地址", settingKeyHTTPAddr, a.settingHTTPPort), + a.newSettingsItem(2, "切换局域网监听", settingKeyToggleListen, a.toggleListen), + a.newSettingsItem(3, "设置工作目录", settingKeyWorkDir, a.settingWorkDir), + a.newSettingsItem(4, "设置数据目录", settingKeyDataDir, a.settingDataDir), + a.newSettingsItem(5, "设置数据密钥", settingKeyDataKey, a.settingDataKey), + a.newSettingsItem(6, "设置图片密钥", settingKeyImgKey, a.settingImgKey), + a.newSettingsItem(7, "设置 OpenAI API Key", settingKeyOpenAIAPIKey, a.settingOpenAIAPIKey), + a.newSettingsItem(8, "设置 OpenAI Base URL", settingKeyOpenAIBaseURL, a.settingOpenAIBaseURL), + a.newSettingsItem(9, "设置 OpenAI 代理", settingKeyOpenAIProxy, a.settingOpenAIProxy), + a.newSettingsItem(10, "设置 OpenAI 请求超时", settingKeyOpenAITimeout, a.settingOpenAITimeout), + } + + a.settingsMenu.SetItems(a.settingsItems) + a.refreshSettingsMenu() } -func (a *App) settingSelected(i *menu.Item) { - - settings := []settingItem{ - { - name: "设置 HTTP 服务地址", - description: "配置 HTTP 服务监听的地址", - action: a.settingHTTPPort, - }, - { - name: "切换局域网监听", - description: "切换局域网访问限制", - action: a.toggleListen, - }, - { - name: "设置工作目录", - description: "配置数据解密后的存储目录", - action: a.settingWorkDir, - }, - { - name: "设置数据密钥", - description: "配置数据解密密钥", - action: a.settingDataKey, - }, - { - name: "设置图片密钥", - description: "配置图片解密密钥", - action: a.settingImgKey, - }, - { - name: "设置数据目录", - description: "配置微信数据文件所在目录", - action: a.settingDataDir, +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 +} - subMenu := menu.NewSubMenu("设置") - for idx, setting := range settings { - item := &menu.Item{ - Index: idx + 1, - Name: setting.name, - Description: setting.description, - Selected: func(action func()) func(*menu.Item) { - return func(*menu.Item) { - action() - } - }(setting.action), +func (a *App) refreshSettingsMenu() { + if a.settingsMenu == nil || len(a.settingsItems) == 0 { + return + } + + if item := a.settingsItemMap[settingKeyHTTPAddr]; item != nil { + item.Description = fmt.Sprintf("配置 HTTP 服务监听的地址 (当前: %s)", formatPathWithFallback(a.ctx.GetHTTPAddr(), "未设置")) + } + + if item := a.settingsItemMap[settingKeyToggleListen]; item != nil { + host := a.ctx.GetHTTPAddr() + if h, _, err := net.SplitHostPort(host); err == nil { + host = h } - subMenu.AddItem(item) + if strings.TrimSpace(host) == "" { + host = "127.0.0.1" + } + item.Description = fmt.Sprintf("当前监听主机: %s", host) } - a.mainPages.AddPage("submenu", subMenu, true, true) - a.SetFocus(subMenu) + 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)) + } + + speech := a.ctx.GetSpeech() + var speechCfg conf.SpeechConfig + if speech != nil { + speechCfg = *speech + } + + if item := a.settingsItemMap[settingKeyOpenAIAPIKey]; item != nil { + item.Description = fmt.Sprintf("配置 OpenAI API Key (当前: %s)", formatSecretSummary(speechCfg.APIKey)) + } + + if item := a.settingsItemMap[settingKeyOpenAIBaseURL]; item != nil { + item.Description = fmt.Sprintf("配置 OpenAI Base URL (当前: %s)", formatPathWithFallback(speechCfg.BaseURL, "官方默认")) + } + + if item := a.settingsItemMap[settingKeyOpenAIProxy]; item != nil { + item.Description = fmt.Sprintf("配置 OpenAI 代理 (当前: %s)", formatPathWithFallback(speechCfg.Proxy, "未设置")) + } + + if item := a.settingsItemMap[settingKeyOpenAITimeout]; item != nil { + item.Description = fmt.Sprintf("配置 OpenAI 请求超时 (当前: %s)", formatTimeoutSummary(speechCfg.RequestTimeoutSeconds)) + } + + a.settingsMenu.SetItems(a.settingsItems) +} + +func (a *App) updateSpeechConfig(mutator func(*conf.SpeechConfig)) error { + current := a.ctx.GetSpeech() + cfg := conf.SpeechConfig{Enabled: true, Provider: "openai"} + if current != nil { + cfg = *current + } + + if mutator != nil { + mutator(&cfg) + } + + cfg.Normalize() + return a.m.SaveSpeechConfig(&cfg) +} + +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 端口 @@ -553,6 +810,7 @@ func (a *App) settingHTTPPort() { formView.AddButton("保存", func() { a.m.SetHTTPAddr(tempHTTPAddr) // 在这里设置HTTP地址 a.mainPages.RemovePage("submenu2") + a.refreshSettingsMenu() a.showInfo("HTTP 地址已设置为 " + a.ctx.HTTPAddr) }) @@ -601,6 +859,7 @@ func (a *App) toggleListen() { if startErr != nil { a.showError(fmt.Errorf("切换失败: %v", startErr)) } else { + a.refreshSettingsMenu() a.showInfo("已切换监听地址为 " + newAddr) } }) @@ -617,6 +876,7 @@ func (a *App) toggleListen() { // 服务未运行,仅更新配置 _ = a.m.SetHTTPAddr(newAddr) + a.refreshSettingsMenu() a.showInfo("已切换监听地址为 " + newAddr) } @@ -637,6 +897,7 @@ func (a *App) settingWorkDir() { formView.AddButton("保存", func() { a.ctx.SetWorkDir(tempWorkDir) // 在这里设置工作目录 a.mainPages.RemovePage("submenu2") + a.refreshSettingsMenu() a.showInfo("工作目录已设置为 " + a.ctx.WorkDir) }) @@ -665,6 +926,7 @@ func (a *App) settingDataKey() { formView.AddButton("保存", func() { a.ctx.DataKey = tempDataKey // 设置数据密钥 a.mainPages.RemovePage("submenu2") + a.refreshSettingsMenu() a.showInfo("数据密钥已设置") }) @@ -689,6 +951,7 @@ func (a *App) settingImgKey() { formView.AddButton("保存", func() { a.ctx.SetImgKey(tempImgKey) a.mainPages.RemovePage("submenu2") + a.refreshSettingsMenu() a.showInfo("图片密钥已设置") }) @@ -717,6 +980,7 @@ func (a *App) settingDataDir() { formView.AddButton("保存", func() { a.ctx.DataDir = tempDataDir // 设置数据目录 a.mainPages.RemovePage("submenu2") + a.refreshSettingsMenu() a.showInfo("数据目录已设置为 " + a.ctx.DataDir) }) @@ -917,3 +1181,29 @@ func (a *App) showInfo(text 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) +} diff --git a/internal/chatlog/conf/speech.go b/internal/chatlog/conf/speech.go index 8d25cdd..1356c2f 100644 --- a/internal/chatlog/conf/speech.go +++ b/internal/chatlog/conf/speech.go @@ -1,22 +1,87 @@ package conf -import "github.com/sjzar/chatlog/internal/whisper" +import ( + "strings" + + "github.com/sjzar/chatlog/internal/whisper" +) + +// OpenAISettings captures provider-specific fields retained for backwards compatibility. +type OpenAISettings struct { + APIKey string `mapstructure:"api_key" json:"api_key"` + BaseURL string `mapstructure:"base_url" json:"base_url"` + Organization string `mapstructure:"organization" json:"organization"` + Project string `mapstructure:"project" json:"project"` + Proxy string `mapstructure:"proxy" json:"proxy"` +} // SpeechConfig controls optional speech-to-text features. type SpeechConfig struct { - Enabled bool `mapstructure:"enabled" json:"enabled"` - Model string `mapstructure:"model" json:"model"` - TranslateModel string `mapstructure:"translate_model" json:"translate_model"` - Threads int `mapstructure:"threads" json:"threads"` - Language string `mapstructure:"language" json:"language"` - Translate *bool `mapstructure:"translate" json:"translate"` - InitialPrompt string `mapstructure:"initial_prompt" json:"initial_prompt"` - Temperature *float64 `mapstructure:"temperature" json:"temperature"` - TemperatureFallback *float64 `mapstructure:"temperature_fallback" json:"temperature_fallback"` - APIKey string `mapstructure:"api_key" json:"api_key"` - BaseURL string `mapstructure:"base_url" json:"base_url"` - Organization string `mapstructure:"organization" json:"organization"` - RequestTimeoutSeconds int `mapstructure:"request_timeout_seconds" json:"request_timeout_seconds"` + Enabled bool `mapstructure:"enabled" json:"enabled"` + Provider string `mapstructure:"provider" json:"provider"` + Model string `mapstructure:"model" json:"model"` + TranslateModel string `mapstructure:"translate_model" json:"translate_model"` + Threads int `mapstructure:"threads" json:"threads"` + Language string `mapstructure:"language" json:"language"` + Translate *bool `mapstructure:"translate" json:"translate"` + InitialPrompt string `mapstructure:"initial_prompt" json:"initial_prompt"` + Temperature *float64 `mapstructure:"temperature" json:"temperature"` + TemperatureFallback *float64 `mapstructure:"temperature_fallback" json:"temperature_fallback"` + APIKey string `mapstructure:"api_key" json:"api_key"` + BaseURL string `mapstructure:"base_url" json:"base_url"` + Organization string `mapstructure:"organization" json:"organization"` + Project string `mapstructure:"project" json:"project"` + Proxy string `mapstructure:"proxy" json:"proxy"` + RequestTimeoutSeconds int `mapstructure:"request_timeout_seconds" json:"request_timeout_seconds"` + OpenAI OpenAISettings `mapstructure:"openai" json:"openai"` +} + +// Normalize hydrates legacy OpenAI fields into the flattened structure and applies defaults. +func (c *SpeechConfig) Normalize() { + if c == nil { + return + } + if c.Provider == "" { + c.Provider = "openai" + } + + if c.APIKey == "" { + c.APIKey = c.OpenAI.APIKey + } + if c.BaseURL == "" { + c.BaseURL = c.OpenAI.BaseURL + } + if c.Organization == "" { + c.Organization = c.OpenAI.Organization + } + if c.Project == "" { + c.Project = c.OpenAI.Project + } + if c.Proxy == "" { + c.Proxy = c.OpenAI.Proxy + } + + c.APIKey = strings.TrimSpace(c.APIKey) + c.BaseURL = strings.TrimSpace(c.BaseURL) + c.Organization = strings.TrimSpace(c.Organization) + c.Project = strings.TrimSpace(c.Project) + c.Proxy = strings.TrimSpace(c.Proxy) +} + +// PrepareForSave syncs the flattened OpenAI fields back into the legacy nested structure. +func (c *SpeechConfig) PrepareForSave() { + if c == nil { + return + } + if c.Provider == "" { + c.Provider = "openai" + } + + c.OpenAI.APIKey = c.APIKey + c.OpenAI.BaseURL = c.BaseURL + c.OpenAI.Organization = c.Organization + c.OpenAI.Project = c.Project + c.OpenAI.Proxy = c.Proxy } // ToOptions converts the speech config into runtime options for a transcription backend. diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go index 997f6f3..5cfa764 100644 --- a/internal/chatlog/ctx/context.go +++ b/internal/chatlog/ctx/context.go @@ -2,6 +2,7 @@ package ctx import ( "encoding/json" + "errors" "os" "path/filepath" "sync" @@ -25,8 +26,9 @@ type Context struct { cm *config.Manager mu sync.RWMutex - History map[string]conf.ProcessConfig - speech *conf.SpeechConfig + History map[string]conf.ProcessConfig + speech *conf.SpeechConfig + speechPath string // 微信账号相关状态 Account string @@ -83,21 +85,25 @@ func (c *Context) loadConfig() { c.Refresh() if c.cm != nil { speechPath := filepath.Join(c.cm.Path, "whisper.json") + c.speechPath = speechPath if data, err := os.ReadFile(speechPath); err == nil { var sc conf.SpeechConfig if err := json.Unmarshal(data, &sc); err != nil { log.Debug().Err(err).Msg("failed to parse speech config") } else { + sc.Normalize() c.speech = &sc } } else if os.IsNotExist(err) { - defaultSpeech := conf.SpeechConfig{Enabled: true, Model: "whisper-1"} + defaultSpeech := conf.SpeechConfig{Enabled: true, Provider: "openai", Model: "whisper-1"} + defaultSpeech.PrepareForSave() payload, marshalErr := json.MarshalIndent(defaultSpeech, "", " ") if marshalErr != nil { log.Error().Err(marshalErr).Msg("failed to marshal default speech config") } else if writeErr := os.WriteFile(speechPath, payload, 0600); writeErr != nil { log.Error().Err(writeErr).Msg("failed to write default speech config") } else { + defaultSpeech.Normalize() c.speech = &defaultSpeech log.Info().Str("path", speechPath).Msg("created default speech config") } @@ -111,6 +117,41 @@ func (c *Context) loadConfig() { } } +func (c *Context) SaveSpeechConfig(cfg *conf.SpeechConfig) error { + if cfg == nil { + return errors.New("speech config is nil") + } + if c.cm == nil { + return errors.New("config manager unavailable") + } + + cfg.Enabled = true + cfg.Normalize() + cfg.PrepareForSave() + + path := c.speechPath + if path == "" { + path = filepath.Join(c.cm.Path, "whisper.json") + c.speechPath = path + } + + payload, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + return err + } + + cfgCopy := *cfg + cfgCopy.Normalize() + + c.mu.Lock() + defer c.mu.Unlock() + c.speech = &cfgCopy + return nil +} + func (c *Context) SwitchHistory(account string) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go index becdd81..2963162 100644 --- a/internal/chatlog/http/service.go +++ b/internal/chatlog/http/service.go @@ -66,11 +66,18 @@ func NewService(conf Config, db *database.Service) *Service { } func (s *Service) initSpeech(cfg Config) { + if s.speechTranscriber != nil { + s.speechTranscriber.Close() + s.speechTranscriber = nil + } + speechCfg := cfg.GetSpeech() if speechCfg == nil || !speechCfg.Enabled { return } + speechCfg.Normalize() + opts := speechCfg.ToOptions() timeout := time.Duration(speechCfg.RequestTimeoutSeconds) * time.Second transcriber, err := whisper.NewOpenAITranscriber(whisper.OpenAIConfig{ @@ -79,6 +86,7 @@ func (s *Service) initSpeech(cfg Config) { APIKey: speechCfg.APIKey, BaseURL: speechCfg.BaseURL, Organization: speechCfg.Organization, + ProxyURL: speechCfg.Proxy, RequestTimeout: timeout, DefaultOptions: opts, }) @@ -92,6 +100,10 @@ func (s *Service) initSpeech(cfg Config) { log.Info().Str("model", transcriber.ModelName()).Msg("speech transcription backend initialised via openai whisper") } +func (s *Service) ReloadSpeech() { + s.initSpeech(s.conf) +} + func (s *Service) Start() error { s.server = &http.Server{ diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go index 1a633ba..a9677ea 100644 --- a/internal/chatlog/manager.go +++ b/internal/chatlog/manager.go @@ -322,6 +322,19 @@ func (m *Manager) StopAutoDecrypt() error { return nil } +func (m *Manager) SaveSpeechConfig(cfg *conf.SpeechConfig) error { + if cfg == nil { + return fmt.Errorf("speech config is nil") + } + if err := m.ctx.SaveSpeechConfig(cfg); err != nil { + return err + } + if m.http != nil { + m.http.ReloadSpeech() + } + return nil +} + func (m *Manager) RefreshSession() error { if m.db.GetDB() == nil { if err := m.db.Start(); err != nil { diff --git a/internal/whisper/transcriber.go b/internal/whisper/transcriber.go index 86c1ca3..65e4cb9 100644 --- a/internal/whisper/transcriber.go +++ b/internal/whisper/transcriber.go @@ -6,8 +6,11 @@ import ( "encoding/binary" "encoding/json" "errors" + "fmt" "io" "math" + "net/http" + "net/url" "strings" "time" @@ -26,6 +29,7 @@ type OpenAIConfig struct { APIKey string BaseURL string Organization string + ProxyURL string RequestTimeout time.Duration DefaultOptions Options } @@ -56,7 +60,13 @@ func NewOpenAITranscriber(cfg OpenAIConfig) (*OpenAITranscriber, error) { if cfg.BaseURL != "" { opts = append(opts, option.WithBaseURL(cfg.BaseURL)) } - if cfg.RequestTimeout > 0 { + if cfg.ProxyURL != "" { + client, err := buildHTTPClient(cfg.ProxyURL, cfg.RequestTimeout) + if err != nil { + return nil, err + } + opts = append(opts, option.WithHTTPClient(client)) + } else if cfg.RequestTimeout > 0 { opts = append(opts, option.WithRequestTimeout(cfg.RequestTimeout)) } @@ -71,6 +81,32 @@ func NewOpenAITranscriber(cfg OpenAIConfig) (*OpenAITranscriber, error) { }, nil } +func buildHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { + transport, ok := http.DefaultTransport.(*http.Transport) + var baseTransport *http.Transport + if ok { + baseTransport = transport.Clone() + } else { + baseTransport = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + + if proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy url: %w", err) + } + baseTransport.Proxy = http.ProxyURL(parsed) + } + + client := &http.Client{ + Transport: baseTransport, + } + if timeout > 0 { + client.Timeout = timeout + } + return client, nil +} + // Close releases resources held by the transcriber. No-op for the OpenAI backend. func (t *OpenAITranscriber) Close() {}