diff --git a/README.md b/README.md index deeafdc..1f77e17 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,19 @@ go install github.com/sjzar/chatlog@latest ### Terminal UI 模式 -最简单的使用方式是通过 Terminal UI 界面操作: +最简单的使用方式是通过 web 界面操作: ```bash chatlog ``` +如果遇到了问题,可以按下面的命令打开调试/控制台前端模式: + +```bash +chatlog --debug // 打开调试模式 +chatlog --console // 打开控制台前端模式 +``` + 操作方法: - 使用 `↑` `↓` 键选择菜单项 diff --git a/TODO.md b/TODO.md index 47f86f0..0c5894a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO - [ ] 修复 html 渲染部分头像缺失的问题 -- [x] 美化搜索界面使风格一致 -- [ ] 实现统一接口管理设置的功能 -- [ ] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper) +- [ ] 美化搜索界面使风格一致 +- [x] 实现统一接口管理设置的功能 +- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper) diff --git a/cmd/chatlog/log.go b/cmd/chatlog/log.go index f1c62fc..150ba10 100644 --- a/cmd/chatlog/log.go +++ b/cmd/chatlog/log.go @@ -14,7 +14,10 @@ import ( "github.com/spf13/cobra" ) -var Debug bool +var ( + Debug bool + Console bool +) func initLog(cmd *cobra.Command, args []string) { zerolog.SetGlobalLevel(zerolog.InfoLevel) diff --git a/cmd/chatlog/root.go b/cmd/chatlog/root.go index da88bcb..d3464c4 100644 --- a/cmd/chatlog/root.go +++ b/cmd/chatlog/root.go @@ -1,7 +1,10 @@ package chatlog import ( + "runtime" + "github.com/sjzar/chatlog/internal/chatlog" + "github.com/sjzar/chatlog/pkg/util" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -12,6 +15,7 @@ func init() { cobra.MousetrapHelpText = "" rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug") + rootCmd.PersistentFlags().BoolVar(&Console, "console", false, "run with console interface") rootCmd.PersistentPreRun = initLog } @@ -30,12 +34,34 @@ var rootCmd = &cobra.Command{ CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: true, }, - PreRun: initTuiLog, + PreRun: prepareRoot, Run: Root, } +func prepareRoot(cmd *cobra.Command, args []string) { + if Console || !Debug { + initTuiLog(cmd, args) + } +} + func Root(cmd *cobra.Command, args []string) { m := chatlog.New() + mode := chatlog.RunModeHeadless + autoOpen := true + if Console { + mode = chatlog.RunModeConsole + autoOpen = false + } + m.SetRunOptions(chatlog.RunOptions{ + Mode: mode, + AutoOpenBrowser: autoOpen, + AutoOpenBrowserSet: true, + }) + + if runtime.GOOS == "windows" && !Console && !Debug { + util.HideConsoleWindow() + } + if err := m.Run(""); err != nil { log.Err(err).Msg("failed to run chatlog instance") } diff --git a/internal/chatlog/app.go b/internal/chatlog/app.go index 4749f11..56f8922 100644 --- a/internal/chatlog/app.go +++ b/internal/chatlog/app.go @@ -1176,7 +1176,7 @@ func (a *App) settingDataKey() { // 添加按钮 - 点击保存时才设置数据密钥 formView.AddButton("保存", func() { - a.ctx.DataKey = tempDataKey // 设置数据密钥 + a.ctx.SetDataKey(tempDataKey) a.mainPages.RemovePage("submenu2") a.refreshSettingsMenu() a.showInfo("数据密钥已设置") @@ -1230,7 +1230,7 @@ func (a *App) settingDataDir() { // 添加按钮 - 点击保存时才设置数据目录 formView.AddButton("保存", func() { - a.ctx.DataDir = tempDataDir // 设置数据目录 + a.ctx.SetDataDir(tempDataDir) a.mainPages.RemovePage("submenu2") a.refreshSettingsMenu() a.showInfo("数据目录已设置为 " + a.ctx.DataDir) diff --git a/internal/chatlog/conf/server.go b/internal/chatlog/conf/server.go index e922b28..8d6b4d9 100644 --- a/internal/chatlog/conf/server.go +++ b/internal/chatlog/conf/server.go @@ -63,3 +63,31 @@ func (c *ServerConfig) GetWebhook() *Webhook { func (c *ServerConfig) GetSpeech() *SpeechConfig { return c.Speech } + +func (c *ServerConfig) SetHTTPAddr(addr string) { + c.HTTPAddr = addr +} + +func (c *ServerConfig) SetWorkDir(dir string) { + c.WorkDir = dir +} + +func (c *ServerConfig) SetDataDir(dir string) { + c.DataDir = dir +} + +func (c *ServerConfig) SetImgKey(key string) { + c.ImgKey = key +} + +func (c *ServerConfig) SetDataKey(key string) { + c.DataKey = key +} + +func (c *ServerConfig) IsHTTPEnabled() bool { + return true +} + +func (c *ServerConfig) IsAutoDecrypt() bool { + return c.AutoDecrypt +} diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go index 5cfa764..09a8603 100644 --- a/internal/chatlog/ctx/context.go +++ b/internal/chatlog/ctx/context.go @@ -244,6 +244,10 @@ func (c *Context) GetDataKey() string { return c.DataKey } +func (c *Context) GetImgKey() string { + return c.ImgKey +} + func (c *Context) GetHTTPAddr() string { if c.HTTPAddr == "" { c.HTTPAddr = DefalutHTTPAddr @@ -251,6 +255,14 @@ func (c *Context) GetHTTPAddr() string { return c.HTTPAddr } +func (c *Context) IsHTTPEnabled() bool { + return c.HTTPEnabled +} + +func (c *Context) IsAutoDecrypt() bool { + return c.AutoDecrypt +} + func (c *Context) GetWebhook() *conf.Webhook { return c.conf.Webhook } @@ -311,6 +323,16 @@ func (c *Context) SetImgKey(key string) { c.UpdateConfig() } +func (c *Context) SetDataKey(key string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.DataKey == key { + return + } + c.DataKey = key + c.UpdateConfig() +} + func (c *Context) SetAutoDecrypt(enabled bool) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/chatlog/http/api_actions.go b/internal/chatlog/http/api_actions.go new file mode 100644 index 0000000..647d0ab --- /dev/null +++ b/internal/chatlog/http/api_actions.go @@ -0,0 +1,90 @@ +package http + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +func (s *Service) handleActionGetDataKey(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if err := s.control.GetDataKey(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (s *Service) handleActionDecrypt(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if err := s.control.DecryptDBFiles(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (s *Service) handleActionStartHTTP(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if s.conf.IsHTTPEnabled() { + c.JSON(http.StatusOK, gin.H{"status": "already_running"}) + return + } + go func() { + if err := s.control.StartService(); err != nil { + log.Err(err).Msg("failed to start http service via api") + } + }() + c.JSON(http.StatusAccepted, gin.H{"status": "starting"}) +} + +func (s *Service) handleActionStopHTTP(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if !s.conf.IsHTTPEnabled() { + c.JSON(http.StatusOK, gin.H{"status": "already_stopped"}) + return + } + go func() { + if err := s.control.StopService(); err != nil { + log.Err(err).Msg("failed to stop http service via api") + } + }() + c.JSON(http.StatusAccepted, gin.H{"status": "stopping"}) +} + +func (s *Service) handleActionStartAutoDecrypt(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if err := s.control.StartAutoDecrypt(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (s *Service) handleActionStopAutoDecrypt(c *gin.Context) { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + if err := s.control.StopAutoDecrypt(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/internal/chatlog/http/api_settings.go b/internal/chatlog/http/api_settings.go new file mode 100644 index 0000000..26c7754 --- /dev/null +++ b/internal/chatlog/http/api_settings.go @@ -0,0 +1,102 @@ +package http + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sjzar/chatlog/internal/chatlog/conf" +) + +type settingRequest struct { + HTTPAddr *string `json:"http_addr"` + WorkDir *string `json:"work_dir"` + DataDir *string `json:"data_dir"` + DataKey *string `json:"data_key"` + ImgKey *string `json:"img_key"` + Speech *conf.SpeechConfig `json:"speech"` +} + +type settingResponse struct { + HTTPAddr string `json:"http_addr"` + HTTPEnabled bool `json:"http_enabled"` + WorkDir string `json:"work_dir"` + DataDir string `json:"data_dir"` + DataKey string `json:"data_key"` + ImgKey string `json:"img_key"` + AutoDecrypt bool `json:"auto_decrypt"` + Speech *conf.SpeechConfig `json:"speech"` +} + +func (s *Service) handleGetSetting(c *gin.Context) { + c.JSON(http.StatusOK, s.buildSettingResponse()) +} + +func (s *Service) handleUpdateSetting(c *gin.Context) { + var req settingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload", "detail": err.Error()}) + return + } + + if req.HTTPAddr != nil { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + trimmed := strings.TrimSpace(*req.HTTPAddr) + if err := s.control.SetHTTPAddr(trimmed); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if req.WorkDir != nil { + s.conf.SetWorkDir(strings.TrimSpace(*req.WorkDir)) + } + + if req.DataDir != nil { + s.conf.SetDataDir(strings.TrimSpace(*req.DataDir)) + } + + if req.DataKey != nil { + s.conf.SetDataKey(strings.TrimSpace(*req.DataKey)) + } + + if req.ImgKey != nil { + s.conf.SetImgKey(strings.TrimSpace(*req.ImgKey)) + } + + if req.Speech != nil { + if s.control == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "control service unavailable"}) + return + } + speechCopy := *req.Speech + if err := s.control.SaveSpeechConfig(&speechCopy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, s.buildSettingResponse()) +} + +func (s *Service) buildSettingResponse() settingResponse { + resp := settingResponse{ + HTTPAddr: s.conf.GetHTTPAddr(), + HTTPEnabled: s.conf.IsHTTPEnabled(), + WorkDir: s.conf.GetWorkDir(), + DataDir: s.conf.GetDataDir(), + DataKey: s.conf.GetDataKey(), + ImgKey: s.conf.GetImgKey(), + AutoDecrypt: s.conf.IsAutoDecrypt(), + } + + if cfg := s.conf.GetSpeech(); cfg != nil { + copyCfg := *cfg + resp.Speech = ©Cfg + } + + return resp +} diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index fef22af..d3d64d8 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -162,15 +162,27 @@ func (s *Service) initMediaRouter() { } func (s *Service) initAPIRouter() { - api := s.router.Group("/api/v1", s.checkDBStateMiddleware()) + api := s.router.Group("/api/v1") { - api.GET("/chatlog", s.handleChatlog) - api.GET("/contact", s.handleContacts) - api.GET("/chatroom", s.handleChatRooms) - api.GET("/session", s.handleSessions) - api.GET("/diary", s.handleDiary) - api.GET("/dashboard", s.handleDashboard) - api.GET("/search", s.handleSearch) + api.GET("/setting", s.handleGetSetting) + api.POST("/setting", s.handleUpdateSetting) + + actions := api.Group("/actions") + actions.POST("/get-data-key", s.handleActionGetDataKey) + actions.POST("/decrypt", s.handleActionDecrypt) + actions.POST("/http/start", s.handleActionStartHTTP) + actions.POST("/http/stop", s.handleActionStopHTTP) + actions.POST("/auto-decrypt/start", s.handleActionStartAutoDecrypt) + actions.POST("/auto-decrypt/stop", s.handleActionStopAutoDecrypt) + + dataAPI := api.Group("", s.checkDBStateMiddleware()) + dataAPI.GET("/chatlog", s.handleChatlog) + dataAPI.GET("/contact", s.handleContacts) + dataAPI.GET("/chatroom", s.handleChatRooms) + dataAPI.GET("/session", s.handleSessions) + dataAPI.GET("/diary", s.handleDiary) + dataAPI.GET("/dashboard", s.handleDashboard) + dataAPI.GET("/search", s.handleSearch) } } diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go index fac330f..caf35eb 100644 --- a/internal/chatlog/http/service.go +++ b/internal/chatlog/http/service.go @@ -17,8 +17,9 @@ import ( ) type Service struct { - conf Config - db *database.Service + conf Config + db *database.Service + control Control router *gin.Engine server *http.Server @@ -33,11 +34,32 @@ type Service struct { type Config interface { GetHTTPAddr() string + SetHTTPAddr(string) GetDataDir() string + SetDataDir(string) + GetWorkDir() string + SetWorkDir(string) + GetDataKey() string + SetDataKey(string) + GetImgKey() string + SetImgKey(string) + IsHTTPEnabled() bool + IsAutoDecrypt() bool GetSpeech() *conf.SpeechConfig } -func NewService(conf Config, db *database.Service) *Service { +type Control interface { + GetDataKey() error + DecryptDBFiles() error + StartService() error + StopService() error + StartAutoDecrypt() error + StopAutoDecrypt() error + SaveSpeechConfig(cfg *conf.SpeechConfig) error + SetHTTPAddr(addr string) error +} + +func NewService(conf Config, db *database.Service, control Control) *Service { gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -55,9 +77,10 @@ func NewService(conf Config, db *database.Service) *Service { ) s := &Service{ - conf: conf, - db: db, - router: router, + conf: conf, + db: db, + control: control, + router: router, } s.initMCPServer() diff --git a/internal/chatlog/http/static/index.htm b/internal/chatlog/http/static/index.htm index 58bd04e..ea699ed 100644 --- a/internal/chatlog/http/static/index.htm +++ b/internal/chatlog/http/static/index.htm @@ -162,6 +162,7 @@ display: inline-flex; align-items: center; justify-content: center; + position: relative; } button:hover { @@ -399,6 +400,177 @@ color: var(--error-color); font-weight: bold; } + + .settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + } + + .settings-card { + background-color: var(--bg-white); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } + + .settings-card--wide { + grid-column: 1 / -1; + } + + .settings-card h3 { + margin-top: 0; + margin-bottom: 12px; + color: #2c3e50; + } + + .settings-card p { + margin-top: 0; + color: #555; + } + + .settings-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 12px; + } + + .secondary-button { + background-color: #7f8c8d; + } + + .secondary-button:hover { + background-color: #707b7c; + } + + .danger-button { + background-color: var(--error-color); + } + + .danger-button:hover { + background-color: #c0392b; + } + + .settings-message { + display: none; + margin-bottom: 16px; + padding: 10px 14px; + border-radius: 6px; + font-weight: 500; + } + + .settings-message--info { + background-color: rgba(52, 152, 219, 0.1); + color: var(--primary-dark); + border: 1px solid rgba(52, 152, 219, 0.2); + } + + .settings-message--success { + background-color: rgba(46, 204, 113, 0.12); + color: var(--success-dark); + border: 1px solid rgba(46, 204, 113, 0.2); + } + + .settings-message--error { + background-color: rgba(231, 76, 60, 0.12); + color: var(--error-color); + border: 1px solid rgba(231, 76, 60, 0.2); + } + + .settings-loading { + display: none; + align-items: center; + gap: 8px; + color: #666; + font-size: 14px; + margin-bottom: 12px; + } + + .settings-loading::before { + content: ""; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid rgba(0, 0, 0, 0.15); + border-top-color: var(--primary-color); + animation: settings-spin 0.8s linear infinite; + } + + @keyframes settings-spin { + to { + transform: rotate(360deg); + } + } + + .status-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + } + + .status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background-color: #bdc3c7; + color: #2c3e50; + } + + .status-badge--on { + background-color: var(--success-color); + color: #fff; + } + + .status-badge--off { + background-color: #bdc3c7; + color: #2c3e50; + } + + .settings-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; + } + + .settings-actions button { + flex: 1 1 180px; + min-width: 160px; + } + + #settings-speech-json { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", + Menlo, monospace; + font-size: 13px; + min-height: 220px; + resize: vertical; + } + + .button--loading { + opacity: 0.6; + cursor: wait !important; + } + + .button--loading::after { + content: ""; + position: absolute; + right: 14px; + top: 50%; + width: 12px; + height: 12px; + margin-top: -6px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.6); + border-top-color: #fff; + animation: settings-spin 0.8s linear infinite; + }
@@ -425,6 +597,7 @@
+ 直接编辑 Whisper 配置,保存后会写入
+ whisper.json
+