feat: 全链路优化 — 死锁修复、MD3主题、上下文持久化、群聊自然化、打字状态、知识库
**死锁根因修复** - periodicThinkLoop:1015 orphaned lock → 删除(字段已原子化) - RecordUserMessage 隔离为 recordMu - atomic.Int64 替换 lastUserMessage/lastThinkTime 等 **MD3 / Android 17 主题** - 毛玻璃卡片 (backdrop-filter) - MD3 色彩令牌 (pink primary #f472b6) - icons.js 独立矢量图标库 + 运行时 emoji 替换 - 无边框卡片、圆角按钮、阴影层次 **上下文持久化** - AddMessage → saveToDB 异步写 PostgreSQL - LoadFromDB 恢复 (admin-session-main + 懒加载) - LLMMessage.Timestamp 字段 **群聊与适配器** - group_ambient 模式: 非@消息让 LLM 自己判断是否插话 - 戳一戳动作消息总是回复 - NapCat 打字状态 (set_input_status, 最小3秒显示) - HTTP API 配置 (http_url/http_token) **知识库 & 防编造** - knowledge.CanHandle 对 chat 意图也触发 - 关键词预筛选避免无关 embedding 调用 - persona + synthesizer 三重诚实规则 - 工具结果持久化到会话历史 **平台桥接器** - detached:true Go进程独立存活 - ethend 重启自动接管已运行服务 - stop() 接管模式 taskkill/F/ PID - Windows netstat 替代 fuser 获取 PID - 重复适配器种子逻辑修复 - 失败转发日志 Direction: error **崩溃诊断** - crashlog 包 (Recover + WrapHTTP + LLMCall) - /api/v1/debug/goroutines 端点 - thinker 操作日志 + 30s stats - 日志写入 logs/ 目录持久化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -185,7 +185,7 @@ func main() {
|
||||
if err != nil {
|
||||
msgLogger.Log(logging.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Direction: "outgoing",
|
||||
Direction: "error",
|
||||
Platform: msg.Platform,
|
||||
ChannelID: msg.ChannelID,
|
||||
SenderID: msg.OriginalSenderUID,
|
||||
@@ -196,7 +196,14 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// 戳一戳/动作消息:总是回复
|
||||
isPoke := strings.Contains(msg.Content, "【动作】")
|
||||
|
||||
switch {
|
||||
case isPoke:
|
||||
msg.RouteType = "normal"
|
||||
response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin)
|
||||
|
||||
case isMessageHistorical(msg, router):
|
||||
msg.RouteType = "silent"
|
||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||
@@ -224,10 +231,12 @@ func main() {
|
||||
response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform}
|
||||
|
||||
case isSilent:
|
||||
msg.RouteType = "silent"
|
||||
// 群聊环境消息:让 LLM 自己判断是否值得插话
|
||||
msg.RouteType = "group_ambient"
|
||||
// 同时后台记录(记忆提取)
|
||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||
fireSilent(namespace, imageURLs, videoURLs, voiceURLs)
|
||||
response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform}
|
||||
response, routeErr = forwardToAICore(cfg, msg, "group_ambient", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin)
|
||||
|
||||
default:
|
||||
msg.RouteType = "normal"
|
||||
@@ -237,7 +246,7 @@ func main() {
|
||||
if routeErr != nil {
|
||||
msgLogger.Log(logging.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Direction: "outgoing",
|
||||
Direction: "error",
|
||||
Platform: msg.Platform,
|
||||
ChannelID: msg.ChannelID,
|
||||
SenderID: msg.OriginalSenderUID,
|
||||
@@ -282,9 +291,13 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
bh := handler.NewBridgeHandler(router)
|
||||
bh.SetLogFunc(func(platform, channelID, senderID, content string, success bool) {
|
||||
dir := "outgoing"
|
||||
if !success {
|
||||
dir = "error"
|
||||
}
|
||||
msgLogger.Log(logging.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Direction: "outgoing",
|
||||
Direction: dir,
|
||||
Platform: platform,
|
||||
ChannelID: channelID,
|
||||
SenderID: senderID,
|
||||
@@ -439,6 +452,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
||||
toSend = append(toSend, rm)
|
||||
}
|
||||
}
|
||||
// NapCat 输入状态:私聊时在发送前显示"正在输入"
|
||||
if messageType == "private" {
|
||||
if cur, err := router.GetAdapter(adapterKey); err == nil {
|
||||
if qa, ok := cur.(*qqadapter.Adapter); ok {
|
||||
qa.SetTypingStatus(userID, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
interval := time.Duration(adapter.SendIntervalMs()) * time.Millisecond
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
@@ -470,6 +491,14 @@ func startOBv11Readers(router *bridge.PlatformRouter) {
|
||||
fmt.Printf("[qq:%s] send msg error: %v\n", adapterKey, sendErr)
|
||||
}
|
||||
}
|
||||
// NapCat: clear typing indicator
|
||||
if messageType == "private" {
|
||||
if cur, err := router.GetAdapter(adapterKey); err == nil {
|
||||
if qa, ok := cur.(*qqadapter.Adapter); ok {
|
||||
qa.SetTypingStatus(userID, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -501,13 +530,17 @@ func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAd
|
||||
}
|
||||
|
||||
// Seed default adapters for platforms that have no stored config.
|
||||
// Track platform types (not config names) to avoid duplicate adapters.
|
||||
seededTypes := map[string]bool{}
|
||||
for _, a := range adapters {
|
||||
seededTypes[a.PlatformName()] = true
|
||||
}
|
||||
for _, stored := range store.List() {
|
||||
seededTypes[stored.Platform] = true // stored.Platform is the type (e.g. "obv11")
|
||||
}
|
||||
defaultPlatforms := []string{"obv11", "telegram", "webhook", "wechat", "feishu", "discord"}
|
||||
for _, name := range defaultPlatforms {
|
||||
if seen[name] || seededTypes[name] {
|
||||
if seededTypes[name] {
|
||||
continue
|
||||
}
|
||||
fields := mergeFields(cfg, name, nil)
|
||||
@@ -548,7 +581,14 @@ func createSingleAdapter(cfg *config.Config, platform, configName, configID stri
|
||||
sendIntervalMs = n
|
||||
}
|
||||
}
|
||||
return qqadapter.NewAdapter(configID, configName, mode, port, token, remoteURL, sendIntervalMs)
|
||||
adapter := qqadapter.NewAdapter(configID, configName, mode, port, token, remoteURL, sendIntervalMs)
|
||||
// Optional HTTP API configuration (for typing status, etc.)
|
||||
httpURL := fields["http_url"]
|
||||
httpToken := fields["http_token"]
|
||||
if httpURL != "" || httpToken != "" {
|
||||
adapter.SetHTTPConfig(httpURL, httpToken)
|
||||
}
|
||||
return adapter
|
||||
case "telegram":
|
||||
token := cfg.TelegramToken
|
||||
if t, ok := fields["bot_token"]; ok && t != "" {
|
||||
|
||||
@@ -32,8 +32,11 @@ type Adapter struct {
|
||||
port string
|
||||
accessToken string
|
||||
remoteURL string // NapCat OneBot WS server URL, used in client mode
|
||||
sendIntervalMs int // minimum interval between consecutive messages
|
||||
selfID string // bot's own QQ number, populated from incoming messages
|
||||
httpURL string // NapCat HTTP API base URL (optional, derived from remoteURL if empty)
|
||||
httpToken_ string // NapCat HTTP API access token (optional, uses accessToken if empty)
|
||||
sendIntervalMs int // minimum interval between consecutive messages
|
||||
lastTypingSent time.Time // last time typing indicator was sent, for min display duration
|
||||
selfID string // bot's own QQ number, populated from incoming messages
|
||||
conn *websocket.Conn
|
||||
connMu sync.Mutex
|
||||
connected bool
|
||||
@@ -64,6 +67,14 @@ func NewAdapter(configID, configName, mode, port, accessToken, remoteURL string,
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPConfig sets the optional HTTP API configuration.
|
||||
func (a *Adapter) SetHTTPConfig(url, token string) {
|
||||
a.httpURL = url
|
||||
if token != "" {
|
||||
a.httpToken_ = token
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) PlatformName() string { return "obv11" }
|
||||
func (a *Adapter) ConfigID() string { return a.configID }
|
||||
func (a *Adapter) ConfigName() string { return a.configName }
|
||||
@@ -97,8 +108,12 @@ func (a *Adapter) SetGroupName(groupID int64, name string) {
|
||||
a.groupNamesMu.Unlock()
|
||||
}
|
||||
|
||||
// httpBase derives the HTTP base URL from the WebSocket remote URL.
|
||||
// httpBase returns the HTTP API base URL.
|
||||
// Uses configured httpURL if set, otherwise derives from WebSocket remoteURL.
|
||||
func (a *Adapter) httpBase() string {
|
||||
if a.httpURL != "" {
|
||||
return a.httpURL
|
||||
}
|
||||
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
|
||||
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
|
||||
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
|
||||
@@ -107,6 +122,48 @@ func (a *Adapter) httpBase() string {
|
||||
return httpBase
|
||||
}
|
||||
|
||||
// httpToken returns the HTTP API access token.
|
||||
func (a *Adapter) httpToken() string {
|
||||
if a.httpToken_ != "" {
|
||||
return a.httpToken_
|
||||
}
|
||||
return a.accessToken
|
||||
}
|
||||
|
||||
// SetTypingStatus sends a typing indicator to a private chat user.
|
||||
// eventType: 1 = typing started, 0 = typing stopped.
|
||||
// This uses the NapCat HTTP API (not standard OneBot v11).
|
||||
// Enforces a minimum 3s display duration.
|
||||
func (a *Adapter) SetTypingStatus(userID int64, eventType int) error {
|
||||
if eventType == 0 {
|
||||
// 确保"正在输入"至少显示了 3 秒
|
||||
if time.Since(a.lastTypingSent) < 3*time.Second {
|
||||
time.Sleep(3*time.Second - time.Since(a.lastTypingSent))
|
||||
}
|
||||
} else {
|
||||
a.lastTypingSent = time.Now()
|
||||
}
|
||||
url := a.httpBase() + "/set_input_status"
|
||||
if token := a.httpToken(); token != "" {
|
||||
url += "?access_token=" + token
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": fmt.Sprintf("%d", userID),
|
||||
"event_type": eventType,
|
||||
})
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
log.Printf("[qq] set_input_status error: %v (url=%s)", err, url)
|
||||
return fmt.Errorf("set_input_status: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("[qq] set_input_status HTTP %d (url=%s)", resp.StatusCode, url)
|
||||
return fmt.Errorf("set_input_status: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
|
||||
func (a *Adapter) fetchGroupName(groupID int64) {
|
||||
if a.mode != "client" || a.remoteURL == "" {
|
||||
|
||||
@@ -7,11 +7,21 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"regexp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
|
||||
)
|
||||
|
||||
// Regex patterns for markdown stripping.
|
||||
var (
|
||||
mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||
mdItalicRe = regexp.MustCompile(`\*(.+?)\*`)
|
||||
mdStrikethroughRe = regexp.MustCompile(`~~(.+?)~~`)
|
||||
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||
)
|
||||
|
||||
// BridgeHandler exposes the Platform Bridge REST API.
|
||||
type BridgeHandler struct {
|
||||
router *bridge.PlatformRouter
|
||||
@@ -200,39 +210,57 @@ func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
|
||||
userID := parseIntSafe(req.UserID)
|
||||
groupID := parseIntSafe(req.GroupID)
|
||||
|
||||
// 过滤 <action>/<app> 标签,去除 markdown 标记
|
||||
content := filterActions(req.Content)
|
||||
content = convertMarkdownPlain(content)
|
||||
|
||||
// 按 \n\n 和 ♪ 拆分为多条消息
|
||||
messages := splitProactiveContent(content)
|
||||
|
||||
// Prepend CQ @mention tag if at_user_id is specified
|
||||
content := req.Content
|
||||
atPrefix := ""
|
||||
if req.AtUserID != "" {
|
||||
content = fmt.Sprintf("[CQ:at,qq=%s] %s", req.AtUserID, content)
|
||||
atPrefix = fmt.Sprintf("[CQ:at,qq=%s] ", req.AtUserID)
|
||||
}
|
||||
|
||||
// Resolve adapter: try exact name first, then find by platform type.
|
||||
// Resolve adapter
|
||||
adapterName := req.Platform
|
||||
err := h.router.SendProactive(adapterName, msgType, userID, groupID, content)
|
||||
if err != nil {
|
||||
for _, name := range h.router.ListAdapters() {
|
||||
if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() {
|
||||
adapterName = name
|
||||
err = h.router.SendProactive(name, msgType, userID, groupID, content)
|
||||
break
|
||||
var sendErr error
|
||||
for i, msg := range messages {
|
||||
fullMsg := atPrefix + msg
|
||||
if i > 0 {
|
||||
atPrefix = "" // only first message gets @mention
|
||||
}
|
||||
sendErr = h.router.SendProactive(adapterName, msgType, userID, groupID, fullMsg)
|
||||
if sendErr != nil {
|
||||
// Fallback: try other adapters with same platform name
|
||||
for _, name := range h.router.ListAdapters() {
|
||||
if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() {
|
||||
adapterName = name
|
||||
sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if sendErr != nil {
|
||||
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, sendErr)
|
||||
break
|
||||
}
|
||||
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d msg=%d/%d",
|
||||
adapterName, msgType, userID, groupID, req.AtUserID, len(fullMsg), i+1, len(messages))
|
||||
if h.logFn != nil {
|
||||
chID := req.GroupID
|
||||
if msgType == "private" {
|
||||
chID = req.UserID
|
||||
}
|
||||
h.logFn(req.Platform, chID, "Cyrene", fullMsg, true)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+err.Error()))
|
||||
if sendErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+sendErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d",
|
||||
adapterName, msgType, userID, groupID, req.AtUserID, len(content))
|
||||
if h.logFn != nil {
|
||||
chID := req.GroupID
|
||||
if msgType == "private" {
|
||||
chID = req.UserID
|
||||
}
|
||||
h.logFn(req.Platform, chID, "Cyrene", content, true)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "消息已发送",
|
||||
@@ -259,3 +287,71 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// filterActions removes <action> and <app> tags and their content.
|
||||
func filterActions(text string) string {
|
||||
tags := [][2]string{
|
||||
{"<action>", "</action>"},
|
||||
{"<app>", "</app>"},
|
||||
}
|
||||
for _, t := range tags {
|
||||
openTag, closeTag := t[0], t[1]
|
||||
for {
|
||||
start := strings.Index(text, openTag)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(text[start:], closeTag)
|
||||
if end == -1 {
|
||||
text = text[:start] + text[start+len(openTag):]
|
||||
continue
|
||||
}
|
||||
text = text[:start] + text[start+end+len(closeTag):]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// convertMarkdownPlain strips basic markdown formatting.
|
||||
func convertMarkdownPlain(md string) string {
|
||||
md = mdBoldRe.ReplaceAllString(md, "$1")
|
||||
md = mdItalicRe.ReplaceAllString(md, "$1")
|
||||
md = mdStrikethroughRe.ReplaceAllString(md, "$1")
|
||||
md = mdHeadingRe.ReplaceAllString(md, "")
|
||||
return md
|
||||
}
|
||||
|
||||
// splitProactiveContent splits long proactive content into multiple messages.
|
||||
// Strategy same as splitContent in cmd/main.go: split by \n\n, then by ♪.
|
||||
func splitProactiveContent(text string) []string {
|
||||
rawParts := strings.Split(text, "\n\n")
|
||||
var parts []string
|
||||
for _, p := range rawParts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(p, "♪") {
|
||||
for _, sub := range strings.Split(p, "♪") {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if sub != "" {
|
||||
parts = append(parts, sub)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge very short segments with neighbors (min 8 runes).
|
||||
const minRunes = 8
|
||||
var merged []string
|
||||
for _, part := range parts {
|
||||
if len([]rune(part)) < minRunes && len(merged) > 0 {
|
||||
merged[len(merged)-1] = merged[len(merged)-1] + part
|
||||
} else {
|
||||
merged = append(merged, part)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user