diff --git a/.vscode/settings.json b/.vscode/settings.json index 04e5c00..2781998 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -52,7 +52,8 @@ "variant": "cpp" }, "go.toolsEnvVars": { + "CGO_ENABLED": "1", "CGO_CFLAGS": "-I${workspaceFolder}/third_party/whisper/include", - "CGO_LDFLAGS": "-L${workspaceFolder}/third_party/whisper/lib -lwhisper -lggml -lstdc++ -lm" + "CGO_LDFLAGS": "-L${workspaceFolder}/third_party/whisper/lib" } } diff --git a/Makefile b/Makefile index 485b4fa..d59d933 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ ifeq ($(VERSION),) VERSION := $(shell git describe --tags --always --dirty="-dev") endif LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s' +TAG := --tags "fts5" +CGO_FLAG := CGO_ENABLED=1 CGO_CFLAGS="-I./third_party/whisper/include" CGO_LDFLAGS="-L./third_party/whisper/lib" PLATFORMS := \ darwin/amd64 \ @@ -52,7 +54,7 @@ test: build: @echo "Building for current platform..." @$(MKDIR_BIN) - @CGO_ENABLED=1 $(GO) build --tags "fts5" -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)$(BINARY_SUFFIX) main.go + @$(CGO_FLAG) $(GO) build -trimpath $(LDFLAGS) $(TAG) -o bin/$(BINARY_NAME)$(BINARY_SUFFIX) main.go @echo "Done!" crossbuild: clean @@ -67,7 +69,7 @@ crossbuild: clean [ "$$os" = "windows" ] && output_name=$$output_name.exe; \ echo "Building for $$os/$$arch..."; \ echo "Building for $$output_name..."; \ - @GOOS=$$os GOARCH=$$arch CGO_ENABLED=1 GOARM=$$float $(GO) build --tags "fts5" -trimpath $(LDFLAGS) -o $$output_name main.go ; \ + @GOOS=$$os GOARCH=$$arch GOARM=$$float $(CGO_FLAG) $(GO) build -trimpath $(LDFLAGS) $(TAG) -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; \ diff --git a/internal/chatlog/conf/tui.go b/internal/chatlog/conf/tui.go index 993d5ea..2d54fb4 100644 --- a/internal/chatlog/conf/tui.go +++ b/internal/chatlog/conf/tui.go @@ -5,7 +5,6 @@ type TUIConfig struct { LastAccount string `mapstructure:"last_account" json:"last_account"` History []ProcessConfig `mapstructure:"history" json:"history"` Webhook *Webhook `mapstructure:"webhook" json:"webhook"` - Speech *SpeechConfig `mapstructure:"speech" json:"speech"` } var TUIDefaults = map[string]any{} diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go index 5183a74..2453a8d 100644 --- a/internal/chatlog/ctx/context.go +++ b/internal/chatlog/ctx/context.go @@ -186,10 +186,7 @@ func (c *Context) GetWebhook() *conf.Webhook { } func (c *Context) GetSpeech() *conf.SpeechConfig { - if c == nil || c.conf == nil { - return nil - } - return c.conf.Speech + return nil } func (c *Context) SetHTTPEnabled(enabled bool) { diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index 1d7bf92..4d8a78c 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -30,459 +30,9 @@ var EFS embed.FS // 统一的 HTML 预览组件片段 var previewHTMLSnippet = ` - -
-
- 预览 - - -
-
-
-` + +
预览
+` func (s *Service) initRouter() { s.initBaseRouter() @@ -527,69 +77,9 @@ func (s *Service) initAPIRouter() { api.GET("/diary", s.handleDiary) api.GET("/dashboard", s.handleDashboard) api.GET("/search", s.handleSearch) - api.POST("/voice/transcribe", s.handleVoiceTranscribe) } } -func (s *Service) handleVoiceTranscribe(c *gin.Context) { - if s.speech == nil { - c.JSON(http.StatusNotImplemented, gin.H{"error": "speech-to-text not enabled"}) - return - } - - var req struct { - Key string `json:"key"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload", "detail": err.Error()}) - return - } - - key := strings.TrimSpace(req.Key) - if key == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "voice key is required"}) - return - } - - media, err := s.db.GetMedia("voice", key) - if err != nil { - errors.Err(c, err) - return - } - if media == nil || len(media.Data) == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "voice media not found"}) - return - } - - result, err := s.speech.TranscribeSilk(c.Request.Context(), media.Data, s.speechOpts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "transcription failed", "detail": err.Error()}) - return - } - - segments := make([]gin.H, 0, len(result.Segments)) - for _, seg := range result.Segments { - segments = append(segments, gin.H{ - "id": seg.ID, - "start": seg.Start.Seconds(), - "end": seg.End.Seconds(), - "text": seg.Text, - }) - } - - response := gin.H{ - "text": result.Text, - "language": result.Language, - "duration": result.Duration.Seconds(), - "segments": segments, - } - if s.speechCfg != nil { - response["model"] = strings.TrimSpace(s.speechCfg.Model) - } - - c.JSON(http.StatusOK, response) -} - func (s *Service) initMCPRouter() { s.router.Any("/mcp", func(c *gin.Context) { s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request) }) s.router.Any("/sse", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) }) @@ -1050,7 +540,7 @@ func (s *Service) handleDashboard(c *gin.Context) { Messages: v.MsgCount, Avatar: s.composeAvatarURL(k), }) - added++; + added++ } } } @@ -2192,23 +1682,6 @@ func messageHTMLPlaceholder(m *model.Message) string { anchorText = "[" + escapedFull + "]" } else if left == "文件" && rest != "" { // 文件保留文件名 anchorText = "[文件]" + template.HTMLEscapeString(rest) - } else if strings.HasPrefix(left, "语音") { - anchorText = "[" + template.HTMLEscapeString(left) + "]" - voiceURL := template.HTMLEscapeString(url) - voiceKey := "" - if idx := strings.Index(url, "/voice/"); idx >= 0 { - keyPart := url[idx+len("/voice/"):] - if cut := strings.IndexAny(keyPart, "?#"); cut >= 0 { - keyPart = keyPart[:cut] - } - voiceKey = keyPart - } - escapedKey := template.HTMLEscapeString(voiceKey) - anchor := `` + anchorText + `` - button := `` - transcript := `` - container := `` + anchor + ` ` + button + ` ` + transcript + `` - return container } else { anchorText = "[" + template.HTMLEscapeString(left) + "]" } diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go index c30e761..5b1354b 100644 --- a/internal/chatlog/http/service.go +++ b/internal/chatlog/http/service.go @@ -3,18 +3,14 @@ package http import ( "context" "net/http" - "strings" "time" "github.com/gin-gonic/gin" "github.com/mark3labs/mcp-go/server" "github.com/rs/zerolog/log" - "github.com/sjzar/chatlog/internal/chatlog/conf" "github.com/sjzar/chatlog/internal/chatlog/database" "github.com/sjzar/chatlog/internal/errors" - "github.com/sjzar/chatlog/internal/speech" - "github.com/sjzar/chatlog/internal/speech/whispercpp" ) type Service struct { @@ -27,17 +23,11 @@ type Service struct { mcpServer *server.MCPServer mcpSSEServer *server.SSEServer mcpStreamableServer *server.StreamableHTTPServer - - speechCfg *conf.SpeechConfig - speechOpts speech.Options - speech speech.Transcriber - speechErr error } type Config interface { GetHTTPAddr() string GetDataDir() string - GetSpeech() *conf.SpeechConfig } func NewService(conf Config, db *database.Service) *Service { @@ -65,7 +55,6 @@ func NewService(conf Config, db *database.Service) *Service { s.initMCPServer() s.initRouter() - s.initSpeech() return s } @@ -115,44 +104,9 @@ func (s *Service) Stop() error { } log.Info().Msg("HTTP server stopped") - s.shutdownSpeech() return nil } func (s *Service) GetRouter() *gin.Engine { return s.router } - -func (s *Service) initSpeech() { - cfg := s.conf.GetSpeech() - if cfg == nil || !cfg.Enabled { - return - } - - modelPath := strings.TrimSpace(cfg.Model) - if modelPath == "" { - log.Warn().Msg("speech config enabled but model path is empty; disabling speech backend") - return - } - - opts := cfg.ToOptions() - transcriber, err := whispercpp.New(whispercpp.Config{ModelPath: modelPath, DefaultOptions: opts}) - if err != nil { - log.Err(err).Str("model", modelPath).Msg("failed to initialize whisper backend; speech disabled") - s.speechErr = err - return - } - - s.speechCfg = cfg - s.speechOpts = opts - s.speech = transcriber - log.Info().Str("model", modelPath).Msg("speech-to-text backend initialized") -} - -func (s *Service) shutdownSpeech() { - if s.speech != nil { - s.speech.Close() - s.speech = nil - log.Info().Msg("speech-to-text backend stopped") - } -} diff --git a/internal/chatlog/http/static/index.htm b/internal/chatlog/http/static/index.htm index 3f18748..f2f6df1 100644 --- a/internal/chatlog/http/static/index.htm +++ b/internal/chatlog/http/static/index.htm @@ -365,93 +365,6 @@ color: var(--error-color); font-weight: bold; } - - .voice-message { - display: inline-flex; - flex-direction: column; - gap: 6px; - align-items: flex-start; - background: rgba(46, 189, 240, 0.08); - padding: 6px 8px; - border-radius: 8px; - margin: 4px 0; - } - - .voice-message .voice-stt-btn { - background-color: #2d333b; - border: 1px solid #4d6277; - color: #d7e2f2; - font-size: 12px; - padding: 4px 10px; - border-radius: 4px; - cursor: pointer; - transition: background 0.2s ease, opacity 0.2s ease; - } - - .voice-message .voice-stt-btn:hover { - background-color: #3a4a5b; - } - - .voice-message .voice-stt-btn[disabled] { - opacity: 0.65; - cursor: wait; - } - - .voice-message .voice-transcript { - font-size: 13px; - color: #2c3e50; - background: rgba(255, 255, 255, 0.7); - padding: 6px 8px; - border-radius: 6px; - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; - max-width: 600px; - } - - .voice-message .voice-transcript[data-state="idle"] { - display: none; - } - - .voice-message .voice-text { - white-space: pre-wrap; - word-break: break-word; - } - - .voice-message .voice-transcript .voice-segment { - padding: 4px 0; - border-left: 2px solid rgba(52, 152, 219, 0.45); - margin-top: 4px; - padding-left: 8px; - } - - .voice-message .voice-transcript .voice-segment-time { - font-size: 12px; - color: #6c7a89; - } - - .voice-message .voice-segments { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 8px; - } - - .voice-message .voice-meta, - .voice-message .voice-transcript .voice-meta { - margin-top: 6px; - font-size: 12px; - color: #6c7a89; - } - - .voice-message .voice-transcript .voice-segment span { - display: block; - } - - .voice-message .voice-text.voice-empty { - color: #95a5a6; - font-style: italic; - } @@ -1261,7 +1174,6 @@ resultContainer.innerHTML = `
${highlighted}
`; } } - prepareVoiceMessages(resultContainer); } catch (error) { resultContainer.innerHTML = ""; errorMessage.textContent = `查询出错: ${error.message}`; @@ -1376,209 +1288,6 @@ }); } - const voiceFallbackCache = new Map(); - - function prepareVoiceMessages(root) { - if (!root) return; - if ( - window.ChatlogSpeech && - typeof window.ChatlogSpeech.refreshVoiceMessages === - "function" - ) { - window.ChatlogSpeech.refreshVoiceMessages(); - return; - } - fallbackEnsureVoiceState(root); - } - - function fallbackEnsureVoiceState(root) { - root.querySelectorAll(".voice-message").forEach((container) => { - const transcript = - container.querySelector(".voice-transcript"); - if (transcript && !transcript.dataset.state) { - transcript.dataset.state = "idle"; - } - const btn = container.querySelector(".voice-stt-btn"); - if (btn && !btn.dataset.labelIdle) { - btn.dataset.labelIdle = btn.textContent.trim(); - } - }); - } - - function voiceKeyFromButton(btn) { - const container = btn.closest(".voice-message"); - if (!container) return ""; - const link = container.querySelector( - "a.voice-original, a[href]" - ); - if (!link) return ""; - const href = link.getAttribute("href") || ""; - try { - const url = new URL(href, window.location.href); - return ( - url.searchParams.get("msgid") || - url.searchParams.get("id") || - url.pathname - ); - } catch (_err) { - return href; - } - } - - async function fallbackVoiceRequest(btn) { - const container = btn.closest(".voice-message"); - const transcript = - container?.querySelector(".voice-transcript"); - const key = voiceKeyFromButton(btn); - if (!container || !transcript || !key) return; - - if (!transcript.dataset.state) { - transcript.dataset.state = "idle"; - } - - const cached = voiceFallbackCache.get(key); - if (cached) { - transcript.innerHTML = renderTranscript(cached); - transcript.dataset.state = "ready"; - btn.disabled = false; - btn.dataset.state = "done"; - btn.textContent = "查看文字"; - return; - } - - const originalText = - btn.dataset.labelIdle || btn.textContent || "语音转文字"; - btn.dataset.labelIdle = originalText; - btn.disabled = true; - btn.dataset.state = "loading"; - btn.textContent = "转文字中…"; - - transcript.dataset.state = "loading"; - transcript.textContent = "语音识别进行中…"; - - try { - const response = await fetch("/api/v1/voice/transcribe", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ key }), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const payload = await response.json(); - voiceFallbackCache.set(key, payload); - transcript.innerHTML = renderTranscript(payload); - transcript.dataset.state = "ready"; - btn.disabled = false; - btn.textContent = "已转文字"; - btn.dataset.state = "done"; - } catch (err) { - console.error("语音识别失败:", err); - transcript.dataset.state = "error"; - transcript.textContent = `转文字失败: ${err.message}`; - btn.textContent = "重试转文字"; - btn.dataset.state = "error"; - } finally { - btn.disabled = false; - } - } - - function renderTranscript(data) { - const pieces = []; - const rawText = - data && typeof data.text === "string" - ? data.text.trim() - : ""; - if (rawText) { - pieces.push( - `
${escapeHtml(rawText)}
` - ); - } else { - pieces.push( - '
未识别到文本
' - ); - } - - const segments = Array.isArray(data && data.segments) - ? data.segments - : []; - if (segments.length) { - const rendered = segments - .map((segment) => { - const start = formatSeconds( - segment && segment.start - ); - const end = formatSeconds(segment && segment.end); - const caption = - segment && segment.text - ? segment.text.trim() - : ""; - return `
${escapeHtml( - start - )} - ${escapeHtml(end)}${escapeHtml( - caption - )}
`; - }) - .join(""); - pieces.push( - `
${rendered}
` - ); - } - - const meta = []; - if (data && data.language) { - meta.push(`语言: ${escapeHtml(String(data.language))}`); - } - if ( - data && - typeof data.duration === "number" && - isFinite(data.duration) && - data.duration > 0 - ) { - meta.push( - `音频: ${escapeHtml(formatSeconds(data.duration))}` - ); - } - if (meta.length) { - pieces.push( - `
${meta.join(" · ")}
` - ); - } - - return pieces.join(""); - } - - function formatSeconds(value) { - const num = Number(value); - if (!isFinite(num) || num <= 0) { - return "0s"; - } - if (num >= 60) { - const m = Math.floor(num / 60); - const s = Math.round(num % 60); - return `${m}m${s < 10 ? "0" : ""}${s}s`; - } - if (num < 1) { - return `${num.toFixed(2)}s`; - } - return `${Math.round(num)}s`; - } - - document.addEventListener("click", (event) => { - const btn = event.target.closest(".voice-stt-btn"); - if (!btn) return; - if ( - window.ChatlogSpeech && - typeof window.ChatlogSpeech.requestVoice === "function" - ) { - return; - } - event.preventDefault(); - fallbackVoiceRequest(btn); - }); - const diaryDateInput = document.getElementById("diary-date"); if (diaryDateInput) { const today = new Date(); diff --git a/internal/speech/whispercpp/transcriber.go b/internal/speech/whispercpp/transcriber.go index 0b4cc5d..7a2b069 100644 --- a/internal/speech/whispercpp/transcriber.go +++ b/internal/speech/whispercpp/transcriber.go @@ -1,11 +1,5 @@ -//go:build cgo - package whispercpp -/* -#cgo CFLAGS: -I${SRCDIR}/../../../third_party/whisper/include -#cgo LDFLAGS: -L${SRCDIR}/../../../third_party/whisper/lib -lwhisper -lggml -lstdc++ -lm -*/ import "C" import ( diff --git a/third_party/whisper/lib/libggml.a b/third_party/whisper/lib/libggml.a new file mode 100644 index 0000000..3c28723 Binary files /dev/null and b/third_party/whisper/lib/libggml.a differ