3ad728406e
- 日志:收/发消息均显示群名称,管理员显示真实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>
123 lines
2.7 KiB
Go
123 lines
2.7 KiB
Go
package llm
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// CallRecord records a single LLM API call.
|
|
type CallRecord struct {
|
|
Time time.Time `json:"time"`
|
|
Model string `json:"model"`
|
|
Duration time.Duration `json:"duration_ms"`
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// CallLogger is a thread-safe ring buffer for LLM call records.
|
|
type CallLogger struct {
|
|
mu sync.RWMutex
|
|
records []CallRecord
|
|
capacity int
|
|
head int
|
|
size int
|
|
}
|
|
|
|
var globalCallLogger = &CallLogger{capacity: 500}
|
|
|
|
// LogCall records an LLM call. Safe for concurrent use.
|
|
func LogCall(r CallRecord) {
|
|
globalCallLogger.log(r)
|
|
}
|
|
|
|
// GetCalls returns recent call records, newest first.
|
|
func GetCalls(limit int) []CallRecord {
|
|
return globalCallLogger.get(limit)
|
|
}
|
|
|
|
func (cl *CallLogger) log(r CallRecord) {
|
|
cl.mu.Lock()
|
|
defer cl.mu.Unlock()
|
|
|
|
if cl.records == nil {
|
|
cl.records = make([]CallRecord, cl.capacity)
|
|
}
|
|
|
|
r.Time = time.Now()
|
|
cl.records[cl.head] = r
|
|
cl.head = (cl.head + 1) % cl.capacity
|
|
if cl.size < cl.capacity {
|
|
cl.size++
|
|
}
|
|
|
|
broadcastCall(r)
|
|
}
|
|
|
|
func (cl *CallLogger) get(limit int) []CallRecord {
|
|
cl.mu.RLock()
|
|
defer cl.mu.RUnlock()
|
|
|
|
if limit <= 0 || limit > cl.size {
|
|
limit = cl.size
|
|
}
|
|
|
|
result := make([]CallRecord, limit)
|
|
for i := 0; i < limit; i++ {
|
|
idx := (cl.head - 1 - i) % cl.capacity
|
|
if idx < 0 {
|
|
idx += cl.capacity
|
|
}
|
|
result[i] = cl.records[idx]
|
|
}
|
|
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:
|
|
}
|
|
}
|
|
}
|