4 Commits

Author SHA1 Message Date
Symb0x76
cfd16294b5 feat(fts) : Auto update index using webhook 2025-10-20 09:25:48 +08:00
Symb0x76
38d5a590de feat(search) : add FTS in replace of Bleve 2025-10-17 17:04:57 +08:00
Symb0x76
4f4a631395 feat(systray) : Add systray support for windows 2025-10-16 21:36:26 +08:00
Symb0x76
35eebcdc30 feat(frontend) : moving frontend to webpage 2025-10-16 16:29:08 +08:00
44 changed files with 3506 additions and 841 deletions

6
.gitignore vendored
View File

@@ -24,12 +24,12 @@ go.work.sum
# env file
.env
# icon file
*.ico
# syncthing files
.stfolder
chatlog.exe# Added by goreleaser init:
dist/
# Nightly python remake
tools/

View File

@@ -8,7 +8,8 @@ ifeq ($(OS),Windows_NT)
CGO_EXTRA_LDFLAGS := -lgomp
endif
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 := \
darwin/amd64 \
@@ -48,16 +49,16 @@ lint:
tidy:
@echo "Tidying up dependencies..."
@$(CGO_FLAG) $(GO) mod tidy
@$(CGOFLAGS) $(GO) mod tidy
test:
@echo "Running tests..."
@$(CGO_FLAG) $(GO) test ./... -cover
@$(CGOFLAGS) $(GO) test ./... -cover
build:
@echo "Building for current platform..."
@$(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
@echo "Building for multiple platforms..."
@@ -71,7 +72,7 @@ crossbuild: clean
[ "$$os" = "windows" ] && output_name=$$output_name.exe; \
echo "Building for $$os/$$arch..."; \
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 \
echo "Compressing binary $$output_name..." && upx --best $$output_name; \
fi; \

View File

@@ -62,12 +62,19 @@ go install github.com/sjzar/chatlog@latest
### Terminal UI 模式
最简单的使用方式是通过 Terminal UI 界面操作:
最简单的使用方式是通过 web 界面操作:
```bash
chatlog
```
如果遇到了问题,可以按下面的命令打开调试/控制台前端模式:
```bash
chatlog --debug // 打开调试模式
chatlog --console // 打开控制台前端模式
```
操作方法:
- 使用 `↑` `↓` 键选择菜单项

View File

@@ -1,6 +1,7 @@
# TODO
- [ ] 修复 html 渲染部分头像缺失的问题
- [x] 美化搜索界面使风格一致
- [ ] 实现统一接口管理设置的功能
- [ ] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
- [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题
- [ ] 美化搜索界面使风格一致
- [x] 实现统一接口管理设置的功能
- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
- [ ] Fix : Systray 右键无响应

View File

@@ -14,7 +14,10 @@ import (
"github.com/spf13/cobra"
)
var Debug bool
var (
Debug bool
Console bool
)
func initLog(cmd *cobra.Command, args []string) {
zerolog.SetGlobalLevel(zerolog.InfoLevel)

View File

@@ -1,7 +1,10 @@
package chatlog
import (
"runtime"
"github.com/sjzar/chatlog/internal/chatlog"
"github.com/sjzar/chatlog/pkg/util"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
@@ -12,6 +15,7 @@ func init() {
cobra.MousetrapHelpText = ""
rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug")
rootCmd.PersistentFlags().BoolVar(&Console, "console", false, "run with console interface")
rootCmd.PersistentPreRun = initLog
}
@@ -30,12 +34,34 @@ var rootCmd = &cobra.Command{
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
PreRun: initTuiLog,
PreRun: prepareRoot,
Run: Root,
}
func prepareRoot(cmd *cobra.Command, args []string) {
if Console || !Debug {
initTuiLog(cmd, args)
}
}
func Root(cmd *cobra.Command, args []string) {
m := chatlog.New()
mode := chatlog.RunModeHeadless
autoOpen := true
if Console {
mode = chatlog.RunModeConsole
autoOpen = false
}
m.SetRunOptions(chatlog.RunOptions{
Mode: mode,
AutoOpenBrowser: autoOpen,
AutoOpenBrowserSet: true,
})
if runtime.GOOS == "windows" && !Console && !Debug {
util.HideConsoleWindow()
}
if err := m.Run(""); err != nil {
log.Err(err).Msg("failed to run chatlog instance")
}

36
go.mod
View File

@@ -4,10 +4,11 @@ go 1.24.0
require (
github.com/Eyevinn/mp4ff v0.49.0
github.com/blevesearch/bleve/v2 v2.4.2
github.com/cespare/xxhash v1.1.0
github.com/fsnotify/fsnotify v1.9.0
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/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0
@@ -15,7 +16,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/mitchellh/mapstructure v1.5.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/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/rs/zerolog v1.34.0
@@ -32,26 +32,7 @@ require (
)
require (
github.com/RoaringBitmap/roaring v1.9.3 // 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/bytedance/sonic v1.14.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/gabriel-vasile/mimetype v1.4.9 // 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/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.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-stack/stack v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // 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/invopop/jsonschema v0.13.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/yosida95/uritemplate/v3 v3.0.2 // 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/net v0.43.0 // indirect
golang.org/x/term v0.34.0 // indirect

78
go.sum
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/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
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/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/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
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/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
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/go.mod h1:qyHjS/50ORo01H0NsuEEGsQR9VCtOcEye0gUl2sx1s8=
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-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
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/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-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/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
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/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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
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/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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.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-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/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1176,7 +1176,7 @@ func (a *App) settingDataKey() {
// 添加按钮 - 点击保存时才设置数据密钥
formView.AddButton("保存", func() {
a.ctx.DataKey = tempDataKey // 设置数据密钥
a.ctx.SetDataKey(tempDataKey)
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("数据密钥已设置")
@@ -1230,7 +1230,7 @@ func (a *App) settingDataDir() {
// 添加按钮 - 点击保存时才设置数据目录
formView.AddButton("保存", func() {
a.ctx.DataDir = tempDataDir // 设置数据目录
a.ctx.SetDataDir(tempDataDir)
a.mainPages.RemovePage("submenu2")
a.refreshSettingsMenu()
a.showInfo("数据目录已设置为 " + a.ctx.DataDir)

View File

@@ -63,3 +63,31 @@ func (c *ServerConfig) GetWebhook() *Webhook {
func (c *ServerConfig) GetSpeech() *SpeechConfig {
return c.Speech
}
func (c *ServerConfig) SetHTTPAddr(addr string) {
c.HTTPAddr = addr
}
func (c *ServerConfig) SetWorkDir(dir string) {
c.WorkDir = dir
}
func (c *ServerConfig) SetDataDir(dir string) {
c.DataDir = dir
}
func (c *ServerConfig) SetImgKey(key string) {
c.ImgKey = key
}
func (c *ServerConfig) SetDataKey(key string) {
c.DataKey = key
}
func (c *ServerConfig) IsHTTPEnabled() bool {
return true
}
func (c *ServerConfig) IsAutoDecrypt() bool {
return c.AutoDecrypt
}

View File

@@ -244,6 +244,10 @@ func (c *Context) GetDataKey() string {
return c.DataKey
}
func (c *Context) GetImgKey() string {
return c.ImgKey
}
func (c *Context) GetHTTPAddr() string {
if c.HTTPAddr == "" {
c.HTTPAddr = DefalutHTTPAddr
@@ -251,6 +255,14 @@ func (c *Context) GetHTTPAddr() string {
return c.HTTPAddr
}
func (c *Context) IsHTTPEnabled() bool {
return c.HTTPEnabled
}
func (c *Context) IsAutoDecrypt() bool {
return c.AutoDecrypt
}
func (c *Context) GetWebhook() *conf.Webhook {
return c.conf.Webhook
}
@@ -311,6 +323,16 @@ func (c *Context) SetImgKey(key string) {
c.UpdateConfig()
}
func (c *Context) SetDataKey(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.DataKey == key {
return
}
c.DataKey = key
c.UpdateConfig()
}
func (c *Context) SetAutoDecrypt(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()

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"})
}

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
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
"math"
"net/http"
@@ -128,6 +129,33 @@ var 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() {
s.initBaseRouter()
s.initMediaRouter()
@@ -162,15 +190,27 @@ func (s *Service) initMediaRouter() {
}
func (s *Service) initAPIRouter() {
api := s.router.Group("/api/v1", s.checkDBStateMiddleware())
api := s.router.Group("/api/v1")
{
api.GET("/chatlog", s.handleChatlog)
api.GET("/contact", s.handleContacts)
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions)
api.GET("/diary", s.handleDiary)
api.GET("/dashboard", s.handleDashboard)
api.GET("/search", s.handleSearch)
api.GET("/setting", s.handleGetSetting)
api.POST("/setting", s.handleUpdateSetting)
actions := api.Group("/actions")
actions.POST("/get-data-key", s.handleActionGetDataKey)
actions.POST("/decrypt", s.handleActionDecrypt)
actions.POST("/http/start", s.handleActionStartHTTP)
actions.POST("/http/stop", s.handleActionStopHTTP)
actions.POST("/auto-decrypt/start", s.handleActionStartAutoDecrypt)
actions.POST("/auto-decrypt/stop", s.handleActionStopAutoDecrypt)
dataAPI := api.Group("", s.checkDBStateMiddleware())
dataAPI.GET("/chatlog", s.handleChatlog)
dataAPI.GET("/contact", s.handleContacts)
dataAPI.GET("/chatroom", s.handleChatRooms)
dataAPI.GET("/session", s.handleSessions)
dataAPI.GET("/diary", s.handleDiary)
dataAPI.GET("/dashboard", s.handleDashboard)
dataAPI.GET("/search", s.handleSearch)
}
}
@@ -913,9 +953,9 @@ func (s *Service) handleSearch(c *gin.Context) {
switch format {
case "html":
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>`)
c.Writer.WriteString("<div class=\"summary\">")
writeChatlogHTMLHeader(c.Writer, "Search Result")
c.Writer.WriteString("<h1>搜索结果</h1>")
c.Writer.WriteString("<div class=\"search-meta\">")
if resp.Query != "" {
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 {
c.Writer.WriteString("<div class=\"empty\">暂无搜索结果</div>")
} else {
c.Writer.WriteString("<div class=\"result-list\">")
for idx, hit := range resp.Hits {
if hit == nil || hit.Message == nil {
continue
@@ -960,22 +999,19 @@ func (s *Service) handleSearch(c *gin.Context) {
if msg.SenderName != "" {
senderDisplay = fmt.Sprintf("%s(%s)", msg.SenderName, msg.Sender)
}
c.Writer.WriteString("<div class=\"hit\">")
c.Writer.WriteString("<div class=\"hit-header\"><span class=\"talker\">#" + fmt.Sprintf("%d", idx+1) + " · " + template.HTMLEscapeString(talkerDisplay) + "</span>")
avatarURL := template.HTMLEscapeString(s.composeAvatarURL(msg.Sender) + "?size=big")
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 {
c.Writer.WriteString("<span class=\"score\">score: " + fmt.Sprintf("%.4f", hit.Score) + "</span>")
}
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>")
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {
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("<pre>" + messageHTMLPlaceholder(msg) + "</pre>")
c.Writer.WriteString("</div></div></div>")
}
c.Writer.WriteString("</div>")
}
c.Writer.WriteString(previewHTMLSnippet)
c.Writer.WriteString("</body></html>")
@@ -1121,7 +1157,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch format {
case "html":
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")))
for _, g := range groups {
title := g.Talker
@@ -1141,7 +1177,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
senderDisplay = template.HTMLEscapeString(senderDisplay)
}
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>")
}
@@ -1201,7 +1238,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch format {
case "html":
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)))
for _, m := range messages {
m.SetContent("host", c.Request.Host)
@@ -1216,7 +1253,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
if m.SenderName != "" {
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("</pre></div></div></div>")
}

View File

@@ -17,8 +17,9 @@ import (
)
type Service struct {
conf Config
db *database.Service
conf Config
db *database.Service
control Control
router *gin.Engine
server *http.Server
@@ -33,11 +34,32 @@ type Service struct {
type Config interface {
GetHTTPAddr() string
SetHTTPAddr(string)
GetDataDir() string
SetDataDir(string)
GetWorkDir() string
SetWorkDir(string)
GetDataKey() string
SetDataKey(string)
GetImgKey() string
SetImgKey(string)
IsHTTPEnabled() bool
IsAutoDecrypt() bool
GetSpeech() *conf.SpeechConfig
}
func NewService(conf Config, db *database.Service) *Service {
type Control interface {
GetDataKey() error
DecryptDBFiles() error
StartService() error
StopService() error
StartAutoDecrypt() error
StopAutoDecrypt() error
SaveSpeechConfig(cfg *conf.SpeechConfig) error
SetHTTPAddr(addr string) error
}
func NewService(conf Config, db *database.Service, control Control) *Service {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
@@ -55,9 +77,10 @@ func NewService(conf Config, db *database.Service) *Service {
)
s := &Service{
conf: conf,
db: db,
router: router,
conf: conf,
db: db,
control: control,
router: router,
}
s.initMCPServer()

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,11 @@ import (
"context"
"fmt"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/rs/zerolog/log"
@@ -14,6 +17,7 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/http"
"github.com/sjzar/chatlog/internal/chatlog/wechat"
"github.com/sjzar/chatlog/internal/tray"
iwechat "github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config"
"github.com/sjzar/chatlog/pkg/util"
@@ -22,6 +26,19 @@ import (
const initialDecryptPollInterval = 5 * time.Second
type RunMode int
const (
RunModeHeadless RunMode = iota
RunModeConsole
)
type RunOptions struct {
Mode RunMode
AutoOpenBrowser bool
AutoOpenBrowserSet bool
}
// Manager 管理聊天日志应用
type Manager struct {
ctx *ctx.Context
@@ -34,15 +51,40 @@ type Manager struct {
wechat *wechat.Service
// Terminal UI
app *App
app *App
trayCtrl tray.Controller
options RunOptions
initialDecryptOnce sync.Once
initialDecryptMu sync.Mutex
initialDecryptLastErr string
shutdownCh chan struct{}
shutdownOnce sync.Once
shutdownReason string
}
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 {
@@ -57,7 +99,7 @@ func (m *Manager) Run(configPath string) error {
m.db = database.NewService(m.ctx)
m.http = http.NewService(m.ctx, m.db)
m.http = http.NewService(m.ctx, m.db, m)
instances := m.wechat.GetWeChatInstances()
m.ctx.SetWeChatInstances(instances)
@@ -67,18 +109,124 @@ func (m *Manager) Run(configPath string) error {
m.startInitialDecryptWatcher()
if m.ctx.HTTPEnabled {
// 启动HTTP服务
wantHTTP := m.ctx.HTTPEnabled || m.options.Mode == RunModeHeadless
if wantHTTP {
if err := m.StartService(); err != nil {
m.StopService()
m.stopService()
if m.options.Mode == RunModeHeadless {
return err
}
log.Err(err).Msg("failed to start HTTP service")
}
}
// 启动终端UI
m.app = NewApp(m.ctx, m)
m.app.Run() // 阻塞
if m.options.Mode == RunModeConsole {
m.app = NewApp(m.ctx, m)
return m.app.Run()
}
if url := m.webInterfaceURL(); url != "" {
log.Info().Str("url", url).Msg("Chatlog web interface available")
if m.options.AutoOpenBrowser {
m.launchBrowser(url)
}
}
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
}
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() {
m.initialDecryptOnce.Do(func() {
go m.initialDecryptLoop()
@@ -260,7 +408,11 @@ func (m *Manager) SetHTTPAddr(text string) error {
} else {
addr = text
}
m.ctx.SetHTTPAddr(addr)
if m.ctx != nil {
m.ctx.SetHTTPAddr(addr)
} else if m.sc != nil {
m.sc.SetHTTPAddr(addr)
}
return nil
}
@@ -476,7 +628,7 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e
m.db = database.NewService(m.sc)
m.http = http.NewService(m.sc, m.db)
m.http = http.NewService(m.sc, m.db, m)
if m.sc.GetAutoDecrypt() {
if err := m.wechat.StartAutoDecrypt(); err != nil {

View File

@@ -166,6 +166,10 @@ func (m *MessageWebhook) Do(event fsnotify.Event) {
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)
for _, message := range messages {

View File

@@ -30,6 +30,10 @@ func TalkerNotFound(talker string) *Error {
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 {
return New(cause, http.StatusInternalServerError, "db close failed").WithStack()
}

View File

@@ -2,8 +2,8 @@ package model
// Avatar represents a user's avatar, either as a remote URL (v3) or inline bytes (v4)
type Avatar struct {
Username string `json:"username"`
URL string `json:"url,omitempty"`
ContentType string `json:"-"`
Data []byte `json:"-"`
Username string `json:"username"`
URL string `json:"url,omitempty"`
ContentType string `json:"-"`
Data []byte `json:"-"`
}

View File

@@ -2,13 +2,13 @@ package model
// IntimacyBase 聚合每个会话/联系人基础统计,用于计算亲密度
type IntimacyBase struct {
UserName string
MsgCount int64
SentCount int64
ReceivedCount int64
MinCreateUnix int64
MaxCreateUnix int64
MessagingDays int64
Last90DaysMsg int64
Past7DaysSentMsg int64
UserName string
MsgCount int64
SentCount int64
ReceivedCount int64
MinCreateUnix int64
MaxCreateUnix int64
MessagingDays int64
Last90DaysMsg int64
Past7DaysSentMsg int64
}

View File

@@ -52,7 +52,7 @@ type SearchResponse struct {
Index *SearchIndexStatus `json:"index_status,omitempty"`
}
// SearchIndexStatus 表示 Bleve 索引的构建状态
// SearchIndexStatus 表示全文索引的构建状态
type SearchIndexStatus struct {
Ready bool `json:"ready"`
InProgress bool `json:"in_progress"`

View File

@@ -2,17 +2,17 @@ package model
// GlobalMessageStats 汇总消息统计
type GlobalMessageStats struct {
Total int64 `json:"total"`
Sent int64 `json:"sent"`
Received int64 `json:"received"`
EarliestUnix int64 `json:"earliest_unix"`
LatestUnix int64 `json:"latest_unix"`
ByType map[string]int64 `json:"by_type"` // 例如:{"文本":123, "图片":456}
Total int64 `json:"total"`
Sent int64 `json:"sent"`
Received int64 `json:"received"`
EarliestUnix int64 `json:"earliest_unix"`
LatestUnix int64 `json:"latest_unix"`
ByType map[string]int64 `json:"by_type"` // 例如:{"文本":123, "图片":456}
}
// MonthlyTrend 月度趋势
type MonthlyTrend struct {
Date string `json:"date"` // YYYY-MM
Sent int64 `json:"sent"`
Received int64 `json:"received"`
Date string `json:"date"` // YYYY-MM
Sent int64 `json:"sent"`
Received int64 `json:"received"`
}

13
internal/tray/tray.go Normal file
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()
}

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
}

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
}
}
}()
}

View File

@@ -6,9 +6,11 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
@@ -18,6 +20,7 @@ import (
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util"
)
@@ -63,14 +66,20 @@ type DataSource struct {
talkerDBMap map[string]string
user2DisplayName map[string]string
messageStores []*msgstore.Store
messageStoreByPath map[string]*msgstore.Store
messageStoreMu sync.RWMutex
}
func New(path string) (*DataSource, error) {
ds := &DataSource{
path: path,
dbm: dbm.NewDBManager(path),
talkerDBMap: make(map[string]string),
user2DisplayName: make(map[string]string),
path: path,
dbm: dbm.NewDBManager(path),
talkerDBMap: make(map[string]string),
user2DisplayName: make(map[string]string),
messageStores: make([]*msgstore.Store, 0),
messageStoreByPath: make(map[string]*msgstore.Store),
}
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)
}
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 {
dbPaths, err := ds.dbm.GetDBPath(Message)
@@ -126,6 +185,8 @@ func (ds *DataSource) initMessageDbs() error {
}
// 处理每个数据库文件
talkerDBMap := make(map[string]string)
storeByPath := make(map[string]*msgstore.Store)
stores := make([]*msgstore.Store, 0, len(dbPaths))
for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath)
if err != nil {
@@ -133,30 +194,83 @@ func (ds *DataSource) initMessageDbs() error {
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_%'")
if err != nil {
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() {
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
if len(store.Talkers) == 0 {
store.Talkers = nil
}
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.messageStoreMu.Lock()
ds.messageStores = stores
ds.messageStoreByPath = storeByPath
ds.messageStoreMu.Unlock()
return nil
}

View File

@@ -11,9 +11,11 @@ import (
"github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3"
v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
)
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)

View File

@@ -6,9 +6,11 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
@@ -18,6 +20,7 @@ import (
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util"
)
@@ -75,14 +78,22 @@ type DataSource struct {
// 消息数据库信息
messageInfos []MessageDBInfo
talkerDBMap map[string]string
messageStores []*msgstore.Store
messageStoreByPath map[string]*msgstore.Store
messageStoreMu sync.RWMutex
}
func New(path string) (*DataSource, error) {
ds := &DataSource{
path: path,
dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0),
path: path,
dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0),
talkerDBMap: make(map[string]string),
messageStores: make([]*msgstore.Store, 0),
messageStoreByPath: make(map[string]*msgstore.Store),
}
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)
}
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 {
dbPaths, err := ds.dbm.GetDBPath(Message)
if err != nil {
@@ -129,6 +187,8 @@ func (ds *DataSource) initMessageDbs() error {
// 处理每个数据库文件
infos := make([]MessageDBInfo, 0)
talkerDBMap := make(map[string]string)
talkerSets := make(map[string]map[string]struct{})
for _, filePath := range dbPaths {
db, err := ds.dbm.OpenDB(filePath)
if err != nil {
@@ -136,6 +196,9 @@ func (ds *DataSource) initMessageDbs() error {
continue
}
talkers := make(map[string]struct{})
talkerSets[filePath] = talkers
// 获取 Timestamp 表中的开始时间
var startTime time.Time
var timestamp int64
@@ -147,6 +210,27 @@ func (ds *DataSource) initMessageDbs() error {
}
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{
FilePath: filePath,
@@ -172,6 +256,37 @@ func (ds *DataSource) initMessageDbs() error {
return nil
}
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
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -18,6 +19,7 @@ import (
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util"
)
@@ -81,6 +83,9 @@ type DataSource struct {
// 消息数据库信息
messageInfos []MessageDBInfo
messageStores []*msgstore.Store
messageStoreMu sync.RWMutex
talkerCacheMu sync.RWMutex
talkerCache []string
talkerCacheExpiry time.Time
@@ -89,9 +94,10 @@ type DataSource struct {
// New 创建一个新的 WindowsV3DataSource
func New(path string) (*DataSource, error) {
ds := &DataSource{
path: path,
dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0),
path: path,
dbm: dbm.NewDBManager(path),
messageInfos: make([]MessageDBInfo, 0),
messageStores: make([]*msgstore.Store, 0),
}
for _, g := range Groups {
@@ -142,6 +148,9 @@ func (ds *DataSource) initMessageDbs() error {
if err != nil {
if strings.Contains(err.Error(), "db file not found") {
ds.messageInfos = make([]MessageDBInfo, 0)
ds.messageStoreMu.Lock()
ds.messageStores = make([]*msgstore.Store, 0)
ds.messageStoreMu.Unlock()
return nil
}
return err
@@ -229,6 +238,28 @@ func (ds *DataSource) initMessageDbs() error {
return nil
}
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()
return nil
}
@@ -426,6 +457,63 @@ func (ds *DataSource) GetDatasetFingerprint(context.Context) (string, error) {
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 {
ds.talkerCacheMu.RLock()
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)
}
// IterateMessages 按 talker 枚举消息并交给处理函数,供 Bleve 索引使用
// IterateMessages 按 talker 枚举消息并交给处理函数,供 FTS 索引使用
func (ds *DataSource) IterateMessages(ctx context.Context, talkers []string, handler func(*model.Message) error) error {
if handler == nil {
return errors.InvalidArg("handler")

File diff suppressed because it is too large Load Diff

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)
}

View File

@@ -2,8 +2,11 @@ package repository
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
@@ -12,10 +15,11 @@ import (
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb/indexer"
"github.com/sjzar/chatlog/internal/wechatdb/msgstore"
"github.com/sjzar/chatlog/pkg/util"
)
type bleveIndexable interface {
type ftsIndexable interface {
ListTalkers(ctx context.Context) ([]string, error)
IterateMessages(ctx context.Context, talkers []string, fn func(*model.Message) error) error
}
@@ -36,11 +40,11 @@ func (r *Repository) initIndex() error {
go func() {
ready, err := r.ensureIndex(r.indexCtx)
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
}
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 {
indexable, ok := r.ds.(bleveIndexable)
indexable, ok := r.ds.(ftsIndexable)
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 {
@@ -136,6 +145,91 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
if _, err := r.index.EnsureVersion(); err != nil {
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)
if err != nil {
@@ -143,6 +237,9 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
}
if len(talkers) == 0 {
if err := flushDirty(); err != nil {
return err
}
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}
@@ -151,18 +248,6 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
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))
for i, talker := range talkers {
if err := ctx.Err(); err != nil {
@@ -173,10 +258,21 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
if msg == nil {
return nil
}
batch = append(batch, msg)
if len(batch) >= cap(batch) {
return flush()
store, err := locateStore(msg)
if err != nil {
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
}
@@ -184,13 +280,17 @@ func (r *Repository) rebuildIndex(ctx context.Context, fp string) error {
return err
}
if err := flush(); err != nil {
if err := flushDirty(); err != nil {
return err
}
r.updateIndexProgress(float64(i+1) / total)
}
if err := flushDirty(); err != nil {
return err
}
if err := r.index.UpdateFingerprint(fp); err != nil {
return err
}

View File

@@ -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
}

View File

@@ -22,7 +22,6 @@ type Repository struct {
indexMu sync.Mutex
indexStatus model.SearchIndexStatus
indexFingerprint string
indexBuildMu sync.Mutex
indexCtx context.Context
indexCancel context.CancelFunc
@@ -80,7 +79,7 @@ func New(ds datasource.DataSource, indexPath string) (*Repository, error) {
ds.SetCallback("chatroom", r.chatroomCallback)
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

View File

@@ -2,6 +2,8 @@ package wechatdb
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
@@ -52,6 +54,9 @@ func (w *DB) Initialize() error {
}
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)
if err != nil {
return err
@@ -77,6 +82,13 @@ func (w *DB) SearchMessages(req *model.SearchRequest) (*model.SearchResponse, er
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 {
Items []*model.Contact `json:"items"`
}

View File

@@ -7,18 +7,18 @@ import (
// Options configures a transcription request.
type Options struct {
Language string // "auto" to let the model detect language
LanguageSet bool // true when Language should override defaults
Translate bool // translate non-English speech into English
TranslateSet bool // true when Translate should override defaults
Threads int // number of threads used by the backend (<=0 uses default)
ThreadsSet bool // true when Threads should override defaults
InitialPrompt string // optional priming prompt
InitialPromptSet bool // true when InitialPrompt should override defaults
Temperature float32 // sampling temperature
TemperatureSet bool // true when Temperature should override defaults
TemperatureFloor float32 // optional fallback temperature when decoding stalls
TemperatureFloorSet bool // true when TemperatureFloor should override defaults
Language string // "auto" to let the model detect language
LanguageSet bool // true when Language should override defaults
Translate bool // translate non-English speech into English
TranslateSet bool // true when Translate should override defaults
Threads int // number of threads used by the backend (<=0 uses default)
ThreadsSet bool // true when Threads should override defaults
InitialPrompt string // optional priming prompt
InitialPromptSet bool // true when InitialPrompt should override defaults
Temperature float32 // sampling temperature
TemperatureSet bool // true when Temperature should override defaults
TemperatureFloor float32 // optional fallback temperature when decoding stalls
TemperatureFloorSet bool // true when TemperatureFloor should override defaults
}
// Segment represents a portion of transcribed text with timestamps.

View File

@@ -17,249 +17,249 @@ import (
// WhisperCPPConfig controls the on-device whisper.cpp backend.
type WhisperCPPConfig struct {
ModelPath string
Threads int
DefaultOptions Options
ModelPath string
Threads int
DefaultOptions Options
}
// WhisperCPPTranscriber wraps a whisper.cpp model for local transcription.
type WhisperCPPTranscriber struct {
model whis.Model
defaultOptions Options
defaultThreads int
model whis.Model
defaultOptions Options
defaultThreads int
}
// NewWhisperCPPTranscriber loads a whisper.cpp model for on-device speech recognition.
func NewWhisperCPPTranscriber(cfg WhisperCPPConfig) (*WhisperCPPTranscriber, error) {
modelPath := strings.TrimSpace(cfg.ModelPath)
if modelPath == "" {
return nil, fmt.Errorf("whisper.cpp model path is empty")
}
modelPath := strings.TrimSpace(cfg.ModelPath)
if modelPath == "" {
return nil, fmt.Errorf("whisper.cpp model path is empty")
}
model, err := whis.New(modelPath)
if err != nil {
return nil, fmt.Errorf("load whisper.cpp model: %w", err)
}
model, err := whis.New(modelPath)
if err != nil {
return nil, fmt.Errorf("load whisper.cpp model: %w", err)
}
return &WhisperCPPTranscriber{
model: model,
defaultOptions: cfg.DefaultOptions,
defaultThreads: cfg.Threads,
}, nil
return &WhisperCPPTranscriber{
model: model,
defaultOptions: cfg.DefaultOptions,
defaultThreads: cfg.Threads,
}, nil
}
// Close releases resources held by the whisper.cpp model.
func (t *WhisperCPPTranscriber) Close() {
if t.model != nil {
if err := t.model.Close(); err != nil {
log.Debug().Err(err).Msg("whispercpp: model close failed")
}
t.model = nil
}
if t.model != nil {
if err := t.model.Close(); err != nil {
log.Debug().Err(err).Msg("whispercpp: model close failed")
}
t.model = nil
}
}
// TranscribePCM runs whisper.cpp against raw PCM samples.
func (t *WhisperCPPTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
if t.model == nil {
return nil, errors.New("whisper.cpp model not initialised")
}
merged := t.mergeOptions(opts)
if t.model == nil {
return nil, errors.New("whisper.cpp model not initialised")
}
merged := t.mergeOptions(opts)
if len(samples) == 0 {
return nil, nil
}
if len(samples) == 0 {
return nil, nil
}
if ctx == nil {
ctx = context.Background()
}
if ctx == nil {
ctx = context.Background()
}
if sampleRate <= 0 {
sampleRate = int(whis.SampleRate)
}
if sampleRate <= 0 {
sampleRate = int(whis.SampleRate)
}
processed := resampleIfNeeded(samples, sampleRate, int(whis.SampleRate))
processed := resampleIfNeeded(samples, sampleRate, int(whis.SampleRate))
ctxInstance, err := t.model.NewContext()
if err != nil {
return nil, fmt.Errorf("create whisper.cpp context: %w", err)
}
ctxInstance, err := t.model.NewContext()
if err != nil {
return nil, fmt.Errorf("create whisper.cpp context: %w", err)
}
threads := t.defaultThreads
if merged.ThreadsSet && merged.Threads > 0 {
threads = merged.Threads
}
if threads > 0 {
ctxInstance.SetThreads(uint(threads))
}
threads := t.defaultThreads
if merged.ThreadsSet && merged.Threads > 0 {
threads = merged.Threads
}
if threads > 0 {
ctxInstance.SetThreads(uint(threads))
}
lang := "auto"
if merged.LanguageSet {
trimmed := strings.TrimSpace(merged.Language)
if trimmed != "" {
lang = trimmed
}
}
if err := ctxInstance.SetLanguage(lang); err != nil {
log.Warn().Err(err).Str("language", lang).Msg("whispercpp: set language failed")
}
lang := "auto"
if merged.LanguageSet {
trimmed := strings.TrimSpace(merged.Language)
if trimmed != "" {
lang = trimmed
}
}
if err := ctxInstance.SetLanguage(lang); err != nil {
log.Warn().Err(err).Str("language", lang).Msg("whispercpp: set language failed")
}
if merged.TranslateSet {
ctxInstance.SetTranslate(merged.Translate)
}
if merged.InitialPromptSet {
ctxInstance.SetInitialPrompt(merged.InitialPrompt)
}
if merged.TemperatureSet {
ctxInstance.SetTemperature(merged.Temperature)
}
if merged.TemperatureFloorSet {
ctxInstance.SetTemperatureFallback(merged.TemperatureFloor)
}
if merged.TranslateSet {
ctxInstance.SetTranslate(merged.Translate)
}
if merged.InitialPromptSet {
ctxInstance.SetInitialPrompt(merged.InitialPrompt)
}
if merged.TemperatureSet {
ctxInstance.SetTemperature(merged.Temperature)
}
if merged.TemperatureFloorSet {
ctxInstance.SetTemperatureFallback(merged.TemperatureFloor)
}
if err := ctxInstance.Process(processed, nil, nil, nil); err != nil {
return nil, fmt.Errorf("whisper.cpp process pcm: %w", err)
}
if err := ctxInstance.Process(processed, nil, nil, nil); err != nil {
return nil, fmt.Errorf("whisper.cpp process pcm: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
var (
segments []Segment
builder strings.Builder
lastEnd time.Duration
)
var (
segments []Segment
builder strings.Builder
lastEnd time.Duration
)
for {
seg, err := ctxInstance.NextSegment()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("whisper.cpp next segment: %w", err)
}
for {
seg, err := ctxInstance.NextSegment()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("whisper.cpp next segment: %w", err)
}
text := strings.TrimSpace(seg.Text)
if text == "" {
continue
}
text := strings.TrimSpace(seg.Text)
if text == "" {
continue
}
if builder.Len() > 0 {
builder.WriteByte(' ')
}
builder.WriteString(text)
if builder.Len() > 0 {
builder.WriteByte(' ')
}
builder.WriteString(text)
segments = append(segments, Segment{
ID: seg.Num,
Start: seg.Start,
End: seg.End,
Text: text,
})
if seg.End > lastEnd {
lastEnd = seg.End
}
}
segments = append(segments, Segment{
ID: seg.Num,
Start: seg.Start,
End: seg.End,
Text: text,
})
if seg.End > lastEnd {
lastEnd = seg.End
}
}
detected := strings.TrimSpace(ctxInstance.DetectedLanguage())
if detected == "" {
detected = fallbackLanguage(merged, merged.TranslateSet && merged.Translate)
}
detected := strings.TrimSpace(ctxInstance.DetectedLanguage())
if detected == "" {
detected = fallbackLanguage(merged, merged.TranslateSet && merged.Translate)
}
return &Result{
Text: strings.TrimSpace(builder.String()),
Language: detected,
Duration: lastEnd,
Segments: segments,
}, nil
return &Result{
Text: strings.TrimSpace(builder.String()),
Language: detected,
Duration: lastEnd,
Segments: segments,
}, nil
}
// TranscribeSilk decodes SILK payloads before invoking whisper.cpp.
func (t *WhisperCPPTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
if len(silkData) == 0 {
return nil, nil
}
samples16, sampleRate, err := silk.Silk2PCM16(silkData)
if err != nil {
return nil, err
}
if len(silkData) == 0 {
return nil, nil
}
samples16, sampleRate, err := silk.Silk2PCM16(silkData)
if err != nil {
return nil, err
}
floatSamples := make([]float32, len(samples16))
const scale = 1.0 / 32768.0
for i, sample := range samples16 {
floatSamples[i] = float32(float64(sample) * scale)
}
floatSamples := make([]float32, len(samples16))
const scale = 1.0 / 32768.0
for i, sample := range samples16 {
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 {
merged := t.defaultOptions
merged := t.defaultOptions
if overrides.LanguageSet {
merged.Language = overrides.Language
merged.LanguageSet = true
}
if overrides.TranslateSet {
merged.Translate = overrides.Translate
merged.TranslateSet = true
}
if overrides.ThreadsSet {
merged.Threads = overrides.Threads
merged.ThreadsSet = true
}
if overrides.InitialPromptSet {
merged.InitialPrompt = overrides.InitialPrompt
merged.InitialPromptSet = true
}
if overrides.TemperatureSet {
merged.Temperature = overrides.Temperature
merged.TemperatureSet = true
}
if overrides.TemperatureFloorSet {
merged.TemperatureFloor = overrides.TemperatureFloor
merged.TemperatureFloorSet = true
}
if overrides.LanguageSet {
merged.Language = overrides.Language
merged.LanguageSet = true
}
if overrides.TranslateSet {
merged.Translate = overrides.Translate
merged.TranslateSet = true
}
if overrides.ThreadsSet {
merged.Threads = overrides.Threads
merged.ThreadsSet = true
}
if overrides.InitialPromptSet {
merged.InitialPrompt = overrides.InitialPrompt
merged.InitialPromptSet = true
}
if overrides.TemperatureSet {
merged.Temperature = overrides.Temperature
merged.TemperatureSet = true
}
if overrides.TemperatureFloorSet {
merged.TemperatureFloor = overrides.TemperatureFloor
merged.TemperatureFloorSet = true
}
return merged
return merged
}
func resampleIfNeeded(samples []float32, fromRate, toRate int) []float32 {
if fromRate <= 0 {
fromRate = toRate
}
if fromRate == toRate || len(samples) == 0 {
dst := make([]float32, len(samples))
copy(dst, samples)
return dst
}
if fromRate <= 0 {
fromRate = toRate
}
if fromRate == toRate || len(samples) == 0 {
dst := make([]float32, len(samples))
copy(dst, samples)
return dst
}
ratio := float64(fromRate) / float64(toRate)
if ratio <= 0 {
dst := make([]float32, len(samples))
copy(dst, samples)
return dst
}
ratio := float64(fromRate) / float64(toRate)
if ratio <= 0 {
dst := make([]float32, len(samples))
copy(dst, samples)
return dst
}
outLen := int(math.Ceil(float64(len(samples)) / ratio))
if outLen <= 0 {
outLen = len(samples)
}
outLen := int(math.Ceil(float64(len(samples)) / ratio))
if outLen <= 0 {
outLen = len(samples)
}
dst := make([]float32, outLen)
for i := range dst {
srcPos := float64(i) * ratio
idx := int(math.Floor(srcPos))
frac := srcPos - float64(idx)
dst := make([]float32, outLen)
for i := range dst {
srcPos := float64(i) * ratio
idx := int(math.Floor(srcPos))
frac := srcPos - float64(idx)
if idx >= len(samples)-1 {
dst[i] = samples[len(samples)-1]
continue
}
if idx >= len(samples)-1 {
dst[i] = samples[len(samples)-1]
continue
}
a := samples[idx]
b := samples[idx+1]
dst[i] = float32(float64(a)*(1-frac) + float64(b)*frac)
}
a := samples[idx]
b := samples[idx+1]
dst[i] = float32(float64(a)*(1-frac) + float64(b)*frac)
}
return dst
}
return dst
}

15
pkg/util/browser.go Normal file
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
pkg/util/browser_unix.go Normal file
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
}
}

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
}

View File

@@ -0,0 +1,6 @@
//go:build !windows
package util
// HideConsoleWindow is a no-op on non-Windows platforms.
func HideConsoleWindow() {}

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()
}

View File

@@ -105,4 +105,3 @@ func ComposeLANURL(addr string) string {
}
return "http://" + h + ":" + port
}