4 Commits
44 changed files with 3504 additions and 839 deletions
+3 -3
View File
@@ -24,12 +24,12 @@ go.work.sum
# env file # env file
.env .env
# icon file
*.ico
# syncthing files # syncthing files
.stfolder .stfolder
chatlog.exe# Added by goreleaser init:
dist/
# Nightly python remake # Nightly python remake
tools/ tools/
+6 -5
View File
@@ -8,7 +8,8 @@ ifeq ($(OS),Windows_NT)
CGO_EXTRA_LDFLAGS := -lgomp CGO_EXTRA_LDFLAGS := -lgomp
endif endif
LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s' LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
CGO_FLAG := CGO_ENABLED=1 CGO_CFLAGS="-I$(abspath $(CURDIR)/include)" CGO_LDFLAGS="-L$(abspath $(CURDIR)/library) $(CGO_EXTRA_LDFLAGS)" CGOFLAGS := CGO_ENABLED=1 CGO_CFLAGS="-I$(abspath $(CURDIR)/include)" CGO_LDFLAGS="-L$(abspath $(CURDIR)/library) $(CGO_EXTRA_LDFLAGS)"
TAGS := --tags "fts5"
PLATFORMS := \ PLATFORMS := \
darwin/amd64 \ darwin/amd64 \
@@ -48,16 +49,16 @@ lint:
tidy: tidy:
@echo "Tidying up dependencies..." @echo "Tidying up dependencies..."
@$(CGO_FLAG) $(GO) mod tidy @$(CGOFLAGS) $(GO) mod tidy
test: test:
@echo "Running tests..." @echo "Running tests..."
@$(CGO_FLAG) $(GO) test ./... -cover @$(CGOFLAGS) $(GO) test ./... -cover
build: build:
@echo "Building for current platform..." @echo "Building for current platform..."
@$(MKDIR_BIN) @$(MKDIR_BIN)
@$(CGO_FLAG) $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)$(BINARY_SUFFIX) main.go @$(CGOFLAGS) $(GO) build -trimpath $(LDFLAGS) $(TAGS) -o bin/$(BINARY_NAME)$(BINARY_SUFFIX) main.go
crossbuild: clean crossbuild: clean
@echo "Building for multiple platforms..." @echo "Building for multiple platforms..."
@@ -71,7 +72,7 @@ crossbuild: clean
[ "$$os" = "windows" ] && output_name=$$output_name.exe; \ [ "$$os" = "windows" ] && output_name=$$output_name.exe; \
echo "Building for $$os/$$arch..."; \ echo "Building for $$os/$$arch..."; \
echo "Building for $$output_name..."; \ echo "Building for $$output_name..."; \
@GOOS=$$os GOARCH=$$arch GOARM=$$float $(CGO_FLAG) $(GO) build -trimpath $(LDFLAGS) -o $$output_name main.go ; \ @GOOS=$$os GOARCH=$$arch GOARM=$$float $(CGOFLAGS) $(GO) build -trimpath $(LDFLAGS) $(TAGS) -o $$output_name main.go ; \
if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$$os/$$arch"; then \ if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$$os/$$arch"; then \
echo "Compressing binary $$output_name..." && upx --best $$output_name; \ echo "Compressing binary $$output_name..." && upx --best $$output_name; \
fi; \ fi; \
+8 -1
View File
@@ -62,12 +62,19 @@ go install github.com/sjzar/chatlog@latest
### Terminal UI 模式 ### Terminal UI 模式
最简单的使用方式是通过 Terminal UI 界面操作: 最简单的使用方式是通过 web 界面操作:
```bash ```bash
chatlog chatlog
``` ```
如果遇到了问题,可以按下面的命令打开调试/控制台前端模式:
```bash
chatlog --debug // 打开调试模式
chatlog --console // 打开控制台前端模式
```
操作方法: 操作方法:
- 使用 `↑` `↓` 键选择菜单项 - 使用 `↑` `↓` 键选择菜单项
+5 -4
View File
@@ -1,6 +1,7 @@
# TODO # TODO
- [ ] 修复 html 渲染部分头像缺失的问题 - [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题
- [x] 美化搜索界面使风格一致 - [ ] 美化搜索界面使风格一致
- [ ] 实现统一接口管理设置的功能 - [x] 实现统一接口管理设置的功能
- [ ] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper) - [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
- [ ] Fix : Systray 右键无响应
+4 -1
View File
@@ -14,7 +14,10 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var Debug bool var (
Debug bool
Console bool
)
func initLog(cmd *cobra.Command, args []string) { func initLog(cmd *cobra.Command, args []string) {
zerolog.SetGlobalLevel(zerolog.InfoLevel) zerolog.SetGlobalLevel(zerolog.InfoLevel)
+27 -1
View File
@@ -1,7 +1,10 @@
package chatlog package chatlog
import ( import (
"runtime"
"github.com/sjzar/chatlog/internal/chatlog" "github.com/sjzar/chatlog/internal/chatlog"
"github.com/sjzar/chatlog/pkg/util"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -12,6 +15,7 @@ func init() {
cobra.MousetrapHelpText = "" cobra.MousetrapHelpText = ""
rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug") rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug")
rootCmd.PersistentFlags().BoolVar(&Console, "console", false, "run with console interface")
rootCmd.PersistentPreRun = initLog rootCmd.PersistentPreRun = initLog
} }
@@ -30,12 +34,34 @@ var rootCmd = &cobra.Command{
CompletionOptions: cobra.CompletionOptions{ CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true, HiddenDefaultCmd: true,
}, },
PreRun: initTuiLog, PreRun: prepareRoot,
Run: Root, Run: Root,
} }
func prepareRoot(cmd *cobra.Command, args []string) {
if Console || !Debug {
initTuiLog(cmd, args)
}
}
func Root(cmd *cobra.Command, args []string) { func Root(cmd *cobra.Command, args []string) {
m := chatlog.New() 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 { if err := m.Run(""); err != nil {
log.Err(err).Msg("failed to run chatlog instance") log.Err(err).Msg("failed to run chatlog instance")
} }
+10 -26
View File
@@ -4,10 +4,11 @@ go 1.24.0
require ( require (
github.com/Eyevinn/mp4ff v0.49.0 github.com/Eyevinn/mp4ff v0.49.0
github.com/blevesearch/bleve/v2 v2.4.2
github.com/cespare/xxhash v1.1.0 github.com/cespare/xxhash v1.1.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gdamore/tcell/v2 v2.8.1 github.com/gdamore/tcell/v2 v2.8.1
github.com/getlantern/systray v1.2.1
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0 github.com/klauspost/compress v1.18.0
@@ -15,7 +16,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.32 github.com/mattn/go-sqlite3 v1.14.32
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/openai/openai-go/v3 v3.3.0 github.com/openai/openai-go/v3 v3.3.0
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca
github.com/pierrec/lz4/v4 v4.1.22 github.com/pierrec/lz4/v4 v4.1.22
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
@@ -32,26 +32,7 @@ require (
) )
require ( require (
github.com/RoaringBitmap/roaring v1.9.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bits-and-blooms/bitset v1.12.0 // indirect
github.com/blevesearch/bleve_index_api v1.1.10 // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.20 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.2.15 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.0.10 // indirect
github.com/blevesearch/zapx/v11 v11.3.10 // indirect
github.com/blevesearch/zapx/v12 v12.3.10 // indirect
github.com/blevesearch/zapx/v13 v13.3.10 // indirect
github.com/blevesearch/zapx/v14 v14.3.10 // indirect
github.com/blevesearch/zapx/v15 v15.3.13 // indirect
github.com/blevesearch/zapx/v16 v16.1.5 // indirect
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
@@ -59,16 +40,20 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect github.com/ebitengine/purego v0.8.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
@@ -82,7 +67,7 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
@@ -103,7 +88,6 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.7 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/term v0.34.0 // indirect golang.org/x/term v0.34.0 // indirect
+25 -53
View File
@@ -2,48 +2,8 @@ github.com/Eyevinn/mp4ff v0.49.0 h1:00eRg5/KwcLGWUbv+hlifldf74qg46G1IxoWXHrgDvw=
github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg= github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM=
github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.4.2 h1:NooYP1mb3c0StkiY9/xviiq2LGSaE8BQBCc/pirMx0U=
github.com/blevesearch/bleve/v2 v2.4.2/go.mod h1:ATNKj7Yl2oJv/lGuF4kx39bST2dveX6w0th2FFYLkc8=
github.com/blevesearch/bleve_index_api v1.1.10 h1:PDLFhVjrjQWr6jCuU7TwlmByQVCSEURADHdCqVS9+g0=
github.com/blevesearch/bleve_index_api v1.1.10/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.20 h1:AIkdTQFWuZ5LQmKQSebgMR4RynGNw8ZseJXaan5kvtI=
github.com/blevesearch/go-faiss v1.0.20/go.mod h1:jrxHrbl42X/RnDPI+wBoZU8joxxuRwedrxqswQ3xfU8=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.2.15 h1:prV17iU/o+A8FiZi9MXmqbagd8I0bCqM7OKUYPbnb5Y=
github.com/blevesearch/scorch_segment_api/v2 v2.2.15/go.mod h1:db0cmP03bPNadXrCDuVkKLV6ywFSiRgPFT1YVrestBc=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.0.10 h1:HGPJDT2bTva12hrHepVT3rOyIKFFF4t7Gf6yMxyMIPI=
github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k=
github.com/blevesearch/zapx/v11 v11.3.10 h1:hvjgj9tZ9DeIqBCxKhi70TtSZYMdcFn7gDb71Xo/fvk=
github.com/blevesearch/zapx/v11 v11.3.10/go.mod h1:0+gW+FaE48fNxoVtMY5ugtNHHof/PxCqh7CnhYdnMzQ=
github.com/blevesearch/zapx/v12 v12.3.10 h1:yHfj3vXLSYmmsBleJFROXuO08mS3L1qDCdDK81jDl8s=
github.com/blevesearch/zapx/v12 v12.3.10/go.mod h1:0yeZg6JhaGxITlsS5co73aqPtM04+ycnI6D1v0mhbCs=
github.com/blevesearch/zapx/v13 v13.3.10 h1:0KY9tuxg06rXxOZHg3DwPJBjniSlqEgVpxIqMGahDE8=
github.com/blevesearch/zapx/v13 v13.3.10/go.mod h1:w2wjSDQ/WBVeEIvP0fvMJZAzDwqwIEzVPnCPrz93yAk=
github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU=
github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
github.com/blevesearch/zapx/v15 v15.3.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wyhnpDHHQ=
github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
github.com/blevesearch/zapx/v16 v16.1.5 h1:b0sMcarqNFxuXvjoXsF8WtwVahnxyhEvBSRJi/AUHjU=
github.com/blevesearch/zapx/v16 v16.1.5/go.mod h1:J4mSF39w1QELc11EWRSBFkPeZuO7r/NPKkHzDCoiaI8=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
@@ -71,12 +31,32 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI=
github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca h1:7R4qXa1dsEI8cpZyZdFt/o2TlstGI4xmew115XYronI= github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca h1:7R4qXa1dsEI8cpZyZdFt/o2TlstGI4xmew115XYronI=
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca/go.mod h1:qyHjS/50ORo01H0NsuEEGsQR9VCtOcEye0gUl2sx1s8= github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20251015072942-4979e04f5dca/go.mod h1:qyHjS/50ORo01H0NsuEEGsQR9VCtOcEye0gUl2sx1s8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -88,6 +68,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -95,13 +77,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -152,10 +127,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/openai/openai-go/v3 v3.3.0 h1:3Xu4+3pJW5HG5hzMflakVbp3IB0uSq70nbLVZZd8AwY= github.com/openai/openai-go/v3 v3.3.0 h1:3Xu4+3pJW5HG5hzMflakVbp3IB0uSq70nbLVZZd8AwY=
github.com/openai/openai-go/v3 v3.3.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs= github.com/openai/openai-go/v3 v3.3.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
@@ -239,8 +214,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -274,6 +247,7 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -320,8 +294,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2 -2
View File
@@ -1176,7 +1176,7 @@ func (a *App) settingDataKey() {
// 添加按钮 - 点击保存时才设置数据密钥 // 添加按钮 - 点击保存时才设置数据密钥
formView.AddButton("保存", func() { formView.AddButton("保存", func() {
a.ctx.DataKey = tempDataKey // 设置数据密钥 a.ctx.SetDataKey(tempDataKey)
a.mainPages.RemovePage("submenu2") a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu() a.refreshSettingsMenu()
a.showInfo("数据密钥已设置") a.showInfo("数据密钥已设置")
@@ -1230,7 +1230,7 @@ func (a *App) settingDataDir() {
// 添加按钮 - 点击保存时才设置数据目录 // 添加按钮 - 点击保存时才设置数据目录
formView.AddButton("保存", func() { formView.AddButton("保存", func() {
a.ctx.DataDir = tempDataDir // 设置数据目录 a.ctx.SetDataDir(tempDataDir)
a.mainPages.RemovePage("submenu2") a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu() a.refreshSettingsMenu()
a.showInfo("数据目录已设置为 " + a.ctx.DataDir) a.showInfo("数据目录已设置为 " + a.ctx.DataDir)
+28
View File
@@ -63,3 +63,31 @@ func (c *ServerConfig) GetWebhook() *Webhook {
func (c *ServerConfig) GetSpeech() *SpeechConfig { func (c *ServerConfig) GetSpeech() *SpeechConfig {
return c.Speech 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 return c.DataKey
} }
func (c *Context) GetImgKey() string {
return c.ImgKey
}
func (c *Context) GetHTTPAddr() string { func (c *Context) GetHTTPAddr() string {
if c.HTTPAddr == "" { if c.HTTPAddr == "" {
c.HTTPAddr = DefalutHTTPAddr c.HTTPAddr = DefalutHTTPAddr
@@ -251,6 +255,14 @@ func (c *Context) GetHTTPAddr() string {
return c.HTTPAddr 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 { func (c *Context) GetWebhook() *conf.Webhook {
return c.conf.Webhook return c.conf.Webhook
} }
@@ -311,6 +323,16 @@ func (c *Context) SetImgKey(key string) {
c.UpdateConfig() 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) { func (c *Context) SetAutoDecrypt(enabled bool) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() 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
}
+64 -26
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io"
"io/fs" "io/fs"
"math" "math"
"net/http" "net/http"
@@ -128,6 +129,33 @@ var previewVoiceSnippet = `
var previewHTMLSnippet = previewHTMLSnippetBase + previewVoiceSnippet var previewHTMLSnippet = previewHTMLSnippetBase + previewVoiceSnippet
var chatlogHTMLHeadTemplate = `<html><head><meta charset="utf-8"><title>%s</title><style>
body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;background:#f8f9fb;padding:24px;color:#2c3e50;}
h1{margin:0 0 16px;font-size:22px;}
h2{margin:24px 0 12px;font-size:18px;}
p.meta{margin:4px 0;color:#5f6c7b;}
.search-meta{background:#fff;padding:18px;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);margin-bottom:18px;}
details{margin:8px 0;padding:6px 10px;border:1px solid #dde1eb;border-radius:8px;background:#fff;box-shadow:0 1px 3px rgba(18,38,63,0.06);}
summary{cursor:pointer;font-weight:600;color:#2c3e50;}
.msg{margin:12px 0;padding:12px 14px;border-left:3px solid #3498db;background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(18,38,63,0.08);}
.msg-row{display:flex;gap:12px;align-items:flex-start;}
.avatar{width:36px;height:36px;border-radius:9px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 36px;}
.msg-content{flex:1;min-width:0;}
.meta{color:#5f6c7b;font-size:12px;display:flex;flex-wrap:wrap;gap:12px;margin-bottom:6px;align-items:center;}
.meta .talker{color:#2c3e50;font-weight:600;}
.meta .sender{color:#2c3e50;}
.meta .time{color:#16a085;}
.meta .score{font-family:monospace;color:#a0aec0;}
pre{white-space:pre-wrap;word-break:break-word;margin:6px 0 0;}
.empty{padding:28px;text-align:center;color:#768390;background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);}
a.media{color:#2c3e50;text-decoration:none;border-bottom:1px dashed rgba(44,62,80,0.45);}
a.media:hover{color:#0f4c81;}
</style></head><body>`
func writeChatlogHTMLHeader(w io.Writer, title string) {
fmt.Fprintf(w, chatlogHTMLHeadTemplate, template.HTMLEscapeString(title))
}
func (s *Service) initRouter() { func (s *Service) initRouter() {
s.initBaseRouter() s.initBaseRouter()
s.initMediaRouter() s.initMediaRouter()
@@ -162,15 +190,27 @@ func (s *Service) initMediaRouter() {
} }
func (s *Service) initAPIRouter() { 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("/setting", s.handleGetSetting)
api.GET("/contact", s.handleContacts) api.POST("/setting", s.handleUpdateSetting)
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions) actions := api.Group("/actions")
api.GET("/diary", s.handleDiary) actions.POST("/get-data-key", s.handleActionGetDataKey)
api.GET("/dashboard", s.handleDashboard) actions.POST("/decrypt", s.handleActionDecrypt)
api.GET("/search", s.handleSearch) 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)
} }
} }
@@ -913,9 +953,9 @@ func (s *Service) handleSearch(c *gin.Context) {
switch format { switch format {
case "html": case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString(`<html><head><meta charset="utf-8"><title>Search Result</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;background:#f8f9fb;padding:24px;color:#2c3e50;}h1{margin:0 0 16px;font-size:22px;}h2{margin:24px 0 12px;font-size:18px;}p.meta{margin:4px 0;color:#5f6c7b;} .summary{background:#fff;padding:18px;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);margin-bottom:18px;} .result-list{display:flex;flex-direction:column;gap:16px;} .hit{background:#fff;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(18,38,63,0.08);} .hit-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px;color:#5f6c7b;} .hit-header .talker{font-weight:600;color:#2c3e50;} .hit-header .score{font-family:monospace;color:#a0aec0;} .snippet{margin-bottom:10px;font-size:13px;color:#4a5568;padding:10px;border-left:3px solid #3498db;background:rgba(52,152,219,0.08);border-radius:6px;} .snippet code{background:rgba(27,31,35,0.05);padding:2px 4px;border-radius:4px;} .msg-content pre{white-space:pre-wrap;word-break:break-word;margin:0;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:13px;color:#1f2933;} .meta-row{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:8px;font-size:13px;color:#5f6c7b;} .meta-row span{display:inline-flex;align-items:center;gap:4px;} .search-highlight{background:#fff3b0;color:inherit;padding:0 2px;border-radius:3px;} .empty{padding:28px;text-align:center;color:#768390;background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(18,38,63,0.08);} a.media{color:#2c3e50;text-decoration:none;border-bottom:1px dashed #2c3e50;} a.media:hover{color:#0f4c81;} </style></head><body>`) writeChatlogHTMLHeader(c.Writer, "Search Result")
c.Writer.WriteString("<div class=\"summary\">")
c.Writer.WriteString("<h1>搜索结果</h1>") c.Writer.WriteString("<h1>搜索结果</h1>")
c.Writer.WriteString("<div class=\"search-meta\">")
if resp.Query != "" { if resp.Query != "" {
c.Writer.WriteString("<p class=\"meta\"><strong>关键词:</strong>" + template.HTMLEscapeString(resp.Query) + "</p>") c.Writer.WriteString("<p class=\"meta\"><strong>关键词:</strong>" + template.HTMLEscapeString(resp.Query) + "</p>")
} }
@@ -942,7 +982,6 @@ func (s *Service) handleSearch(c *gin.Context) {
if len(resp.Hits) == 0 { if len(resp.Hits) == 0 {
c.Writer.WriteString("<div class=\"empty\">暂无搜索结果</div>") c.Writer.WriteString("<div class=\"empty\">暂无搜索结果</div>")
} else { } else {
c.Writer.WriteString("<div class=\"result-list\">")
for idx, hit := range resp.Hits { for idx, hit := range resp.Hits {
if hit == nil || hit.Message == nil { if hit == nil || hit.Message == nil {
continue continue
@@ -960,22 +999,19 @@ func (s *Service) handleSearch(c *gin.Context) {
if msg.SenderName != "" { if msg.SenderName != "" {
senderDisplay = fmt.Sprintf("%s(%s)", msg.SenderName, msg.Sender) senderDisplay = fmt.Sprintf("%s(%s)", msg.SenderName, msg.Sender)
} }
c.Writer.WriteString("<div class=\"hit\">") avatarURL := template.HTMLEscapeString(s.composeAvatarURL(msg.Sender) + "?size=big")
c.Writer.WriteString("<div class=\"hit-header\"><span class=\"talker\">#" + fmt.Sprintf("%d", idx+1) + " · " + template.HTMLEscapeString(talkerDisplay) + "</span>") talkerText := template.HTMLEscapeString(talkerDisplay)
senderText := template.HTMLEscapeString(senderDisplay)
timeText := template.HTMLEscapeString(msg.Time.Format("2006-01-02 15:04:05"))
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\"><img class=\"avatar\" src=\"" + avatarURL + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/><div class=\"msg-content\">")
c.Writer.WriteString("<div class=\"meta\"><span class=\"talker\">#" + fmt.Sprintf("%d", idx+1) + " · " + talkerText + "</span><span class=\"sender\">" + senderText + "</span><span class=\"time\">" + timeText + "</span>")
if hit.Score > 0 { if hit.Score > 0 {
c.Writer.WriteString("<span class=\"score\">score: " + fmt.Sprintf("%.4f", hit.Score) + "</span>") c.Writer.WriteString("<span class=\"score\">score: " + fmt.Sprintf("%.4f", hit.Score) + "</span>")
} }
c.Writer.WriteString("</div>") c.Writer.WriteString("</div>")
c.Writer.WriteString("<div class=\"meta-row\"><span>时间:" + template.HTMLEscapeString(msg.Time.Format("2006-01-02 15:04:05")) + "</span><span>发送者:" + template.HTMLEscapeString(senderDisplay) + "</span></div>") c.Writer.WriteString("<pre>" + messageHTMLPlaceholder(msg) + "</pre>")
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" { c.Writer.WriteString("</div></div></div>")
escaped := template.HTMLEscapeString(snippet)
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
c.Writer.WriteString("<div class=\"snippet\">" + escaped + "</div>")
}
c.Writer.WriteString("<div class=\"msg-content\"><pre>" + messageHTMLPlaceholder(msg) + "</pre></div>")
c.Writer.WriteString("</div>")
} }
c.Writer.WriteString("</div>")
} }
c.Writer.WriteString(previewHTMLSnippet) c.Writer.WriteString(previewHTMLSnippet)
c.Writer.WriteString("</body></html>") c.Writer.WriteString("</body></html>")
@@ -1121,7 +1157,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch format { switch format {
case "html": case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>") writeChatlogHTMLHeader(c.Writer, "Chatlog")
c.Writer.WriteString(fmt.Sprintf("<h2>All Messages %s ~ %s</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))) c.Writer.WriteString(fmt.Sprintf("<h2>All Messages %s ~ %s</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
for _, g := range groups { for _, g := range groups {
title := g.Talker title := g.Talker
@@ -1141,7 +1177,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
senderDisplay = template.HTMLEscapeString(senderDisplay) senderDisplay = template.HTMLEscapeString(senderDisplay)
} }
aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big") aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big")
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\"><img class=\"avatar\" src=\"" + aurl + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/><div class=\"msg-content\"><div class=\"meta\"><span class=\"sender>" + senderDisplay + "</span><span class=\"time\">" + m.Time.Format("2006-01-02 15:04:05") + "</span></div><pre>" + messageHTMLPlaceholder(m) + "</pre></div></div></div>") timeText := template.HTMLEscapeString(m.Time.Format("2006-01-02 15:04:05"))
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\"><img class=\"avatar\" src=\"" + aurl + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/><div class=\"msg-content\"><div class=\"meta\"><span class=\"sender\">" + senderDisplay + "</span><span class=\"time\">" + timeText + "</span></div><pre>" + messageHTMLPlaceholder(m) + "</pre></div></div></div>")
} }
c.Writer.WriteString("</details>") c.Writer.WriteString("</details>")
} }
@@ -1201,7 +1238,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch format { switch format {
case "html": case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>") writeChatlogHTMLHeader(c.Writer, "Chatlog")
c.Writer.WriteString(fmt.Sprintf("<h2>Messages %s ~ %s (%s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker))) c.Writer.WriteString(fmt.Sprintf("<h2>Messages %s ~ %s (%s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
for _, m := range messages { for _, m := range messages {
m.SetContent("host", c.Request.Host) m.SetContent("host", c.Request.Host)
@@ -1216,7 +1253,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
if m.SenderName != "" { if m.SenderName != "" {
c.Writer.WriteString(")") c.Writer.WriteString(")")
} }
c.Writer.WriteString("</span><span class=\"time\">" + m.Time.Format("2006-01-02 15:04:05") + "</span></div><pre>") timeText := template.HTMLEscapeString(m.Time.Format("2006-01-02 15:04:05"))
c.Writer.WriteString("</span><span class=\"time\">" + timeText + "</span></div><pre>")
c.Writer.WriteString(messageHTMLPlaceholder(m)) c.Writer.WriteString(messageHTMLPlaceholder(m))
c.Writer.WriteString("</pre></div></div></div>") c.Writer.WriteString("</pre></div></div></div>")
} }
+29 -6
View File
@@ -17,8 +17,9 @@ import (
) )
type Service struct { type Service struct {
conf Config conf Config
db *database.Service db *database.Service
control Control
router *gin.Engine router *gin.Engine
server *http.Server server *http.Server
@@ -33,11 +34,32 @@ type Service struct {
type Config interface { type Config interface {
GetHTTPAddr() string GetHTTPAddr() string
SetHTTPAddr(string)
GetDataDir() string GetDataDir() string
SetDataDir(string)
GetWorkDir() string
SetWorkDir(string)
GetDataKey() string
SetDataKey(string)
GetImgKey() string
SetImgKey(string)
IsHTTPEnabled() bool
IsAutoDecrypt() bool
GetSpeech() *conf.SpeechConfig 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) gin.SetMode(gin.ReleaseMode)
router := gin.New() router := gin.New()
@@ -55,9 +77,10 @@ func NewService(conf Config, db *database.Service) *Service {
) )
s := &Service{ s := &Service{
conf: conf, conf: conf,
db: db, db: db,
router: router, control: control,
router: router,
} }
s.initMCPServer() s.initMCPServer()
File diff suppressed because it is too large Load Diff
+163 -11
View File
@@ -4,8 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -14,6 +17,7 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/database" "github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/http" "github.com/sjzar/chatlog/internal/chatlog/http"
"github.com/sjzar/chatlog/internal/chatlog/wechat" "github.com/sjzar/chatlog/internal/chatlog/wechat"
"github.com/sjzar/chatlog/internal/tray"
iwechat "github.com/sjzar/chatlog/internal/wechat" iwechat "github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config" "github.com/sjzar/chatlog/pkg/config"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
@@ -22,6 +26,19 @@ import (
const initialDecryptPollInterval = 5 * time.Second const initialDecryptPollInterval = 5 * time.Second
type RunMode int
const (
RunModeHeadless RunMode = iota
RunModeConsole
)
type RunOptions struct {
Mode RunMode
AutoOpenBrowser bool
AutoOpenBrowserSet bool
}
// Manager 管理聊天日志应用 // Manager 管理聊天日志应用
type Manager struct { type Manager struct {
ctx *ctx.Context ctx *ctx.Context
@@ -34,15 +51,40 @@ type Manager struct {
wechat *wechat.Service wechat *wechat.Service
// Terminal UI // Terminal UI
app *App app *App
trayCtrl tray.Controller
options RunOptions
initialDecryptOnce sync.Once initialDecryptOnce sync.Once
initialDecryptMu sync.Mutex initialDecryptMu sync.Mutex
initialDecryptLastErr string initialDecryptLastErr string
shutdownCh chan struct{}
shutdownOnce sync.Once
shutdownReason string
} }
func New() *Manager { func New() *Manager {
return &Manager{} return &Manager{
options: RunOptions{
Mode: RunModeHeadless,
AutoOpenBrowser: true,
AutoOpenBrowserSet: true,
},
shutdownCh: make(chan struct{}),
}
}
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 { func (m *Manager) Run(configPath string) error {
@@ -57,7 +99,7 @@ func (m *Manager) Run(configPath string) error {
m.db = database.NewService(m.ctx) 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() instances := m.wechat.GetWeChatInstances()
m.ctx.SetWeChatInstances(instances) m.ctx.SetWeChatInstances(instances)
@@ -67,18 +109,124 @@ func (m *Manager) Run(configPath string) error {
m.startInitialDecryptWatcher() m.startInitialDecryptWatcher()
if m.ctx.HTTPEnabled { wantHTTP := m.ctx.HTTPEnabled || m.options.Mode == RunModeHeadless
// 启动HTTP服务 if wantHTTP {
if err := m.StartService(); err != nil { 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) if m.options.Mode == RunModeConsole {
m.app.Run() // 阻塞 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)
}
}
if runtime.GOOS == "windows" {
ctrl, err := tray.Start(tray.Options{
Tooltip: "Chatlog",
OnOpen: func() {
if next := m.webInterfaceURL(); next != "" {
m.launchBrowser(next)
}
},
OnQuit: func() {
m.requestShutdown("tray menu exit")
},
})
if err != nil {
log.Warn().Err(err).Msg("failed to start system tray icon")
} else {
m.trayCtrl = ctrl
}
}
log.Info().Msg("Chatlog is running in headless mode. Press Ctrl+C to exit.")
m.waitForShutdown()
return nil 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) {
if strings.TrimSpace(url) == "" {
return
}
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)
var reason string
select {
case sig := <-sigCh:
reason = fmt.Sprintf("received signal %s", sig)
case <-m.shutdownCh:
reason = m.shutdownReason
if reason == "" {
reason = "shutdown requested"
}
}
log.Info().Msgf("%s, shutting down", reason)
m.stopTray()
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) requestShutdown(reason string) {
m.shutdownOnce.Do(func() {
m.shutdownReason = reason
close(m.shutdownCh)
})
}
func (m *Manager) stopTray() {
if m.trayCtrl == nil {
return
}
m.trayCtrl.Stop()
m.trayCtrl = nil
}
func (m *Manager) startInitialDecryptWatcher() { func (m *Manager) startInitialDecryptWatcher() {
m.initialDecryptOnce.Do(func() { m.initialDecryptOnce.Do(func() {
go m.initialDecryptLoop() go m.initialDecryptLoop()
@@ -260,7 +408,11 @@ func (m *Manager) SetHTTPAddr(text string) error {
} else { } else {
addr = text 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 return nil
} }
@@ -476,7 +628,7 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e
m.db = database.NewService(m.sc) 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 m.sc.GetAutoDecrypt() {
if err := m.wechat.StartAutoDecrypt(); err != nil { if err := m.wechat.StartAutoDecrypt(); err != nil {
+4
View File
@@ -166,6 +166,10 @@ func (m *MessageWebhook) Do(event fsnotify.Event) {
return return
} }
if err := m.db.IndexMessages(messages); err != nil {
log.Warn().Err(err).Msg("incremental fts update failed")
}
m.lastTime = messages[len(messages)-1].Time.Add(time.Second) m.lastTime = messages[len(messages)-1].Time.Add(time.Second)
for _, message := range messages { for _, message := range messages {
+4
View File
@@ -30,6 +30,10 @@ func TalkerNotFound(talker string) *Error {
return Newf(nil, http.StatusNotFound, "talker not found: %s", talker).WithStack() return Newf(nil, http.StatusNotFound, "talker not found: %s", talker).WithStack()
} }
func MessageStoreNotFound(key string) *Error {
return Newf(nil, http.StatusNotFound, "message store not found: %s", key).WithStack()
}
func DBCloseFailed(cause error) *Error { func DBCloseFailed(cause error) *Error {
return New(cause, http.StatusInternalServerError, "db close failed").WithStack() return New(cause, http.StatusInternalServerError, "db close failed").WithStack()
} }
+4 -4
View File
@@ -2,8 +2,8 @@ package model
// Avatar represents a user's avatar, either as a remote URL (v3) or inline bytes (v4) // Avatar represents a user's avatar, either as a remote URL (v3) or inline bytes (v4)
type Avatar struct { type Avatar struct {
Username string `json:"username"` Username string `json:"username"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
ContentType string `json:"-"` ContentType string `json:"-"`
Data []byte `json:"-"` Data []byte `json:"-"`
} }
+9 -9
View File
@@ -2,13 +2,13 @@ package model
// IntimacyBase 聚合每个会话/联系人基础统计,用于计算亲密度 // IntimacyBase 聚合每个会话/联系人基础统计,用于计算亲密度
type IntimacyBase struct { type IntimacyBase struct {
UserName string UserName string
MsgCount int64 MsgCount int64
SentCount int64 SentCount int64
ReceivedCount int64 ReceivedCount int64
MinCreateUnix int64 MinCreateUnix int64
MaxCreateUnix int64 MaxCreateUnix int64
MessagingDays int64 MessagingDays int64
Last90DaysMsg int64 Last90DaysMsg int64
Past7DaysSentMsg int64 Past7DaysSentMsg int64
} }
+1 -1
View File
@@ -52,7 +52,7 @@ type SearchResponse struct {
Index *SearchIndexStatus `json:"index_status,omitempty"` Index *SearchIndexStatus `json:"index_status,omitempty"`
} }
// SearchIndexStatus 表示 Bleve 索引的构建状态 // SearchIndexStatus 表示全文索引的构建状态
type SearchIndexStatus struct { type SearchIndexStatus struct {
Ready bool `json:"ready"` Ready bool `json:"ready"`
InProgress bool `json:"in_progress"` InProgress bool `json:"in_progress"`
+9 -9
View File
@@ -2,17 +2,17 @@ package model
// GlobalMessageStats 汇总消息统计 // GlobalMessageStats 汇总消息统计
type GlobalMessageStats struct { type GlobalMessageStats struct {
Total int64 `json:"total"` Total int64 `json:"total"`
Sent int64 `json:"sent"` Sent int64 `json:"sent"`
Received int64 `json:"received"` Received int64 `json:"received"`
EarliestUnix int64 `json:"earliest_unix"` EarliestUnix int64 `json:"earliest_unix"`
LatestUnix int64 `json:"latest_unix"` LatestUnix int64 `json:"latest_unix"`
ByType map[string]int64 `json:"by_type"` // 例如:{"文本":123, "图片":456} ByType map[string]int64 `json:"by_type"` // 例如:{"文本":123, "图片":456}
} }
// MonthlyTrend 月度趋势 // MonthlyTrend 月度趋势
type MonthlyTrend struct { type MonthlyTrend struct {
Date string `json:"date"` // YYYY-MM Date string `json:"date"` // YYYY-MM
Sent int64 `json:"sent"` Sent int64 `json:"sent"`
Received int64 `json:"received"` Received int64 `json:"received"`
} }
+13
View File
@@ -0,0 +1,13 @@
package tray
// Options controls how the tray icon behaves.
type Options struct {
Tooltip string
OnOpen func()
OnQuit func()
}
// Controller allows callers to stop the tray icon when shutting down.
type Controller interface {
Stop()
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package tray
type noopController struct{}
func (noopController) Stop() {}
// Start is a no-op on platforms without a system tray implementation.
func Start(opts Options) (Controller, error) {
return noopController{}, nil
}
+122
View File
@@ -0,0 +1,122 @@
//go:build windows
package tray
import (
"errors"
"os"
"path/filepath"
"sync"
"github.com/getlantern/systray"
"github.com/rs/zerolog/log"
)
type controller struct {
stopOnce sync.Once
stopped chan struct{}
}
func newController() *controller {
return &controller{stopped: make(chan struct{})}
}
func (c *controller) Stop() {
c.stopOnce.Do(func() {
systray.Quit()
})
<-c.stopped
}
var (
iconInit sync.Once
iconData []byte
iconErr error
)
func trayIcon() ([]byte, error) {
iconInit.Do(func() {
iconData, iconErr = loadIcon()
})
return iconData, iconErr
}
func loadIcon() ([]byte, error) {
const iconName = "icon.ico"
paths := []string{
iconName,
}
exePath, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exePath)
paths = append(paths, filepath.Join(exeDir, iconName))
}
var errs error
for _, candidate := range paths {
if data, readErr := os.ReadFile(candidate); readErr == nil {
return data, nil
} else {
errs = errors.Join(errs, readErr)
}
}
return nil, errs
}
// Start launches the Windows notification area icon.
func Start(opts Options) (Controller, error) {
ctrl := newController()
ready := make(chan struct{})
go systray.Run(func() {
setupTray(opts, ctrl)
close(ready)
}, func() {
close(ctrl.stopped)
})
<-ready
return ctrl, nil
}
func setupTray(opts Options, ctrl *controller) {
if data, err := trayIcon(); err != nil {
log.Warn().Err(err).Msg("failed to load tray icon from icon.ico")
} else if len(data) > 0 {
systray.SetIcon(data)
} else {
log.Warn().Msg("tray icon icon.ico is empty")
}
tip := opts.Tooltip
if tip == "" {
tip = "Chatlog"
}
systray.SetTooltip(tip)
openItem := systray.AddMenuItem("Open Chatlog", "Open Chatlog web interface")
systray.AddSeparator()
quitItem := systray.AddMenuItem("Exit Chatlog", "Quit Chatlog")
go func() {
for {
select {
case <-openItem.ClickedCh:
if opts.OnOpen != nil {
opts.OnOpen()
}
case <-quitItem.ClickedCh:
if opts.OnQuit != nil {
opts.OnQuit()
}
ctrl.Stop()
return
case <-ctrl.stopped:
return
}
}
}()
}
@@ -6,9 +6,11 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -18,6 +20,7 @@ import (
"github.com/sjzar/chatlog/internal/errors" "github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model" "github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm" "github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
) )
@@ -63,14 +66,20 @@ type DataSource struct {
talkerDBMap map[string]string talkerDBMap map[string]string
user2DisplayName map[string]string user2DisplayName map[string]string
messageStores []*msgstore.Store
messageStoreByPath map[string]*msgstore.Store
messageStoreMu sync.RWMutex
} }
func New(path string) (*DataSource, error) { func New(path string) (*DataSource, error) {
ds := &DataSource{ ds := &DataSource{
path: path, path: path,
dbm: dbm.NewDBManager(path), dbm: dbm.NewDBManager(path),
talkerDBMap: make(map[string]string), talkerDBMap: make(map[string]string),
user2DisplayName: make(map[string]string), user2DisplayName: make(map[string]string),
messageStores: make([]*msgstore.Store, 0),
messageStoreByPath: make(map[string]*msgstore.Store),
} }
for _, g := range Groups { for _, g := range Groups {
@@ -114,6 +123,56 @@ func (ds *DataSource) SetCallback(group string, callback func(event fsnotify.Eve
return ds.dbm.AddCallback(group, callback) return ds.dbm.AddCallback(group, callback)
} }
func (ds *DataSource) ListMessageStores(ctx context.Context) ([]*msgstore.Store, error) {
_ = ctx
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
stores := make([]*msgstore.Store, len(ds.messageStores))
for i, store := range ds.messageStores {
stores[i] = store.Clone()
}
return stores, nil
}
func (ds *DataSource) LocateMessageStore(msg *model.Message) (*msgstore.Store, error) {
if msg == nil {
return nil, errors.MessageStoreNotFound("nil message")
}
talker := strings.TrimSpace(msg.Talker)
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
if talker != "" {
hash := md5.Sum([]byte(talker))
key := hex.EncodeToString(hash[:])
if path, ok := ds.talkerDBMap[key]; ok {
if store, exists := ds.messageStoreByPath[path]; exists {
return store, nil
}
}
}
ts := msg.Time
if !ts.IsZero() {
for _, store := range ds.messageStores {
if store.StartTime.IsZero() || store.EndTime.IsZero() {
continue
}
if (ts.Equal(store.StartTime) || ts.After(store.StartTime)) && ts.Before(store.EndTime) {
return store, nil
}
}
}
if talker == "" {
talker = "unknown"
}
return nil, errors.MessageStoreNotFound(talker)
}
func (ds *DataSource) initMessageDbs() error { func (ds *DataSource) initMessageDbs() error {
dbPaths, err := ds.dbm.GetDBPath(Message) dbPaths, err := ds.dbm.GetDBPath(Message)
@@ -126,6 +185,8 @@ func (ds *DataSource) initMessageDbs() error {
} }
// 处理每个数据库文件 // 处理每个数据库文件
talkerDBMap := make(map[string]string) talkerDBMap := make(map[string]string)
storeByPath := make(map[string]*msgstore.Store)
stores := make([]*msgstore.Store, 0, len(dbPaths))
for _, filePath := range dbPaths { for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath) db, err := ds.dbm.OpenDB(filePath)
if err != nil { if err != nil {
@@ -133,30 +194,83 @@ func (ds *DataSource) initMessageDbs() error {
continue continue
} }
baseName := filepath.Base(filePath)
id := strings.TrimSuffix(baseName, filepath.Ext(baseName))
store := &msgstore.Store{
ID: id,
FilePath: filePath,
FileName: baseName,
IndexPath: filepath.Join(ds.path, "indexes", "messages", id+".fts.db"),
Talkers: make(map[string]struct{}),
}
var minTS int64
var maxTS int64
haveMin := false
haveMax := false
// 获取所有表名 // 获取所有表名
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'") rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
if err != nil { if err != nil {
log.Err(err).Msgf("数据库 %s 中没有 Chat 表", filePath) log.Err(err).Msgf("数据库 %s 中没有 Chat 表", filePath)
continue } else {
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
log.Err(err).Msgf("数据库 %s 扫描表名失败", filePath)
continue
}
// 从表名中提取可能的talker信息
talkerMd5 := extractTalkerFromTableName(tableName)
if talkerMd5 == "" {
continue
}
talkerDBMap[talkerMd5] = filePath
store.Talkers[talkerMd5] = struct{}{}
row := db.QueryRow(fmt.Sprintf("SELECT MIN(MsgCreateTime), MAX(MsgCreateTime) FROM %s", tableName))
var tableMin, tableMax sql.NullInt64
if err := row.Scan(&tableMin, &tableMax); err != nil {
log.Debug().Err(err).Msgf("查询 %s 时间范围失败", tableName)
continue
}
if tableMin.Valid {
if !haveMin || tableMin.Int64 < minTS {
minTS = tableMin.Int64
}
haveMin = true
}
if tableMax.Valid {
if !haveMax || tableMax.Int64 > maxTS {
maxTS = tableMax.Int64
}
haveMax = true
}
}
rows.Close()
} }
for rows.Next() { if len(store.Talkers) == 0 {
var tableName string store.Talkers = nil
if err := rows.Scan(&tableName); err != nil {
log.Err(err).Msgf("数据库 %s 扫描表名失败", filePath)
continue
}
// 从表名中提取可能的talker信息
talkerMd5 := extractTalkerFromTableName(tableName)
if talkerMd5 == "" {
continue
}
talkerDBMap[talkerMd5] = filePath
} }
rows.Close() if haveMin {
store.StartTime = time.Unix(minTS, 0)
}
if haveMax {
store.EndTime = time.Unix(maxTS, 0).Add(time.Second)
}
stores = append(stores, store)
storeByPath[filePath] = store
} }
ds.talkerDBMap = talkerDBMap ds.talkerDBMap = talkerDBMap
ds.messageStoreMu.Lock()
ds.messageStores = stores
ds.messageStoreByPath = storeByPath
ds.messageStoreMu.Unlock()
return nil return nil
} }
@@ -11,9 +11,11 @@ import (
"github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3" "github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3"
v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4" v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3" "github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
) )
type DataSource interface { type DataSource interface {
msgstore.Provider
// 消息 // 消息
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error)
+118 -3
View File
@@ -6,9 +6,11 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -18,6 +20,7 @@ import (
"github.com/sjzar/chatlog/internal/errors" "github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model" "github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm" "github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
) )
@@ -75,14 +78,22 @@ type DataSource struct {
// 消息数据库信息 // 消息数据库信息
messageInfos []MessageDBInfo messageInfos []MessageDBInfo
talkerDBMap map[string]string
messageStores []*msgstore.Store
messageStoreByPath map[string]*msgstore.Store
messageStoreMu sync.RWMutex
} }
func New(path string) (*DataSource, error) { func New(path string) (*DataSource, error) {
ds := &DataSource{ ds := &DataSource{
path: path, path: path,
dbm: dbm.NewDBManager(path), dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0), messageInfos: make([]MessageDBInfo, 0),
talkerDBMap: make(map[string]string),
messageStores: make([]*msgstore.Store, 0),
messageStoreByPath: make(map[string]*msgstore.Store),
} }
for _, g := range Groups { for _, g := range Groups {
@@ -117,6 +128,53 @@ func (ds *DataSource) SetCallback(group string, callback func(event fsnotify.Eve
return ds.dbm.AddCallback(group, callback) return ds.dbm.AddCallback(group, callback)
} }
func (ds *DataSource) ListMessageStores(ctx context.Context) ([]*msgstore.Store, error) {
_ = ctx
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
stores := make([]*msgstore.Store, len(ds.messageStores))
for i, store := range ds.messageStores {
stores[i] = store.Clone()
}
return stores, nil
}
func (ds *DataSource) LocateMessageStore(msg *model.Message) (*msgstore.Store, error) {
if msg == nil {
return nil, errors.MessageStoreNotFound("nil message")
}
talker := strings.TrimSpace(msg.Talker)
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
if talker != "" {
hash := md5.Sum([]byte(talker))
key := hex.EncodeToString(hash[:])
if path, ok := ds.talkerDBMap[key]; ok {
if store, exists := ds.messageStoreByPath[path]; exists {
return store, nil
}
}
}
ts := msg.Time
if !ts.IsZero() {
for _, store := range ds.messageStores {
if (ts.Equal(store.StartTime) || ts.After(store.StartTime)) && ts.Before(store.EndTime) {
return store, nil
}
}
}
if talker == "" {
talker = "unknown"
}
return nil, errors.MessageStoreNotFound(talker)
}
func (ds *DataSource) initMessageDbs() error { func (ds *DataSource) initMessageDbs() error {
dbPaths, err := ds.dbm.GetDBPath(Message) dbPaths, err := ds.dbm.GetDBPath(Message)
if err != nil { if err != nil {
@@ -129,6 +187,8 @@ func (ds *DataSource) initMessageDbs() error {
// 处理每个数据库文件 // 处理每个数据库文件
infos := make([]MessageDBInfo, 0) infos := make([]MessageDBInfo, 0)
talkerDBMap := make(map[string]string)
talkerSets := make(map[string]map[string]struct{})
for _, filePath := range dbPaths { for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath) db, err := ds.dbm.OpenDB(filePath)
if err != nil { if err != nil {
@@ -136,6 +196,9 @@ func (ds *DataSource) initMessageDbs() error {
continue continue
} }
talkers := make(map[string]struct{})
talkerSets[filePath] = talkers
// 获取 Timestamp 表中的开始时间 // 获取 Timestamp 表中的开始时间
var startTime time.Time var startTime time.Time
var timestamp int64 var timestamp int64
@@ -147,6 +210,27 @@ func (ds *DataSource) initMessageDbs() error {
} }
startTime = time.Unix(timestamp, 0) startTime = time.Unix(timestamp, 0)
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
if err != nil {
log.Debug().Err(err).Msgf("数据库 %s 查询 Msg 表失败", filePath)
} else {
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
log.Debug().Err(err).Msgf("数据库 %s 扫描 Msg 表失败", filePath)
continue
}
hash := strings.TrimPrefix(tableName, "Msg_")
if hash == "" {
continue
}
talkers[hash] = struct{}{}
talkerDBMap[hash] = filePath
}
rows.Close()
}
// 保存数据库信息 // 保存数据库信息
infos = append(infos, MessageDBInfo{ infos = append(infos, MessageDBInfo{
FilePath: filePath, FilePath: filePath,
@@ -172,6 +256,37 @@ func (ds *DataSource) initMessageDbs() error {
return nil return nil
} }
ds.messageInfos = infos ds.messageInfos = infos
stores := make([]*msgstore.Store, 0, len(infos))
storeByPath := make(map[string]*msgstore.Store, len(infos))
for _, info := range infos {
filename := filepath.Base(info.FilePath)
id := strings.TrimSuffix(filename, filepath.Ext(filename))
var talkerMap map[string]struct{}
if set := talkerSets[info.FilePath]; len(set) > 0 {
talkerMap = make(map[string]struct{}, len(set))
for hash := range set {
talkerMap[hash] = struct{}{}
}
}
store := &msgstore.Store{
ID: id,
FilePath: info.FilePath,
FileName: filename,
IndexPath: filepath.Join(ds.path, "indexes", "messages", id+".fts.db"),
StartTime: info.StartTime,
EndTime: info.EndTime,
Talkers: talkerMap,
}
stores = append(stores, store)
storeByPath[info.FilePath] = store
}
ds.messageStoreMu.Lock()
ds.messageStores = stores
ds.messageStoreByPath = storeByPath
ds.messageStoreMu.Unlock()
ds.talkerDBMap = talkerDBMap
return nil return nil
} }
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -18,6 +19,7 @@ import (
"github.com/sjzar/chatlog/internal/errors" "github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model" "github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm" "github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
) )
@@ -81,6 +83,9 @@ type DataSource struct {
// 消息数据库信息 // 消息数据库信息
messageInfos []MessageDBInfo messageInfos []MessageDBInfo
messageStores []*msgstore.Store
messageStoreMu sync.RWMutex
talkerCacheMu sync.RWMutex talkerCacheMu sync.RWMutex
talkerCache []string talkerCache []string
talkerCacheExpiry time.Time talkerCacheExpiry time.Time
@@ -89,9 +94,10 @@ type DataSource struct {
// New 创建一个新的 WindowsV3DataSource // New 创建一个新的 WindowsV3DataSource
func New(path string) (*DataSource, error) { func New(path string) (*DataSource, error) {
ds := &DataSource{ ds := &DataSource{
path: path, path: path,
dbm: dbm.NewDBManager(path), dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0), messageInfos: make([]MessageDBInfo, 0),
messageStores: make([]*msgstore.Store, 0),
} }
for _, g := range Groups { for _, g := range Groups {
@@ -142,6 +148,9 @@ func (ds *DataSource) initMessageDbs() error {
if err != nil { if err != nil {
if strings.Contains(err.Error(), "db file not found") { if strings.Contains(err.Error(), "db file not found") {
ds.messageInfos = make([]MessageDBInfo, 0) ds.messageInfos = make([]MessageDBInfo, 0)
ds.messageStoreMu.Lock()
ds.messageStores = make([]*msgstore.Store, 0)
ds.messageStoreMu.Unlock()
return nil return nil
} }
return err return err
@@ -229,6 +238,28 @@ func (ds *DataSource) initMessageDbs() error {
return nil return nil
} }
ds.messageInfos = infos ds.messageInfos = infos
stores := make([]*msgstore.Store, 0, len(infos))
for _, info := range infos {
filename := filepath.Base(info.FilePath)
id := strings.TrimSuffix(filename, filepath.Ext(filename))
talkers := make(map[string]struct{}, len(info.TalkerMap))
for t := range info.TalkerMap {
talkers[t] = struct{}{}
}
store := &msgstore.Store{
ID: id,
FilePath: info.FilePath,
FileName: filename,
IndexPath: filepath.Join(ds.path, "indexes", "messages", id+".fts.db"),
StartTime: info.StartTime,
EndTime: info.EndTime,
Talkers: talkers,
}
stores = append(stores, store)
}
ds.messageStoreMu.Lock()
ds.messageStores = stores
ds.messageStoreMu.Unlock()
ds.invalidateTalkerCache() ds.invalidateTalkerCache()
return nil return nil
} }
@@ -426,6 +457,63 @@ func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
return ds.dbm.FingerprintForGroups(Message) return ds.dbm.FingerprintForGroups(Message)
} }
func (ds *DataSource) ListMessageStores(ctx context.Context) ([]*msgstore.Store, error) {
_ = ctx
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
stores := make([]*msgstore.Store, len(ds.messageStores))
for i, store := range ds.messageStores {
stores[i] = store.Clone()
}
return stores, nil
}
func (ds *DataSource) LocateMessageStore(msg *model.Message) (*msgstore.Store, error) {
if msg == nil {
return nil, errors.MessageStoreNotFound("nil message")
}
talker := strings.TrimSpace(msg.Talker)
ts := msg.Time
ds.messageStoreMu.RLock()
defer ds.messageStoreMu.RUnlock()
if ts.IsZero() {
for _, store := range ds.messageStores {
if len(store.Talkers) == 0 {
continue
}
if _, ok := store.Talkers[talker]; ok {
return store, nil
}
}
return nil, errors.MessageStoreNotFound(talker)
}
for _, store := range ds.messageStores {
if len(store.Talkers) > 0 {
if _, ok := store.Talkers[talker]; !ok {
continue
}
}
if (ts.Equal(store.StartTime) || ts.After(store.StartTime)) && ts.Before(store.EndTime) {
return store, nil
}
}
for _, store := range ds.messageStores {
if len(store.Talkers) > 0 {
if _, ok := store.Talkers[talker]; ok {
return store, nil
}
}
}
return nil, errors.MessageStoreNotFound(fmt.Sprintf("%s@%s", talker, ts.Format(time.RFC3339)))
}
func (ds *DataSource) getCachedTalkers() []string { func (ds *DataSource) getCachedTalkers() []string {
ds.talkerCacheMu.RLock() ds.talkerCacheMu.RLock()
if ds.talkerCacheExpiry.IsZero() || time.Now().After(ds.talkerCacheExpiry) { if ds.talkerCacheExpiry.IsZero() || time.Now().After(ds.talkerCacheExpiry) {
@@ -538,7 +626,7 @@ func (ds *DataSource) ListTalkers(ctx context.Context) ([]string, error) {
return ds.collectAllTalkers(ctx) return ds.collectAllTalkers(ctx)
} }
// IterateMessages 按 talker 枚举消息并交给处理函数,供 Bleve 索引使用 // IterateMessages 按 talker 枚举消息并交给处理函数,供 FTS 索引使用
func (ds *DataSource) IterateMessages(ctx context.Context, talkers []string, handler func(*model.Message) error) error { func (ds *DataSource) IterateMessages(ctx context.Context, talkers []string, handler func(*model.Message) error) error {
if handler == nil { if handler == nil {
return errors.InvalidArg("handler") return errors.InvalidArg("handler")
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
package msgstore
import (
"context"
"time"
"github.com/sjzar/chatlog/internal/model"
)
// Store describes a physical message database and its derived FTS index metadata.
type Store struct {
ID string
FilePath string
FileName string
IndexPath string
StartTime time.Time
EndTime time.Time
Talkers map[string]struct{}
}
// Clone creates a shallow copy of the store with a deep copy of the talker set.
func (s *Store) Clone() *Store {
if s == nil {
return nil
}
clone := *s
if s.Talkers != nil {
talkers := make(map[string]struct{}, len(s.Talkers))
for talker := range s.Talkers {
talkers[talker] = struct{}{}
}
clone.Talkers = talkers
}
return &clone
}
// Provider exposes message store metadata for building per-database indexes.
type Provider interface {
ListMessageStores(ctx context.Context) ([]*Store, error)
LocateMessageStore(msg *model.Message) (*Store, error)
}
+121 -21
View File
@@ -2,8 +2,11 @@ package repository
import ( import (
"context" "context"
"crypto/md5"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -12,10 +15,11 @@ import (
"github.com/sjzar/chatlog/internal/model" "github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/indexer" "github.com/sjzar/chatlog/internal/wechatdb/indexer"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
) )
type bleveIndexable interface { type ftsIndexable interface {
ListTalkers(ctx context.Context) ([]string, error) ListTalkers(ctx context.Context) ([]string, error)
IterateMessages(ctx context.Context, talkers []string, fn func(*model.Message) error) error IterateMessages(ctx context.Context, talkers []string, fn func(*model.Message) error) error
} }
@@ -36,11 +40,11 @@ func (r *Repository) initIndex() error {
go func() { go func() {
ready, err := r.ensureIndex(r.indexCtx) ready, err := r.ensureIndex(r.indexCtx)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) {
log.Warn().Err(err).Msg("ensure bleve index failed") log.Warn().Err(err).Msg("ensure fts index failed")
return return
} }
if ready { if ready {
log.Info().Msg("bleve index ready") log.Info().Msg("fts index ready")
} }
}() }()
@@ -125,9 +129,14 @@ func (r *Repository) ensureIndex(ctx context.Context) (bool, error) {
} }
func (r *Repository) rebuildIndex(ctx context.Context, fp string) error { func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
indexable, ok := r.ds.(bleveIndexable) indexable, ok := r.ds.(ftsIndexable)
if !ok { if !ok {
return fmt.Errorf("datasource does not support bleve indexing") return fmt.Errorf("datasource does not support fts indexing")
}
stores, err := r.ds.ListMessageStores(ctx)
if err != nil {
return err
} }
if err := r.index.Reset(); err != nil { if err := r.index.Reset(); err != nil {
@@ -136,6 +145,91 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
if _, err := r.index.EnsureVersion(); err != nil { if _, err := r.index.EnsureVersion(); err != nil {
return err return err
} }
if err := r.index.SyncStores(stores); err != nil {
return err
}
if len(stores) == 0 {
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
return r.index.UpdateLastBuilt(time.Now())
}
storeByID := make(map[string]*msgstore.Store, len(stores))
storeByPath := make(map[string]*msgstore.Store, len(stores))
talkerHashStore := make(map[string]*msgstore.Store)
for _, store := range stores {
if store == nil {
continue
}
storeByID[store.ID] = store
if store.FilePath != "" {
storeByPath[filepath.Clean(store.FilePath)] = store
}
for hash := range store.Talkers {
talkerHashStore[hash] = store
}
}
const perStoreBatchSize = 512
storeBuffers := make(map[string][]*model.Message, len(stores))
dirtyStores := make(map[string]struct{})
locateStore := func(msg *model.Message) (*msgstore.Store, error) {
if msg == nil {
return nil, errors.New("message is nil")
}
talker := strings.TrimSpace(msg.Talker)
if talker != "" {
hashBytes := md5.Sum([]byte(talker))
hash := hex.EncodeToString(hashBytes[:])
if store := talkerHashStore[hash]; store != nil {
return store, nil
}
}
located, err := r.ds.LocateMessageStore(msg)
if err != nil {
return nil, err
}
if located == nil {
return nil, fmt.Errorf("message store not found for talker %s", talker)
}
if store := storeByPath[filepath.Clean(located.FilePath)]; store != nil {
return store, nil
}
if store := storeByID[located.ID]; store != nil {
return store, nil
}
return nil, fmt.Errorf("message store %s not registered", located.FilePath)
}
flushStore := func(store *msgstore.Store) error {
if store == nil {
return nil
}
buf := storeBuffers[store.ID]
if len(buf) == 0 {
return nil
}
if err := r.index.IndexStoreMessages(store, buf); err != nil {
return err
}
storeBuffers[store.ID] = buf[:0]
return nil
}
flushDirty := func() error {
for id := range dirtyStores {
store := storeByID[id]
if err := flushStore(store); err != nil {
return err
}
delete(dirtyStores, id)
}
return nil
}
talkers, err := indexable.ListTalkers(ctx) talkers, err := indexable.ListTalkers(ctx)
if err != nil { if err != nil {
@@ -143,6 +237,9 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
} }
if len(talkers) == 0 { if len(talkers) == 0 {
if err := flushDirty(); err != nil {
return err
}
if err := r.index.UpdateFingerprint(fp); err != nil { if err := r.index.UpdateFingerprint(fp); err != nil {
return err return err
} }
@@ -151,18 +248,6 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
sort.Strings(talkers) sort.Strings(talkers)
batch := make([]*model.Message, 0, 512)
flush := func() error {
if len(batch) == 0 {
return nil
}
if err := r.index.IndexMessages(batch); err != nil {
return err
}
batch = batch[:0]
return nil
}
total := float64(len(talkers)) total := float64(len(talkers))
for i, talker := range talkers { for i, talker := range talkers {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -173,10 +258,21 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
if msg == nil { if msg == nil {
return nil return nil
} }
batch = append(batch, msg) store, err := locateStore(msg)
if len(batch) >= cap(batch) { if err != nil {
return flush() log.Warn().Err(err).Str("talker", msg.Talker).Msg("skip message without store")
return nil
} }
batch := storeBuffers[store.ID]
batch = append(batch, msg)
if len(batch) >= perStoreBatchSize {
if err := r.index.IndexStoreMessages(store, batch); err != nil {
return err
}
batch = batch[:0]
}
storeBuffers[store.ID] = batch
dirtyStores[store.ID] = struct{}{}
return nil return nil
} }
@@ -184,13 +280,17 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
return err return err
} }
if err := flush(); err != nil { if err := flushDirty(); err != nil {
return err return err
} }
r.updateIndexProgress(float64(i+1) / total) r.updateIndexProgress(float64(i+1) / total)
} }
if err := flushDirty(); err != nil {
return err
}
if err := r.index.UpdateFingerprint(fp); err != nil { if err := r.index.UpdateFingerprint(fp); err != nil {
return err return err
} }
@@ -0,0 +1,90 @@
package repository
import (
"context"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
)
// IndexMessages incrementally indexes the provided messages into the FTS cache.
func (r *Repository) IndexMessages(ctx context.Context, messages []*model.Message) error {
if len(messages) == 0 || r == nil {
return nil
}
if r.index == nil {
return nil
}
r.indexMu.Lock()
status := r.indexStatus
r.indexMu.Unlock()
if status.InProgress || !status.Ready {
return nil
}
batches := make(map[string][]*model.Message)
stores := make(map[string]*msgstore.Store)
for _, msg := range messages {
if msg == nil {
continue
}
store, err := r.ds.LocateMessageStore(msg)
if err != nil {
log.Warn().Err(err).Str("talker", msg.Talker).Msg("locate message store for incremental index failed")
continue
}
if store == nil {
log.Warn().Str("talker", msg.Talker).Msg("skip incremental index: message store not found")
continue
}
batches[store.ID] = append(batches[store.ID], msg)
if _, ok := stores[store.ID]; !ok {
stores[store.ID] = store
}
}
if len(batches) == 0 {
return nil
}
for id, batch := range batches {
store := stores[id]
if len(batch) == 0 || store == nil {
continue
}
if err := r.index.IndexStoreMessages(store, batch); err != nil {
return err
}
}
fp, err := r.ds.GetDatasetFingerprint(ctx)
if err != nil {
log.Debug().Err(err).Msg("get dataset fingerprint for incremental index failed")
return nil
}
if strings.TrimSpace(fp) == "" {
return nil
}
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
r.indexMu.Lock()
r.indexFingerprint = fp
r.indexStatus.LastCompletedAt = time.Now()
r.indexMu.Unlock()
return nil
}
+1 -2
View File
@@ -22,7 +22,6 @@ type Repository struct {
indexMu sync.Mutex indexMu sync.Mutex
indexStatus model.SearchIndexStatus indexStatus model.SearchIndexStatus
indexFingerprint string indexFingerprint string
indexBuildMu sync.Mutex
indexCtx context.Context indexCtx context.Context
indexCancel context.CancelFunc indexCancel context.CancelFunc
@@ -80,7 +79,7 @@ func New(ds datasource.DataSource, indexPath string) (*Repository, error) {
ds.SetCallback("chatroom", r.chatroomCallback) ds.SetCallback("chatroom", r.chatroomCallback)
if err := r.initIndex(); err != nil { if err := r.initIndex(); err != nil {
log.Warn().Err(err).Msg("init bleve index failed") log.Warn().Err(err).Msg("init fts index failed")
} }
return r, nil return r, nil
+12
View File
@@ -2,6 +2,8 @@ package wechatdb
import ( import (
"context" "context"
"fmt"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -52,6 +54,9 @@ func (w *DB) Initialize() error {
} }
indexPath := filepath.Join(w.path, "indexes", "messages") indexPath := filepath.Join(w.path, "indexes", "messages")
if err := os.MkdirAll(indexPath, 0o755); err != nil {
return fmt.Errorf("prepare index directory: %w", err)
}
w.repo, err = repository.New(w.ds, indexPath) w.repo, err = repository.New(w.ds, indexPath)
if err != nil { if err != nil {
return err return err
@@ -77,6 +82,13 @@ func (w *DB) SearchMessages(req *model.SearchRequest) (*model.SearchResponse, er
return w.repo.SearchMessages(ctx, req) return w.repo.SearchMessages(ctx, req)
} }
func (w *DB) IndexMessages(messages []*model.Message) error {
if w.repo == nil {
return fmt.Errorf("repository not initialized")
}
return w.repo.IndexMessages(context.Background(), messages)
}
type GetContactsResp struct { type GetContactsResp struct {
Items []*model.Contact `json:"items"` Items []*model.Contact `json:"items"`
} }
+12 -12
View File
@@ -7,18 +7,18 @@ import (
// Options configures a transcription request. // Options configures a transcription request.
type Options struct { type Options struct {
Language string // "auto" to let the model detect language Language string // "auto" to let the model detect language
LanguageSet bool // true when Language should override defaults LanguageSet bool // true when Language should override defaults
Translate bool // translate non-English speech into English Translate bool // translate non-English speech into English
TranslateSet bool // true when Translate should override defaults TranslateSet bool // true when Translate should override defaults
Threads int // number of threads used by the backend (<=0 uses default) Threads int // number of threads used by the backend (<=0 uses default)
ThreadsSet bool // true when Threads should override defaults ThreadsSet bool // true when Threads should override defaults
InitialPrompt string // optional priming prompt InitialPrompt string // optional priming prompt
InitialPromptSet bool // true when InitialPrompt should override defaults InitialPromptSet bool // true when InitialPrompt should override defaults
Temperature float32 // sampling temperature Temperature float32 // sampling temperature
TemperatureSet bool // true when Temperature should override defaults TemperatureSet bool // true when Temperature should override defaults
TemperatureFloor float32 // optional fallback temperature when decoding stalls TemperatureFloor float32 // optional fallback temperature when decoding stalls
TemperatureFloorSet bool // true when TemperatureFloor should override defaults TemperatureFloorSet bool // true when TemperatureFloor should override defaults
} }
// Segment represents a portion of transcribed text with timestamps. // Segment represents a portion of transcribed text with timestamps.
+191 -191
View File
@@ -17,249 +17,249 @@ import (
// WhisperCPPConfig controls the on-device whisper.cpp backend. // WhisperCPPConfig controls the on-device whisper.cpp backend.
type WhisperCPPConfig struct { type WhisperCPPConfig struct {
ModelPath string ModelPath string
Threads int Threads int
DefaultOptions Options DefaultOptions Options
} }
// WhisperCPPTranscriber wraps a whisper.cpp model for local transcription. // WhisperCPPTranscriber wraps a whisper.cpp model for local transcription.
type WhisperCPPTranscriber struct { type WhisperCPPTranscriber struct {
model whis.Model model whis.Model
defaultOptions Options defaultOptions Options
defaultThreads int defaultThreads int
} }
// NewWhisperCPPTranscriber loads a whisper.cpp model for on-device speech recognition. // NewWhisperCPPTranscriber loads a whisper.cpp model for on-device speech recognition.
func NewWhisperCPPTranscriber(cfg WhisperCPPConfig) (*WhisperCPPTranscriber, error) { func NewWhisperCPPTranscriber(cfg WhisperCPPConfig) (*WhisperCPPTranscriber, error) {
modelPath := strings.TrimSpace(cfg.ModelPath) modelPath := strings.TrimSpace(cfg.ModelPath)
if modelPath == "" { if modelPath == "" {
return nil, fmt.Errorf("whisper.cpp model path is empty") return nil, fmt.Errorf("whisper.cpp model path is empty")
} }
model, err := whis.New(modelPath) model, err := whis.New(modelPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("load whisper.cpp model: %w", err) return nil, fmt.Errorf("load whisper.cpp model: %w", err)
} }
return &WhisperCPPTranscriber{ return &WhisperCPPTranscriber{
model: model, model: model,
defaultOptions: cfg.DefaultOptions, defaultOptions: cfg.DefaultOptions,
defaultThreads: cfg.Threads, defaultThreads: cfg.Threads,
}, nil }, nil
} }
// Close releases resources held by the whisper.cpp model. // Close releases resources held by the whisper.cpp model.
func (t *WhisperCPPTranscriber) Close() { func (t *WhisperCPPTranscriber) Close() {
if t.model != nil { if t.model != nil {
if err := t.model.Close(); err != nil { if err := t.model.Close(); err != nil {
log.Debug().Err(err).Msg("whispercpp: model close failed") log.Debug().Err(err).Msg("whispercpp: model close failed")
} }
t.model = nil t.model = nil
} }
} }
// TranscribePCM runs whisper.cpp against raw PCM samples. // TranscribePCM runs whisper.cpp against raw PCM samples.
func (t *WhisperCPPTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) { func (t *WhisperCPPTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
if t.model == nil { if t.model == nil {
return nil, errors.New("whisper.cpp model not initialised") return nil, errors.New("whisper.cpp model not initialised")
} }
merged := t.mergeOptions(opts) merged := t.mergeOptions(opts)
if len(samples) == 0 { if len(samples) == 0 {
return nil, nil return nil, nil
} }
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
if sampleRate <= 0 { if sampleRate <= 0 {
sampleRate = int(whis.SampleRate) sampleRate = int(whis.SampleRate)
} }
processed := resampleIfNeeded(samples, sampleRate, int(whis.SampleRate)) processed := resampleIfNeeded(samples, sampleRate, int(whis.SampleRate))
ctxInstance, err := t.model.NewContext() ctxInstance, err := t.model.NewContext()
if err != nil { if err != nil {
return nil, fmt.Errorf("create whisper.cpp context: %w", err) return nil, fmt.Errorf("create whisper.cpp context: %w", err)
} }
threads := t.defaultThreads threads := t.defaultThreads
if merged.ThreadsSet && merged.Threads > 0 { if merged.ThreadsSet && merged.Threads > 0 {
threads = merged.Threads threads = merged.Threads
} }
if threads > 0 { if threads > 0 {
ctxInstance.SetThreads(uint(threads)) ctxInstance.SetThreads(uint(threads))
} }
lang := "auto" lang := "auto"
if merged.LanguageSet { if merged.LanguageSet {
trimmed := strings.TrimSpace(merged.Language) trimmed := strings.TrimSpace(merged.Language)
if trimmed != "" { if trimmed != "" {
lang = trimmed lang = trimmed
} }
} }
if err := ctxInstance.SetLanguage(lang); err != nil { if err := ctxInstance.SetLanguage(lang); err != nil {
log.Warn().Err(err).Str("language", lang).Msg("whispercpp: set language failed") log.Warn().Err(err).Str("language", lang).Msg("whispercpp: set language failed")
} }
if merged.TranslateSet { if merged.TranslateSet {
ctxInstance.SetTranslate(merged.Translate) ctxInstance.SetTranslate(merged.Translate)
} }
if merged.InitialPromptSet { if merged.InitialPromptSet {
ctxInstance.SetInitialPrompt(merged.InitialPrompt) ctxInstance.SetInitialPrompt(merged.InitialPrompt)
} }
if merged.TemperatureSet { if merged.TemperatureSet {
ctxInstance.SetTemperature(merged.Temperature) ctxInstance.SetTemperature(merged.Temperature)
} }
if merged.TemperatureFloorSet { if merged.TemperatureFloorSet {
ctxInstance.SetTemperatureFallback(merged.TemperatureFloor) ctxInstance.SetTemperatureFallback(merged.TemperatureFloor)
} }
if err := ctxInstance.Process(processed, nil, nil, nil); err != nil { if err := ctxInstance.Process(processed, nil, nil, nil); err != nil {
return nil, fmt.Errorf("whisper.cpp process pcm: %w", err) return nil, fmt.Errorf("whisper.cpp process pcm: %w", err)
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
var ( var (
segments []Segment segments []Segment
builder strings.Builder builder strings.Builder
lastEnd time.Duration lastEnd time.Duration
) )
for { for {
seg, err := ctxInstance.NextSegment() seg, err := ctxInstance.NextSegment()
if err != nil { if err != nil {
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
return nil, fmt.Errorf("whisper.cpp next segment: %w", err) return nil, fmt.Errorf("whisper.cpp next segment: %w", err)
} }
text := strings.TrimSpace(seg.Text) text := strings.TrimSpace(seg.Text)
if text == "" { if text == "" {
continue continue
} }
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteByte(' ') builder.WriteByte(' ')
} }
builder.WriteString(text) builder.WriteString(text)
segments = append(segments, Segment{ segments = append(segments, Segment{
ID: seg.Num, ID: seg.Num,
Start: seg.Start, Start: seg.Start,
End: seg.End, End: seg.End,
Text: text, Text: text,
}) })
if seg.End > lastEnd { if seg.End > lastEnd {
lastEnd = seg.End lastEnd = seg.End
} }
} }
detected := strings.TrimSpace(ctxInstance.DetectedLanguage()) detected := strings.TrimSpace(ctxInstance.DetectedLanguage())
if detected == "" { if detected == "" {
detected = fallbackLanguage(merged, merged.TranslateSet && merged.Translate) detected = fallbackLanguage(merged, merged.TranslateSet && merged.Translate)
} }
return &Result{ return &Result{
Text: strings.TrimSpace(builder.String()), Text: strings.TrimSpace(builder.String()),
Language: detected, Language: detected,
Duration: lastEnd, Duration: lastEnd,
Segments: segments, Segments: segments,
}, nil }, nil
} }
// TranscribeSilk decodes SILK payloads before invoking whisper.cpp. // TranscribeSilk decodes SILK payloads before invoking whisper.cpp.
func (t *WhisperCPPTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) { func (t *WhisperCPPTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
if len(silkData) == 0 { if len(silkData) == 0 {
return nil, nil return nil, nil
} }
samples16, sampleRate, err := silk.Silk2PCM16(silkData) samples16, sampleRate, err := silk.Silk2PCM16(silkData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
floatSamples := make([]float32, len(samples16)) floatSamples := make([]float32, len(samples16))
const scale = 1.0 / 32768.0 const scale = 1.0 / 32768.0
for i, sample := range samples16 { for i, sample := range samples16 {
floatSamples[i] = float32(float64(sample) * scale) floatSamples[i] = float32(float64(sample) * scale)
} }
return t.TranscribePCM(ctx, floatSamples, sampleRate, opts) return t.TranscribePCM(ctx, floatSamples, sampleRate, opts)
} }
func (t *WhisperCPPTranscriber) mergeOptions(overrides Options) Options { func (t *WhisperCPPTranscriber) mergeOptions(overrides Options) Options {
merged := t.defaultOptions merged := t.defaultOptions
if overrides.LanguageSet { if overrides.LanguageSet {
merged.Language = overrides.Language merged.Language = overrides.Language
merged.LanguageSet = true merged.LanguageSet = true
} }
if overrides.TranslateSet { if overrides.TranslateSet {
merged.Translate = overrides.Translate merged.Translate = overrides.Translate
merged.TranslateSet = true merged.TranslateSet = true
} }
if overrides.ThreadsSet { if overrides.ThreadsSet {
merged.Threads = overrides.Threads merged.Threads = overrides.Threads
merged.ThreadsSet = true merged.ThreadsSet = true
} }
if overrides.InitialPromptSet { if overrides.InitialPromptSet {
merged.InitialPrompt = overrides.InitialPrompt merged.InitialPrompt = overrides.InitialPrompt
merged.InitialPromptSet = true merged.InitialPromptSet = true
} }
if overrides.TemperatureSet { if overrides.TemperatureSet {
merged.Temperature = overrides.Temperature merged.Temperature = overrides.Temperature
merged.TemperatureSet = true merged.TemperatureSet = true
} }
if overrides.TemperatureFloorSet { if overrides.TemperatureFloorSet {
merged.TemperatureFloor = overrides.TemperatureFloor merged.TemperatureFloor = overrides.TemperatureFloor
merged.TemperatureFloorSet = true merged.TemperatureFloorSet = true
} }
return merged return merged
} }
func resampleIfNeeded(samples []float32, fromRate, toRate int) []float32 { func resampleIfNeeded(samples []float32, fromRate, toRate int) []float32 {
if fromRate <= 0 { if fromRate <= 0 {
fromRate = toRate fromRate = toRate
} }
if fromRate == toRate || len(samples) == 0 { if fromRate == toRate || len(samples) == 0 {
dst := make([]float32, len(samples)) dst := make([]float32, len(samples))
copy(dst, samples) copy(dst, samples)
return dst return dst
} }
ratio := float64(fromRate) / float64(toRate) ratio := float64(fromRate) / float64(toRate)
if ratio <= 0 { if ratio <= 0 {
dst := make([]float32, len(samples)) dst := make([]float32, len(samples))
copy(dst, samples) copy(dst, samples)
return dst return dst
} }
outLen := int(math.Ceil(float64(len(samples)) / ratio)) outLen := int(math.Ceil(float64(len(samples)) / ratio))
if outLen <= 0 { if outLen <= 0 {
outLen = len(samples) outLen = len(samples)
} }
dst := make([]float32, outLen) dst := make([]float32, outLen)
for i := range dst { for i := range dst {
srcPos := float64(i) * ratio srcPos := float64(i) * ratio
idx := int(math.Floor(srcPos)) idx := int(math.Floor(srcPos))
frac := srcPos - float64(idx) frac := srcPos - float64(idx)
if idx >= len(samples)-1 { if idx >= len(samples)-1 {
dst[i] = samples[len(samples)-1] dst[i] = samples[len(samples)-1]
continue continue
} }
a := samples[idx] a := samples[idx]
b := samples[idx+1] b := samples[idx+1]
dst[i] = float32(float64(a)*(1-frac) + float64(b)*frac) dst[i] = float32(float64(a)*(1-frac) + float64(b)*frac)
} }
return dst return dst
} }
+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()
}
-1
View File
@@ -105,4 +105,3 @@ func ComposeLANURL(addr string) string {
} }
return "http://" + h + ":" + port return "http://" + h + ":" + port
} }