Compare commits
4 Commits
Whispercpp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd16294b5 | ||
|
|
38d5a590de | ||
|
|
4f4a631395 | ||
|
|
35eebcdc30 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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; \
|
||||
|
||||
@@ -62,12 +62,19 @@ go install github.com/sjzar/chatlog@latest
|
||||
|
||||
### Terminal UI 模式
|
||||
|
||||
最简单的使用方式是通过 Terminal UI 界面操作:
|
||||
最简单的使用方式是通过 web 界面操作:
|
||||
|
||||
```bash
|
||||
chatlog
|
||||
```
|
||||
|
||||
如果遇到了问题,可以按下面的命令打开调试/控制台前端模式:
|
||||
|
||||
```bash
|
||||
chatlog --debug // 打开调试模式
|
||||
chatlog --console // 打开控制台前端模式
|
||||
```
|
||||
|
||||
操作方法:
|
||||
|
||||
- 使用 `↑` `↓` 键选择菜单项
|
||||
|
||||
9
TODO.md
9
TODO.md
@@ -1,6 +1,7 @@
|
||||
# TODO
|
||||
|
||||
- [ ] 修复 html 渲染部分头像缺失的问题
|
||||
- [x] 美化搜索界面使风格一致
|
||||
- [ ] 实现统一接口管理设置的功能
|
||||
- [ ] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
|
||||
- [ ] (No Fix : 本地就没有这个头像导致的) 修复 html 渲染部分头像缺失的问题
|
||||
- [ ] 美化搜索界面使风格一致
|
||||
- [x] 实现统一接口管理设置的功能
|
||||
- [x] 使用 python 重新实现语音转文字功能(或使用 docker 部署 whisper)
|
||||
- [ ] Fix : Systray 右键无响应
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
36
go.mod
@@ -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
78
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@ func (c *Context) GetDataKey() string {
|
||||
return c.DataKey
|
||||
}
|
||||
|
||||
func (c *Context) GetImgKey() string {
|
||||
return c.ImgKey
|
||||
}
|
||||
|
||||
func (c *Context) GetHTTPAddr() string {
|
||||
if c.HTTPAddr == "" {
|
||||
c.HTTPAddr = DefalutHTTPAddr
|
||||
@@ -251,6 +255,14 @@ func (c *Context) GetHTTPAddr() string {
|
||||
return c.HTTPAddr
|
||||
}
|
||||
|
||||
func (c *Context) IsHTTPEnabled() bool {
|
||||
return c.HTTPEnabled
|
||||
}
|
||||
|
||||
func (c *Context) IsAutoDecrypt() bool {
|
||||
return c.AutoDecrypt
|
||||
}
|
||||
|
||||
func (c *Context) GetWebhook() *conf.Webhook {
|
||||
return c.conf.Webhook
|
||||
}
|
||||
@@ -311,6 +323,16 @@ func (c *Context) SetImgKey(key string) {
|
||||
c.UpdateConfig()
|
||||
}
|
||||
|
||||
func (c *Context) SetDataKey(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.DataKey == key {
|
||||
return
|
||||
}
|
||||
c.DataKey = key
|
||||
c.UpdateConfig()
|
||||
}
|
||||
|
||||
func (c *Context) SetAutoDecrypt(enabled bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
90
internal/chatlog/http/api_actions.go
Normal file
90
internal/chatlog/http/api_actions.go
Normal 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
internal/chatlog/http/api_settings.go
Normal file
102
internal/chatlog/http/api_settings.go
Normal 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 = ©Cfg
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -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>")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
13
internal/tray/tray.go
Normal 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
internal/tray/tray_other.go
Normal file
12
internal/tray/tray_other.go
Normal 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
internal/tray/tray_windows.go
Normal file
122
internal/tray/tray_windows.go
Normal 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"
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
41
internal/wechatdb/msgstore/msgstore.go
Normal file
41
internal/wechatdb/msgstore/msgstore.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
90
internal/wechatdb/repository/index_incremental.go
Normal file
90
internal/wechatdb/repository/index_incremental.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
15
pkg/util/browser.go
Normal 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
21
pkg/util/browser_unix.go
Normal 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
pkg/util/browser_windows.go
Normal file
18
pkg/util/browser_windows.go
Normal 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
pkg/util/console_other.go
Normal file
6
pkg/util/console_other.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package util
|
||||
|
||||
// HideConsoleWindow is a no-op on non-Windows platforms.
|
||||
func HideConsoleWindow() {}
|
||||
15
pkg/util/console_windows.go
Normal file
15
pkg/util/console_windows.go
Normal 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()
|
||||
}
|
||||
@@ -105,4 +105,3 @@ func ComposeLANURL(addr string) string {
|
||||
}
|
||||
return "http://" + h + ":" + port
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user