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