fix: 消息日志增强 + 历史消息抑制 + SSE实时追踪 + 群聊上下文优化
- 日志:收/发消息均显示群名称,管理员显示真实QQ昵称而非"开拓者" - 历史消息:服务重启后NapCat回放的历史消息不再触发回复,静默注入上下文 - 消息时间戳:转发给AI时附带【消息时间: HH:MM:SS (XmXs前)】标记 - ♪ 分割符:QQ消息支持♪作为句子断点 - AI-Core SSE端点:全链路追踪实时推送,ethend不再5秒轮询 - 群聊上下文:AI-Core明确被告知消息来自群聊,以实际发送者为主语 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -46,8 +47,13 @@ import (
|
||||
var cfg Config
|
||||
|
||||
func main() {
|
||||
// 自动加载 .env 文件(来自仓库根目录)
|
||||
if err := godotenv.Load("../../.env"); err != nil {
|
||||
// 自动加载 .env 文件(优先从可执行文件位置反推仓库根目录)
|
||||
_ = godotenv.Load() // 先尝试当前目录
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
_ = godotenv.Load(filepath.Join(filepath.Dir(exe), "..", "..", ".env"))
|
||||
}
|
||||
// 兜底:如果 LLM_MODEL 仍未设置,打印提示
|
||||
if os.Getenv("LLM_MODEL") == "" {
|
||||
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
|
||||
}
|
||||
|
||||
@@ -431,6 +437,36 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// LLM 调用 SSE 实时推送
|
||||
mux.HandleFunc("/api/v1/llm-calls/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch, done := llm.SubscribeCalls()
|
||||
defer llm.UnsubscribeCalls(ch)
|
||||
|
||||
for {
|
||||
select {
|
||||
case rec := <-ch:
|
||||
data, _ := json.Marshal(rec)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
case <-done:
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
// 工具调用记录
|
||||
mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -724,6 +760,13 @@ func handleChat(
|
||||
Images []string `json:"images,omitempty"` // 图片 base64 data URL
|
||||
Mode string `json:"mode"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Source struct {
|
||||
Platform string `json:"platform"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
SenderName string `json:"sender_name"`
|
||||
OriginalUID string `json:"original_uid"`
|
||||
} `json:"source,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "无效的请求体", http.StatusBadRequest)
|
||||
@@ -781,12 +824,13 @@ func handleChat(
|
||||
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
|
||||
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
|
||||
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
Message: req.Message,
|
||||
Images: req.Images,
|
||||
Mode: req.Mode,
|
||||
Nickname: userNickname,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
Message: req.Message,
|
||||
Images: req.Images,
|
||||
Mode: req.Mode,
|
||||
Nickname: userNickname,
|
||||
ChannelType: req.Source.ChannelType,
|
||||
})
|
||||
if err != nil {
|
||||
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)})
|
||||
|
||||
@@ -576,7 +576,7 @@ func (t *Thinker) performPlatformObservation() {
|
||||
|
||||
log.Printf("[后台思考] 平台观察:%d 个频道有记忆数据,调用中间会话生成摘要...", len(channelSummaries))
|
||||
|
||||
systemPrompt := "你是昔涟的后台观察助手。以下是各聊天平台频道最近的观察摘要。\n请生成简洁报告:\n1. 各频道近期讨论主题(每频道1-2句)\n2. 是否有需要开拓者关注的重要/紧急事项\n3. 整体氛围评估\n不要直接对开拓者说话,这是给昔涟参考的幕后报告。\n输出为JSON格式:{\"summary\": \"报告内容\", \"needs_attention\": true/false}"
|
||||
systemPrompt := "你是昔涟的后台观察助手。以下是各聊天平台频道最近的观察摘要。\n请生成简洁报告:\n1. 各频道近期讨论主题(每频道1-2句)\n2. 是否有需要关注的重要/紧急事项\n3. 整体氛围评估\n注意:这些记忆可能来自不同的群聊成员(不只是开拓者),请以实际发言者为主语描述。不要直接对开拓者说话,这是给昔涟参考的幕后报告。\n输出为JSON格式:{\"summary\": \"报告内容\", \"needs_attention\": true/false}"
|
||||
|
||||
userPrompt := strings.Join(channelSummaries, "\n\n")
|
||||
|
||||
@@ -1099,12 +1099,12 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
|
||||
switch triggerReason {
|
||||
case "post_chat":
|
||||
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||
case "silence":
|
||||
t.mu.Lock()
|
||||
silenceDuration := time.Since(t.lastUserMessage)
|
||||
t.mu.Unlock()
|
||||
sb.WriteString(fmt.Sprintf("开拓者已经大约 %s 没有说话了。你有点想知道他在做什么……\n",
|
||||
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
|
||||
formatDurationHuman(silenceDuration)))
|
||||
default:
|
||||
sb.WriteString("现在是你的自由思考时间。\n")
|
||||
@@ -1117,7 +1117,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
msgCount := 0
|
||||
for _, msg := range convHistory {
|
||||
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
||||
roleLabel := "开拓者"
|
||||
roleLabel := "用户"
|
||||
if msg.Role == model.RoleAssistant {
|
||||
roleLabel = "昔涟"
|
||||
}
|
||||
@@ -1185,7 +1185,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
|
||||
// 平台观察摘要 (中间会话产生的报告)
|
||||
if platformObservation != "" {
|
||||
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,供参考)】\n")
|
||||
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,可能包含多位群聊成员的信息)】\n")
|
||||
sb.WriteString(platformObservation)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -1193,9 +1193,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
// 结尾引导
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想他,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才输出一行【主动消息】+ 你要发给他的话。")
|
||||
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要对开拓者说的话(用\"你\"称呼),语气自然像主动找他聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||||
sb.WriteString("\n⚠️ 如果有人正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认对方现在是醒着、有空、且真的需要关心时,才输出一行【主动消息】+ 你要发给他的话。")
|
||||
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要说的话(用\"你\"称呼),语气自然像主动找对方聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ func (cl *CallLogger) log(r CallRecord) {
|
||||
if cl.size < cl.capacity {
|
||||
cl.size++
|
||||
}
|
||||
|
||||
broadcastCall(r)
|
||||
}
|
||||
|
||||
func (cl *CallLogger) get(limit int) []CallRecord {
|
||||
@@ -72,3 +74,49 @@ func (cl *CallLogger) get(limit int) []CallRecord {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- SSE subscriber system ---
|
||||
|
||||
type callSubscriber struct {
|
||||
ch chan CallRecord
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
callSubscribers []*callSubscriber
|
||||
callSubscribersMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SubscribeCalls returns a channel that receives new CallRecords and a done channel.
|
||||
func SubscribeCalls() (<-chan CallRecord, <-chan struct{}) {
|
||||
ch := make(chan CallRecord, 20)
|
||||
done := make(chan struct{})
|
||||
callSubscribersMu.Lock()
|
||||
callSubscribers = append(callSubscribers, &callSubscriber{ch: ch, done: done})
|
||||
callSubscribersMu.Unlock()
|
||||
return ch, done
|
||||
}
|
||||
|
||||
// UnsubscribeCalls removes a subscriber. Safe to call multiple times.
|
||||
func UnsubscribeCalls(ch <-chan CallRecord) {
|
||||
callSubscribersMu.Lock()
|
||||
defer callSubscribersMu.Unlock()
|
||||
for i, s := range callSubscribers {
|
||||
if s.ch == ch {
|
||||
close(s.done)
|
||||
callSubscribers = append(callSubscribers[:i], callSubscribers[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastCall(r CallRecord) {
|
||||
callSubscribersMu.RLock()
|
||||
defer callSubscribersMu.RUnlock()
|
||||
for _, s := range callSubscribers {
|
||||
select {
|
||||
case s.ch <- r:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message stri
|
||||
观察到的消息: %s
|
||||
|
||||
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
|
||||
提取角度:这条消息中包含了什么关于聊天参与者、讨论主题、事件或氛围的信息?
|
||||
消息格式为:[群聊 群号] 发送者昵称 (QQ号):消息内容
|
||||
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
|
||||
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
|
||||
|
||||
每条记忆需要包含以下字段:
|
||||
- content: 完整的记忆内容(一句话描述,客观准确)
|
||||
|
||||
@@ -117,12 +117,13 @@ func NewOrchestrator(
|
||||
|
||||
// ProcessParams 处理参数
|
||||
type ProcessParams struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
Message string
|
||||
Images []string // 图片 base64 data URL (多模态)
|
||||
Mode string // text / voice_msg / voice_assistant
|
||||
Nickname string
|
||||
UserID string
|
||||
SessionID string
|
||||
Message string
|
||||
Images []string // 图片 base64 data URL (多模态)
|
||||
Mode string // text / voice_msg / voice_assistant
|
||||
Nickname string
|
||||
ChannelType string // direct / group
|
||||
}
|
||||
|
||||
// ProcessResult 处理结果
|
||||
@@ -334,6 +335,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
PersonaPrompt: systemPrompt,
|
||||
DialogHistory: history,
|
||||
Mode: params.Mode,
|
||||
ChannelType: params.ChannelType,
|
||||
}
|
||||
if prevEnrichment != nil {
|
||||
synthParams.MemorySummary = prevEnrichment.MemorySummary
|
||||
|
||||
@@ -45,6 +45,7 @@ type SynthesizeParams struct {
|
||||
KnowledgeInfo string // 知识库检索摘要
|
||||
PendingToolResults []PendingToolResult // 上一轮异步完成的工具结果
|
||||
Mode string // text / voice_assistant
|
||||
ChannelType string // direct / group
|
||||
}
|
||||
|
||||
// Synthesize 综合所有子会话结果,流式生成最终回复。
|
||||
@@ -210,7 +211,15 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
|
||||
Content: systemPrompt,
|
||||
})
|
||||
|
||||
// 注入记忆摘要
|
||||
// 群聊上下文:当消息来自群聊时,告知模型这是一条群聊消息而非一对一私聊。
|
||||
if params.ChannelType == "group" {
|
||||
messages = append(messages, model.LLMMessage{
|
||||
Role: model.RoleSystem,
|
||||
Content: "【群聊上下文】这条消息来自QQ群聊。消息前缀 [群聊 群号] 昵称 (QQ号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请用发送者的真实名字称呼,不要叫所有人开拓者或叶酱。只在对你说话或延续已有对话时才回复。",
|
||||
})
|
||||
}
|
||||
|
||||
// 注入记忆摘要// 注入记忆摘要
|
||||
if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") {
|
||||
messages = append(messages, model.LLMMessage{
|
||||
Role: model.RoleSystem,
|
||||
|
||||
Reference in New Issue
Block a user