feat(frontend) : moving frontend to webpage

This commit is contained in:
Symb0x76
2025-10-16 16:29:08 +08:00
parent 2691ee2c41
commit 35eebcdc30
18 changed files with 1458 additions and 89 deletions
+8 -1
View File
@@ -62,12 +62,19 @@ go install github.com/sjzar/chatlog@latest
### Terminal UI 模式
最简单的使用方式是通过 Terminal UI 界面操作:
最简单的使用方式是通过 web 界面操作:
```bash
chatlog
```
如果遇到了问题,可以按下面的命令打开调试/控制台前端模式:
```bash
chatlog --debug // 打开调试模式
chatlog --console // 打开控制台前端模式
```
操作方法:
- 使用 `↑` `↓` 键选择菜单项
+3 -3
View File
@@ -1,6 +1,6 @@
# TODO
- [ ] 修复 html 渲染部分头像缺失的问题
- [x] 美化搜索界面使风格一致
- [ ] 实现统一接口管理设置的功能
- [ ] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
- [ ] 美化搜索界面使风格一致
- [x] 实现统一接口管理设置的功能
- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
+4 -1
View File
@@ -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)
+27 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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)
+28
View File
@@ -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
}
+22
View File
@@ -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()
+90
View File
@@ -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"})
}
+102
View File
@@ -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 = &copyCfg
}
return resp
}
+20 -8
View File
@@ -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)
}
}
+29 -6
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+106 -10
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/rs/zerolog/log"
@@ -22,6 +24,19 @@ import (
const initialDecryptPollInterval = 5 * time.Second
type RunMode int
const (
RunModeHeadless RunMode = iota
RunModeConsole
)
type RunOptions struct {
Mode RunMode
AutoOpenBrowser bool
AutoOpenBrowserSet bool
}
// Manager 管理聊天日志应用
type Manager struct {
ctx *ctx.Context
@@ -36,13 +51,32 @@ type Manager struct {
// Terminal UI
app *App
options RunOptions
initialDecryptOnce sync.Once
initialDecryptMu sync.Mutex
initialDecryptLastErr string
}
func New() *Manager {
return &Manager{}
return &Manager{
options: RunOptions{
Mode: RunModeHeadless,
AutoOpenBrowser: true,
AutoOpenBrowserSet: true,
},
}
}
func (m *Manager) SetRunOptions(opts RunOptions) {
if opts.Mode != RunModeConsole {
opts.Mode = RunModeHeadless
}
if !opts.AutoOpenBrowserSet {
opts.AutoOpenBrowser = m.options.AutoOpenBrowser
opts.AutoOpenBrowserSet = m.options.AutoOpenBrowserSet
}
m.options = opts
}
func (m *Manager) Run(configPath string) error {
@@ -57,7 +91,7 @@ func (m *Manager) Run(configPath string) error {
m.db = database.NewService(m.ctx)
m.http = http.NewService(m.ctx, m.db)
m.http = http.NewService(m.ctx, m.db, m)
instances := m.wechat.GetWeChatInstances()
m.ctx.SetWeChatInstances(instances)
@@ -67,18 +101,76 @@ func (m *Manager) Run(configPath string) error {
m.startInitialDecryptWatcher()
if m.ctx.HTTPEnabled {
// 启动HTTP服务
wantHTTP := m.ctx.HTTPEnabled || m.options.Mode == RunModeHeadless
if wantHTTP {
if err := m.StartService(); err != nil {
m.StopService()
m.stopService()
if m.options.Mode == RunModeHeadless {
return err
}
log.Err(err).Msg("failed to start HTTP service")
}
}
// 启动终端UI
m.app = NewApp(m.ctx, m)
m.app.Run() // 阻塞
if m.options.Mode == RunModeConsole {
m.app = NewApp(m.ctx, m)
return m.app.Run()
}
if url := m.webInterfaceURL(); url != "" {
log.Info().Str("url", url).Msg("Chatlog web interface available")
if m.options.AutoOpenBrowser {
m.launchBrowser(url)
}
}
log.Info().Msg("Chatlog is running in headless mode. Press Ctrl+C to exit.")
m.waitForShutdown()
return nil
}
func (m *Manager) webInterfaceURL() string {
if m.ctx == nil {
return ""
}
addr := strings.TrimSpace(m.ctx.GetHTTPAddr())
if addr == "" {
return ""
}
return util.ComposeLANURL(addr)
}
func (m *Manager) launchBrowser(url string) {
go func() {
time.Sleep(500 * time.Millisecond)
log.Debug().Str("url", url).Msg("launching default browser")
if err := util.OpenBrowser(url); err != nil {
log.Warn().Err(err).Str("url", url).Msg("failed to open browser")
}
}()
}
func (m *Manager) waitForShutdown() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
sig := <-sigCh
log.Info().Msgf("received signal %s, shutting down", sig)
if m.wechat != nil && m.ctx != nil && m.ctx.IsAutoDecrypt() {
if err := m.wechat.StopAutoDecrypt(); err != nil {
log.Warn().Err(err).Msg("failed to stop auto decrypt during shutdown")
}
}
if err := m.stopService(); err != nil {
log.Warn().Err(err).Msg("failed to stop services during shutdown")
}
log.Info().Msg("Shutdown complete")
}
func (m *Manager) startInitialDecryptWatcher() {
m.initialDecryptOnce.Do(func() {
go m.initialDecryptLoop()
@@ -260,7 +352,11 @@ func (m *Manager) SetHTTPAddr(text string) error {
} else {
addr = text
}
m.ctx.SetHTTPAddr(addr)
if m.ctx != nil {
m.ctx.SetHTTPAddr(addr)
} else if m.sc != nil {
m.sc.SetHTTPAddr(addr)
}
return nil
}
@@ -476,7 +572,7 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e
m.db = database.NewService(m.sc)
m.http = http.NewService(m.sc, m.db)
m.http = http.NewService(m.sc, m.db, m)
if m.sc.GetAutoDecrypt() {
if err := m.wechat.StartAutoDecrypt(); err != nil {
+15
View File
@@ -0,0 +1,15 @@
package util
import "fmt"
// OpenBrowser launches the user's default browser with the provided URL.
func OpenBrowser(url string) error {
cmd, err := browserCommand(url)
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start browser: %w", err)
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package util
import (
"errors"
"os/exec"
"runtime"
)
func browserCommand(url string) (*exec.Cmd, error) {
if url == "" {
return nil, errors.New("empty url")
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url), nil
default:
return exec.Command("xdg-open", url), nil
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package util
import (
"errors"
"os/exec"
"syscall"
)
func browserCommand(url string) (*exec.Cmd, error) {
if url == "" {
return nil, errors.New("empty url")
}
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd, nil
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !windows
package util
// HideConsoleWindow is a no-op on non-Windows platforms.
func HideConsoleWindow() {}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
package util
import "syscall"
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procFreeConsole = kernel32.NewProc("FreeConsole")
)
// HideConsoleWindow detaches the process from the current console so no window is shown.
func HideConsoleWindow() {
procFreeConsole.Call()
}