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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -46,8 +47,13 @@ import (
|
|||||||
var cfg Config
|
var cfg Config
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 自动加载 .env 文件(来自仓库根目录)
|
// 自动加载 .env 文件(优先从可执行文件位置反推仓库根目录)
|
||||||
if err := godotenv.Load("../../.env"); err != nil {
|
_ = 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 文件,将使用环境变量或默认值")
|
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) {
|
mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@@ -724,6 +760,13 @@ func handleChat(
|
|||||||
Images []string `json:"images,omitempty"` // 图片 base64 data URL
|
Images []string `json:"images,omitempty"` // 图片 base64 data URL
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Nickname string `json:"nickname,omitempty"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "无效的请求体", http.StatusBadRequest)
|
http.Error(w, "无效的请求体", http.StatusBadRequest)
|
||||||
@@ -787,6 +830,7 @@ func handleChat(
|
|||||||
Images: req.Images,
|
Images: req.Images,
|
||||||
Mode: req.Mode,
|
Mode: req.Mode,
|
||||||
Nickname: userNickname,
|
Nickname: userNickname,
|
||||||
|
ChannelType: req.Source.ChannelType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)})
|
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))
|
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")
|
userPrompt := strings.Join(channelSummaries, "\n\n")
|
||||||
|
|
||||||
@@ -1099,12 +1099,12 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
|
|
||||||
switch triggerReason {
|
switch triggerReason {
|
||||||
case "post_chat":
|
case "post_chat":
|
||||||
sb.WriteString("开拓者刚和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
sb.WriteString("刚有人和你聊完天。你想自然地在心里回味一下刚才的对话……\n")
|
||||||
case "silence":
|
case "silence":
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
silenceDuration := time.Since(t.lastUserMessage)
|
silenceDuration := time.Since(t.lastUserMessage)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
sb.WriteString(fmt.Sprintf("开拓者已经大约 %s 没有说话了。你有点想知道他在做什么……\n",
|
sb.WriteString(fmt.Sprintf("已经大约 %s 没有说话了。你有点想知道大家在做什么……\n",
|
||||||
formatDurationHuman(silenceDuration)))
|
formatDurationHuman(silenceDuration)))
|
||||||
default:
|
default:
|
||||||
sb.WriteString("现在是你的自由思考时间。\n")
|
sb.WriteString("现在是你的自由思考时间。\n")
|
||||||
@@ -1117,7 +1117,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
msgCount := 0
|
msgCount := 0
|
||||||
for _, msg := range convHistory {
|
for _, msg := range convHistory {
|
||||||
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
|
||||||
roleLabel := "开拓者"
|
roleLabel := "用户"
|
||||||
if msg.Role == model.RoleAssistant {
|
if msg.Role == model.RoleAssistant {
|
||||||
roleLabel = "昔涟"
|
roleLabel = "昔涟"
|
||||||
}
|
}
|
||||||
@@ -1185,7 +1185,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
|
|
||||||
// 平台观察摘要 (中间会话产生的报告)
|
// 平台观察摘要 (中间会话产生的报告)
|
||||||
if platformObservation != "" {
|
if platformObservation != "" {
|
||||||
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,供参考)】\n")
|
sb.WriteString("\n\n【平台频道观察报告(中间会话生成,可能包含多位群聊成员的信息)】\n")
|
||||||
sb.WriteString(platformObservation)
|
sb.WriteString(platformObservation)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -1193,9 +1193,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
|||||||
// 结尾引导
|
// 结尾引导
|
||||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
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❗【主动消息】标记必须独占一行开头,后面紧跟你要对开拓者说的话(用\"你\"称呼),语气自然像主动找他聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
sb.WriteString("\n❗【主动消息】标记必须独占一行开头,后面紧跟你要说的话(用\"你\"称呼),语气自然像主动找对方聊天。不要在反思正文中提及\"主动消息\"这个词——如果需要表达这个意思但又不打算发消息,用别的词代替。")
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func (cl *CallLogger) log(r CallRecord) {
|
|||||||
if cl.size < cl.capacity {
|
if cl.size < cl.capacity {
|
||||||
cl.size++
|
cl.size++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastCall(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cl *CallLogger) get(limit int) []CallRecord {
|
func (cl *CallLogger) get(limit int) []CallRecord {
|
||||||
@@ -72,3 +74,49 @@ func (cl *CallLogger) get(limit int) []CallRecord {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
观察到的消息: %s
|
||||||
|
|
||||||
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
|
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
|
||||||
提取角度:这条消息中包含了什么关于聊天参与者、讨论主题、事件或氛围的信息?
|
消息格式为:[群聊 群号] 发送者昵称 (QQ号):消息内容
|
||||||
|
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
|
||||||
|
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
|
||||||
|
|
||||||
每条记忆需要包含以下字段:
|
每条记忆需要包含以下字段:
|
||||||
- content: 完整的记忆内容(一句话描述,客观准确)
|
- content: 完整的记忆内容(一句话描述,客观准确)
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ type ProcessParams struct {
|
|||||||
Images []string // 图片 base64 data URL (多模态)
|
Images []string // 图片 base64 data URL (多模态)
|
||||||
Mode string // text / voice_msg / voice_assistant
|
Mode string // text / voice_msg / voice_assistant
|
||||||
Nickname string
|
Nickname string
|
||||||
|
ChannelType string // direct / group
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessResult 处理结果
|
// ProcessResult 处理结果
|
||||||
@@ -334,6 +335,7 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
PersonaPrompt: systemPrompt,
|
PersonaPrompt: systemPrompt,
|
||||||
DialogHistory: history,
|
DialogHistory: history,
|
||||||
Mode: params.Mode,
|
Mode: params.Mode,
|
||||||
|
ChannelType: params.ChannelType,
|
||||||
}
|
}
|
||||||
if prevEnrichment != nil {
|
if prevEnrichment != nil {
|
||||||
synthParams.MemorySummary = prevEnrichment.MemorySummary
|
synthParams.MemorySummary = prevEnrichment.MemorySummary
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type SynthesizeParams struct {
|
|||||||
KnowledgeInfo string // 知识库检索摘要
|
KnowledgeInfo string // 知识库检索摘要
|
||||||
PendingToolResults []PendingToolResult // 上一轮异步完成的工具结果
|
PendingToolResults []PendingToolResult // 上一轮异步完成的工具结果
|
||||||
Mode string // text / voice_assistant
|
Mode string // text / voice_assistant
|
||||||
|
ChannelType string // direct / group
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthesize 综合所有子会话结果,流式生成最终回复。
|
// Synthesize 综合所有子会话结果,流式生成最终回复。
|
||||||
@@ -210,7 +211,15 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
|
|||||||
Content: systemPrompt,
|
Content: systemPrompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 注入记忆摘要
|
// 群聊上下文:当消息来自群聊时,告知模型这是一条群聊消息而非一对一私聊。
|
||||||
|
if params.ChannelType == "group" {
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: model.RoleSystem,
|
||||||
|
Content: "【群聊上下文】这条消息来自QQ群聊。消息前缀 [群聊 群号] 昵称 (QQ号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请用发送者的真实名字称呼,不要叫所有人开拓者或叶酱。只在对你说话或延续已有对话时才回复。",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入记忆摘要// 注入记忆摘要
|
||||||
if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") {
|
if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") {
|
||||||
messages = append(messages, model.LLMMessage{
|
messages = append(messages, model.LLMMessage{
|
||||||
Role: model.RoleSystem,
|
Role: model.RoleSystem,
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ func main() {
|
|||||||
Platform: msg.Platform,
|
Platform: msg.Platform,
|
||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.OriginalSenderUID,
|
SenderID: msg.OriginalSenderUID,
|
||||||
SenderName: msg.SenderName,
|
SenderName: msg.OriginalSenderName,
|
||||||
|
GroupName: msg.GroupName,
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
ContentType: msg.ContentType,
|
ContentType: msg.ContentType,
|
||||||
MessageID: msg.MessageID,
|
MessageID: msg.MessageID,
|
||||||
@@ -88,6 +89,29 @@ func main() {
|
|||||||
isBotMentioned := msg.BotUID != "" && containsString(msg.Mentions, msg.BotUID)
|
isBotMentioned := msg.BotUID != "" && containsString(msg.Mentions, msg.BotUID)
|
||||||
isSilent := cfg.PlatformSilentEnabled && !isAdmin && !isBotMentioned
|
isSilent := cfg.PlatformSilentEnabled && !isAdmin && !isBotMentioned
|
||||||
|
|
||||||
|
// Add message timestamp for AI context.
|
||||||
|
if !msg.Timestamp.IsZero() {
|
||||||
|
timeAgo := time.Since(msg.Timestamp)
|
||||||
|
timeLabel := fmt.Sprintf("【消息时间: %s (%s前)】\n", msg.Timestamp.Format("15:04:05"), formatDuration(timeAgo))
|
||||||
|
msg.Content = timeLabel + msg.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich group messages with group name and sender info.
|
||||||
|
if msg.ChannelType == "group" {
|
||||||
|
groupLabel := msg.ChannelID
|
||||||
|
if msg.GroupName != "" {
|
||||||
|
groupLabel = truncateString(msg.GroupName, 8) + " " + msg.ChannelID
|
||||||
|
}
|
||||||
|
senderLabel := msg.OriginalSenderName
|
||||||
|
if senderLabel == "" {
|
||||||
|
senderLabel = msg.SenderName
|
||||||
|
}
|
||||||
|
if isAdmin {
|
||||||
|
senderLabel = "【管理员】" + msg.OriginalSenderName
|
||||||
|
}
|
||||||
|
msg.Content = fmt.Sprintf("[群聊 %s] %s (%s):\n%s", groupLabel, senderLabel, msg.OriginalSenderUID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
// Blocklist/whitelist check (admin always bypasses).
|
// Blocklist/whitelist check (admin always bypasses).
|
||||||
if blocked := blocklistStore.IsBlocked(msg.ChannelType, msg.ChannelID, msg.OriginalSenderUID, isAdmin); blocked {
|
if blocked := blocklistStore.IsBlocked(msg.ChannelType, msg.ChannelID, msg.OriginalSenderUID, isAdmin); blocked {
|
||||||
msgLogger.Log(logging.LogEntry{
|
msgLogger.Log(logging.LogEntry{
|
||||||
@@ -122,6 +146,11 @@ func main() {
|
|||||||
groupSessionID := fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID)
|
groupSessionID := fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case isMessageHistorical(msg, router):
|
||||||
|
msg.RouteType = "silent"
|
||||||
|
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||||
|
response, routeErr = forwardToAICore(cfg, msg, "platform_silent", namespace, namespace, nil)
|
||||||
|
|
||||||
case isAdmin && !isBotMentioned && shouldAdminBeSilent(msg, router):
|
case isAdmin && !isBotMentioned && shouldAdminBeSilent(msg, router):
|
||||||
msg.RouteType = "silent"
|
msg.RouteType = "silent"
|
||||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||||
@@ -189,6 +218,7 @@ func main() {
|
|||||||
ChannelID: msg.ChannelID,
|
ChannelID: msg.ChannelID,
|
||||||
SenderID: msg.BotUID,
|
SenderID: msg.BotUID,
|
||||||
SenderName: "Cyrene",
|
SenderName: "Cyrene",
|
||||||
|
GroupName: msg.GroupName,
|
||||||
Content: rm.Content,
|
Content: rm.Content,
|
||||||
ContentType: "text",
|
ContentType: "text",
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -678,21 +708,31 @@ func parseSSEAndAccumulate(body string) string {
|
|||||||
return strings.Join(deltas, "")
|
return strings.Join(deltas, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitContent splits text by \n\n into multiple ResponseMessage segments.
|
// splitContent splits text by ♪ (sentence-break marker), then by \n\n within each segment.
|
||||||
// Non-empty segments are each wrapped as a chat message; empty input returns a single empty message.
|
// Non-empty segments are each wrapped as a chat message; empty input returns a single empty message.
|
||||||
func splitContent(text string) []bridge.ResponseMessage {
|
func splitContent(text string) []bridge.ResponseMessage {
|
||||||
parts := strings.Split(text, "\n\n")
|
// First split by ♪ sentence-break marker.
|
||||||
|
var rawParts []string
|
||||||
|
if strings.Contains(text, "♪") {
|
||||||
|
rawParts = strings.Split(text, "♪")
|
||||||
|
} else {
|
||||||
|
rawParts = strings.Split(text, "\n\n")
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, p := range rawParts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
var msgs []bridge.ResponseMessage
|
var msgs []bridge.ResponseMessage
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part != "" {
|
|
||||||
msgs = append(msgs, bridge.ResponseMessage{
|
msgs = append(msgs, bridge.ResponseMessage{
|
||||||
DisplayType: "chat",
|
DisplayType: "chat",
|
||||||
Content: part,
|
Content: part,
|
||||||
FormatMode: "plain",
|
FormatMode: "plain",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(msgs) == 0 {
|
if len(msgs) == 0 {
|
||||||
return []bridge.ResponseMessage{
|
return []bridge.ResponseMessage{
|
||||||
{DisplayType: "chat", Content: text, FormatMode: "plain"},
|
{DisplayType: "chat", Content: text, FormatMode: "plain"},
|
||||||
@@ -843,3 +883,49 @@ func syncAdminUIDs(m *bridge.IdentityMapper, platform string, fields map[string]
|
|||||||
}
|
}
|
||||||
fmt.Printf("Synced admin identities for %s from config: %s\n", platform, raw)
|
fmt.Printf("Synced admin identities for %s from config: %s\n", platform, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatDuration returns a human-readable duration string like "1h2m3s".
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
m := int(d.Minutes())
|
||||||
|
s := int(d.Seconds()) % 60
|
||||||
|
if s > 0 {
|
||||||
|
return fmt.Sprintf("%dm%ds", m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if m > 0 {
|
||||||
|
return fmt.Sprintf("%dh%dm", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateString truncates a string to maxRunes runes, appending "…" if truncated.
|
||||||
|
func truncateString(s string, maxRunes int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMessageHistorical returns true if the message timestamp is before the adapter's connection time,
|
||||||
|
// indicating it is a replayed historical message that should be silently observed.
|
||||||
|
func isMessageHistorical(msg *bridge.UnifiedMessage, router *bridge.PlatformRouter) bool {
|
||||||
|
if msg.Timestamp.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range router.GetAdaptersByPlatform(msg.Platform) {
|
||||||
|
if connectedAt, ok := a.(interface{ ConnectedAt() time.Time }); ok {
|
||||||
|
if msg.Timestamp.Before(connectedAt.ConnectedAt()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ type Adapter struct {
|
|||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
|
connectedAt time.Time // when the connection was established (for historical message detection)
|
||||||
srv *http.Server // HTTP server for WS upgrades (server mode only)
|
srv *http.Server // HTTP server for WS upgrades (server mode only)
|
||||||
|
|
||||||
|
groupNames map[int64]string // group ID → group name cache
|
||||||
|
groupNamesMu sync.RWMutex
|
||||||
|
|
||||||
pendingResponses map[string]chan *OBv11APIResponse
|
pendingResponses map[string]chan *OBv11APIResponse
|
||||||
respMu sync.Mutex
|
respMu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ func NewAdapter(configName, mode, port, accessToken, remoteURL string, sendInter
|
|||||||
remoteURL: remoteURL,
|
remoteURL: remoteURL,
|
||||||
sendIntervalMs: sendIntervalMs,
|
sendIntervalMs: sendIntervalMs,
|
||||||
pendingResponses: make(map[string]chan *OBv11APIResponse),
|
pendingResponses: make(map[string]chan *OBv11APIResponse),
|
||||||
|
groupNames: make(map[int64]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +65,76 @@ func (a *Adapter) ConfigName() string { return a.configName }
|
|||||||
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
|
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
|
||||||
func (a *Adapter) SelfID() string { return a.selfID }
|
func (a *Adapter) SelfID() string { return a.selfID }
|
||||||
|
|
||||||
|
// ConnectedAt returns the time the connection was established, for historical message detection.
|
||||||
|
func (a *Adapter) ConnectedAt() time.Time {
|
||||||
|
a.connMu.Lock()
|
||||||
|
defer a.connMu.Unlock()
|
||||||
|
return a.connectedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupName resolves a group ID to its name. Returns empty string if unknown.
|
||||||
|
func (a *Adapter) GroupName(groupID int64) string {
|
||||||
|
a.groupNamesMu.RLock()
|
||||||
|
n, ok := a.groupNames[groupID]
|
||||||
|
a.groupNamesMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// Try to resolve via API.
|
||||||
|
a.fetchGroupName(groupID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupName caches a group name for a group ID.
|
||||||
|
func (a *Adapter) SetGroupName(groupID int64, name string) {
|
||||||
|
a.groupNamesMu.Lock()
|
||||||
|
a.groupNames[groupID] = name
|
||||||
|
a.groupNamesMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Derive HTTP base from WS URL: ws://host:port → http://host:port
|
||||||
|
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
|
||||||
|
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
|
||||||
|
// Strip path suffix if present
|
||||||
|
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
|
||||||
|
httpBase = httpBase[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
|
||||||
|
if a.accessToken != "" {
|
||||||
|
url += "&access_token=" + a.accessToken
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var result struct {
|
||||||
|
Data struct {
|
||||||
|
GroupName string `json:"group_name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Data.GroupName != "" {
|
||||||
|
a.SetGroupName(groupID, result.Data.GroupName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
|
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
|
||||||
return bridge.PlatformCapabilities{
|
return bridge.PlatformCapabilities{
|
||||||
MaxMessageLength: 200,
|
MaxMessageLength: 200,
|
||||||
@@ -109,6 +184,7 @@ func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
a.conn = conn
|
a.conn = conn
|
||||||
a.connected = true
|
a.connected = true
|
||||||
|
a.connectedAt = time.Now()
|
||||||
a.connMu.Unlock()
|
a.connMu.Unlock()
|
||||||
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
|
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
|
||||||
}
|
}
|
||||||
@@ -148,6 +224,7 @@ func (a *Adapter) connectClient(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
a.conn = conn
|
a.conn = conn
|
||||||
a.connected = true
|
a.connected = true
|
||||||
|
a.connectedAt = time.Now()
|
||||||
a.connMu.Unlock()
|
a.connMu.Unlock()
|
||||||
|
|
||||||
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
|
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
|
||||||
@@ -257,6 +334,26 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: parse CQ at codes from string format (e.g. [CQ:at,qq=2254389756]).
|
||||||
|
if len(mentions) == 0 {
|
||||||
|
raw := msg.RawMessage
|
||||||
|
if raw == "" {
|
||||||
|
if s, ok := msg.Message.(string); ok {
|
||||||
|
raw = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range cqAtRegex.FindAllStringSubmatch(raw, -1) {
|
||||||
|
if len(m) >= 2 {
|
||||||
|
mentions = append(mentions, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve group name for group messages.
|
||||||
|
groupName := ""
|
||||||
|
if msg.MessageType == "group" {
|
||||||
|
groupName = a.GroupName(msg.GroupID)
|
||||||
|
}
|
||||||
|
|
||||||
attachments := extractAttachments(msg)
|
attachments := extractAttachments(msg)
|
||||||
|
|
||||||
@@ -273,6 +370,7 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
|||||||
Attachments: attachments,
|
Attachments: attachments,
|
||||||
RawData: rawMessage,
|
RawData: rawMessage,
|
||||||
Timestamp: time.Unix(msg.Time, 0),
|
Timestamp: time.Unix(msg.Time, 0),
|
||||||
|
GroupName: groupName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +514,7 @@ func extractText(msg *OBv11Message) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cqAtRegex = regexp.MustCompile(`\[CQ:at,qq=(\d+)\]`)
|
||||||
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
|
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
|
||||||
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
|
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
|
||||||
var boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
var boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
|
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
const participantTTL = 5 * time.Minute
|
||||||
|
|
||||||
// adapterKey returns the unique key for an adapter in the router map.
|
// adapterKey returns the unique key for an adapter in the router map.
|
||||||
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
|
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
|
||||||
func adapterKey(a PlatformAdapter) string {
|
func adapterKey(a PlatformAdapter) string {
|
||||||
@@ -37,6 +41,7 @@ type ChannelContext struct {
|
|||||||
LastUserMsg string
|
LastUserMsg string
|
||||||
LastSenderUID string
|
LastSenderUID string
|
||||||
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
|
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
|
||||||
|
ActiveParticipants map[string]time.Time // UID -> last bot reply time (for multi-user conversation continuity)
|
||||||
MessageCount int
|
MessageCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +142,7 @@ func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*U
|
|||||||
|
|
||||||
// Preserve original platform UID before identity mapping.
|
// Preserve original platform UID before identity mapping.
|
||||||
unified.OriginalSenderUID = unified.SenderID
|
unified.OriginalSenderUID = unified.SenderID
|
||||||
|
unified.OriginalSenderName = unified.SenderName
|
||||||
unified.OriginalRawMessage = rawMsg
|
unified.OriginalRawMessage = rawMsg
|
||||||
|
|
||||||
// Capture bot's own UID for @mention detection.
|
// Capture bot's own UID for @mention detection.
|
||||||
@@ -228,3 +234,37 @@ func (r *PlatformRouter) GetContext(platform, channelID string) *ChannelContext
|
|||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
return r.contexts[platform+":"+channelID]
|
return r.contexts[platform+":"+channelID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NoteBotReply records that the bot just replied to a specific user in a channel.
|
||||||
|
// Used for conversation continuity: subsequent messages from this user continue the
|
||||||
|
// conversation even without an explicit @mention, within the participant TTL window.
|
||||||
|
func (r *PlatformRouter) NoteBotReply(platform, channelID, recipientUID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
key := r.channelKey(platform, channelID)
|
||||||
|
ctx, ok := r.contexts[key]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.ActiveParticipants == nil {
|
||||||
|
ctx.ActiveParticipants = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
ctx.ActiveParticipants[recipientUID] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActiveParticipant checks if a user was recently engaged by the bot.
|
||||||
|
// TTL controls how long the continuity window stays open after the last bot reply.
|
||||||
|
func (r *PlatformRouter) IsActiveParticipant(platform, channelID, uid string) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
key := r.channelKey(platform, channelID)
|
||||||
|
ctx, ok := r.contexts[key]
|
||||||
|
if !ok || ctx.ActiveParticipants == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t, ok := ctx.ActiveParticipants[uid]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(t) < participantTTL
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type UnifiedMessage struct {
|
|||||||
// Routing metadata.
|
// Routing metadata.
|
||||||
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
|
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
|
||||||
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
|
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
|
||||||
|
OriginalSenderName string `json:"original_sender_name,omitempty"` // preserved before identity mapping
|
||||||
|
GroupName string `json:"group_name,omitempty"` // resolved group name for group chats
|
||||||
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
|
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
|
||||||
BotUID string `json:"-"` // bot's own platform UID, set by router
|
BotUID string `json:"-"` // bot's own platform UID, set by router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type LogEntry struct {
|
|||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
SenderName string `json:"sender_name"`
|
SenderName string `json:"sender_name"`
|
||||||
|
GroupName string `json:"group_name,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
MessageID string `json:"message_id,omitempty"`
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
|||||||
+186
-88
@@ -1200,8 +1200,8 @@ function switchPanel(name) {
|
|||||||
history.replaceState(null, '', '#' + name);
|
history.replaceState(null, '', '#' + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止链路追踪定时器
|
// 停止链路追踪 SSE
|
||||||
stopTraceAutoRefresh();
|
disconnectTraceStream();
|
||||||
|
|
||||||
// 更新侧边栏
|
// 更新侧边栏
|
||||||
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
||||||
@@ -1232,24 +1232,24 @@ function switchPanel(name) {
|
|||||||
|
|
||||||
// 渲染面板
|
// 渲染面板
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'vmMonitor': renderVMMonitorPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'vmMonitor': renderVMMonitorPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'stt': renderSTTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'plugins': renderPluginsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'plugins': renderPluginsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
||||||
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
||||||
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'thinkingSchedule': renderThinkingSchedulePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'thinkingSchedule': renderThinkingSchedulePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); disconnectTraceStream(); break;
|
||||||
case 'trace': renderTracePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'trace': renderTracePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6135,22 +6135,23 @@ async function renderVMMonitorPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== 全链路追踪面板 ==========
|
// ========== 全链路追踪面板 ==========
|
||||||
var traceAutoRefreshId = null;
|
|
||||||
var traceMode = 'recent'; // 'recent' | 'session'
|
var traceMode = 'recent'; // 'recent' | 'session'
|
||||||
var traceSessionId = '';
|
var traceSessionId = '';
|
||||||
|
var traceStreamSource = null;
|
||||||
|
var traceSeenIds = new Set();
|
||||||
|
var traceMaxDomEntries = 200;
|
||||||
|
|
||||||
function renderTracePanel() {
|
function renderTracePanel() {
|
||||||
document.getElementById('panel-actions').innerHTML =
|
document.getElementById('panel-actions').innerHTML =
|
||||||
'<button class="btn btn-sm" onclick="refreshTrace()" id="trace-refresh-btn">🔄 刷新</button>' +
|
'<button class="btn btn-sm" onclick="refreshTrace()" id="trace-refresh-btn">🔄 刷新</button>' +
|
||||||
'<label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer">' +
|
'<span id="trace-live-status" style="font-size:11px;color:var(--green);display:flex;align-items:center;gap:4px">' +
|
||||||
'<input type="checkbox" id="trace-auto-refresh" onchange="toggleTraceAutoRefresh()"> 自动刷新 (5s)' +
|
'<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite"></span> 实时</span>';
|
||||||
'</label>';
|
|
||||||
|
|
||||||
document.getElementById('panel-trace').innerHTML =
|
document.getElementById('panel-trace').innerHTML =
|
||||||
'<div class="card">' +
|
'<div class="card">' +
|
||||||
'<div class="card-header">' +
|
'<div class="card-header">' +
|
||||||
'<span class="card-title">🔗 全链路消息追踪</span>' +
|
'<span class="card-title">🔗 全链路消息追踪</span>' +
|
||||||
'<span style="font-size:11px;color:var(--text2)">追踪 Client → Gateway → AI-Core → LLM 全链路</span>' +
|
'<span style="font-size:11px;color:var(--text2)">追踪 Client → Gateway → AI-Core → LLM 全链路 (实时推送)</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="trace-control-bar">' +
|
'<div class="trace-control-bar">' +
|
||||||
'<button class="btn btn-sm ' + (traceMode === 'recent' ? 'btn-accent' : '') + '" onclick="switchTraceMode(\'recent\')">📋 最近活动</button>' +
|
'<button class="btn btn-sm ' + (traceMode === 'recent' ? 'btn-accent' : '') + '" onclick="switchTraceMode(\'recent\')">📋 最近活动</button>' +
|
||||||
@@ -6166,35 +6167,69 @@ function renderTracePanel() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
|
connectTraceStream();
|
||||||
refreshTrace();
|
refreshTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTraceMode(mode) {
|
function switchTraceMode(mode) {
|
||||||
traceMode = mode;
|
traceMode = mode;
|
||||||
stopTraceAutoRefresh();
|
traceSeenIds.clear();
|
||||||
document.getElementById('trace-auto-refresh').checked = false;
|
|
||||||
renderTracePanel();
|
renderTracePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTraceAutoRefresh() {
|
// --- SSE 实时推送 ---
|
||||||
var checked = document.getElementById('trace-auto-refresh').checked;
|
|
||||||
if (checked) {
|
function connectTraceStream() {
|
||||||
startTraceAutoRefresh();
|
disconnectTraceStream();
|
||||||
|
var url = getAICoreBaseUrl() + '/api/v1/llm-calls/stream';
|
||||||
|
traceStreamSource = new EventSource(url);
|
||||||
|
traceStreamSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
var call = JSON.parse(e.data);
|
||||||
|
var trace = llmCallToTrace(call);
|
||||||
|
if (trace && !traceSeenIds.has(trace.id)) {
|
||||||
|
traceSeenIds.add(trace.id);
|
||||||
|
addTraceToTimeline(trace, true);
|
||||||
|
}
|
||||||
|
} catch(ignore) {}
|
||||||
|
};
|
||||||
|
traceStreamSource.onerror = function() {
|
||||||
|
updateTraceLiveStatus(false);
|
||||||
|
traceStreamSource.close();
|
||||||
|
setTimeout(function() {
|
||||||
|
if (STATE.activePanel === 'trace') connectTraceStream();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
traceStreamSource.onopen = function() {
|
||||||
|
updateTraceLiveStatus(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectTraceStream() {
|
||||||
|
if (traceStreamSource) {
|
||||||
|
traceStreamSource.close();
|
||||||
|
traceStreamSource = null;
|
||||||
|
}
|
||||||
|
updateTraceLiveStatus(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTraceLiveStatus(connected) {
|
||||||
|
var el = document.getElementById('trace-live-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (connected) {
|
||||||
|
el.innerHTML = '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite"></span> 实时';
|
||||||
|
el.style.color = 'var(--green)';
|
||||||
} else {
|
} else {
|
||||||
stopTraceAutoRefresh();
|
el.innerHTML = '⚫ 离线';
|
||||||
|
el.style.color = 'var(--text3)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTraceAutoRefresh() {
|
function getAICoreBaseUrl() {
|
||||||
stopTraceAutoRefresh();
|
return 'http://localhost:8081';
|
||||||
traceAutoRefreshId = setInterval(function() {
|
|
||||||
if (STATE.activePanel === 'trace') refreshTrace();
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTraceAutoRefresh() {
|
// --- 初始加载 & 手动刷新 ---
|
||||||
if (traceAutoRefreshId) { clearInterval(traceAutoRefreshId); traceAutoRefreshId = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTrace() {
|
async function refreshTrace() {
|
||||||
var contentEl = document.getElementById('trace-content');
|
var contentEl = document.getElementById('trace-content');
|
||||||
@@ -6202,8 +6237,6 @@ async function refreshTrace() {
|
|||||||
var btn = document.getElementById('trace-refresh-btn');
|
var btn = document.getElementById('trace-refresh-btn');
|
||||||
if (btn) btn.classList.add('spinning');
|
if (btn) btn.classList.add('spinning');
|
||||||
|
|
||||||
// 热更新:不清空现有内容,仅在按钮上显示加载状态
|
|
||||||
|
|
||||||
var url, data;
|
var url, data;
|
||||||
if (traceMode === 'session') {
|
if (traceMode === 'session') {
|
||||||
var sid = document.getElementById('trace-session-id').value.trim();
|
var sid = document.getElementById('trace-session-id').value.trim();
|
||||||
@@ -6242,73 +6275,108 @@ async function refreshTrace() {
|
|||||||
var traces = data.traces || [];
|
var traces = data.traces || [];
|
||||||
var stats = data.stats || {};
|
var stats = data.stats || {};
|
||||||
|
|
||||||
// 统计栏 — 热更新
|
// 筛选出新的追踪节点
|
||||||
var servicesCount = (stats.services || []).length;
|
var newTraces = [];
|
||||||
var statsHtml =
|
for (var i = 0; i < traces.length; i++) {
|
||||||
'<div class="trace-summary-item">📊 <span class="tsi-val">' + traces.length + '</span> 个追踪节点</div>' +
|
if (!traceSeenIds.has(traces[i].id)) {
|
||||||
'<div class="trace-summary-item">🖥 <span class="tsi-val">' + servicesCount + '</span> 个服务</div>' +
|
traceSeenIds.add(traces[i].id);
|
||||||
(stats.errors > 0 ? '<div class="trace-summary-item" style="color:var(--red)">❌ <span class="tsi-val">' + stats.errors + '</span> 个错误</div>' : '<div class="trace-summary-item" style="color:var(--green)">✅ 全部成功</div>') +
|
newTraces.push(traces[i]);
|
||||||
(stats.totalSpanMs > 0 ? '<div class="trace-summary-item">⏱ 总跨度 <span class="tsi-val">' + (stats.totalSpanMs / 1000).toFixed(2) + 's</span></div>' : '') +
|
}
|
||||||
(stats.totalDurationMs > 0 ? '<div class="trace-summary-item">⏳ 总耗时 <span class="tsi-val">' + (stats.totalDurationMs / 1000).toFixed(2) + 's</span></div>' : '');
|
|
||||||
|
|
||||||
// Session info
|
|
||||||
if (data.session) {
|
|
||||||
var s = data.session;
|
|
||||||
statsHtml +=
|
|
||||||
'<div class="trace-summary-item">💬 Session: <code style="font-size:10px">' + escHtml((s.session_id || '').substring(0, 20)) + '...</code></div>' +
|
|
||||||
'<div class="trace-summary-item">👤 User: ' + escHtml(s.user_id || '—') + '</div>';
|
|
||||||
}
|
}
|
||||||
statsEl.innerHTML = statsHtml;
|
|
||||||
|
|
||||||
// 更新侧边栏徽章
|
// 如果是首次加载(content 只有 loading 提示),全量渲染
|
||||||
|
var timeline = contentEl.querySelector('.trace-timeline');
|
||||||
|
if (!timeline) {
|
||||||
|
if (traces.length === 0) {
|
||||||
|
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">📭</div>暂无链路追踪数据<br><span style="font-size:11px;color:var(--text3)">请确保服务正在运行且有消息活动</span></div>';
|
||||||
|
statsEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 全量渲染
|
||||||
|
var html = '<div class="trace-timeline">';
|
||||||
|
for (var i = 0; i < traces.length; i++) {
|
||||||
|
html += traceHopHtml(traces[i], i > 0 ? traces[i-1] : null);
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
contentEl.innerHTML = html;
|
||||||
|
} else if (newTraces.length > 0) {
|
||||||
|
// 增量添加
|
||||||
|
for (var j = newTraces.length - 1; j >= 0; j--) {
|
||||||
|
addTraceToTimeline(newTraces[j], false);
|
||||||
|
}
|
||||||
|
pruneTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新统计
|
||||||
|
updateTraceStatsEl(statsEl, traces, stats, data.session);
|
||||||
|
|
||||||
|
// 更新徽章
|
||||||
var badge = document.getElementById('trace-badge');
|
var badge = document.getElementById('trace-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.textContent = traces.length;
|
badge.textContent = traces.length;
|
||||||
badge.style.display = traces.length > 0 ? 'inline-block' : 'none';
|
badge.style.display = traces.length > 0 ? 'inline-block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (traces.length === 0) {
|
|
||||||
contentEl.innerHTML = '<div class="trace-empty"><div class="icon">📭</div>暂无链路追踪数据<br><span style="font-size:11px;color:var(--text3)">请确保服务正在运行且有消息活动</span></div>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前展开状态,重建 DOM 后恢复
|
// --- 增量 DOM 操作 ---
|
||||||
var expandedHops = [];
|
|
||||||
var existingHops = contentEl.querySelectorAll('.trace-hop-detail.open');
|
function addTraceToTimeline(trace, isLive) {
|
||||||
for (var ei = 0; ei < existingHops.length; ei++) {
|
var contentEl = document.getElementById('trace-content');
|
||||||
// 用 hop 的 label+timestamp 做 key 来恢复
|
var timeline = contentEl.querySelector('.trace-timeline');
|
||||||
var prevHop = existingHops[ei].previousElementSibling;
|
if (!timeline) return;
|
||||||
if (prevHop) {
|
|
||||||
var hopLabel = prevHop.querySelector('.hop-label');
|
var gapIndicator = timeline.querySelector('div[data-gap]');
|
||||||
var hopTime = prevHop.querySelector('.hop-time');
|
var insertPoint = gapIndicator || timeline.firstChild;
|
||||||
if (hopLabel && hopTime) {
|
|
||||||
expandedHops.push(hopTime.textContent + '|' + hopLabel.textContent);
|
var html = traceHopHtml(trace, null);
|
||||||
|
var temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
|
||||||
|
while (temp.firstChild) {
|
||||||
|
timeline.insertBefore(temp.firstChild, insertPoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
var newHop = timeline.querySelector('.trace-hop');
|
||||||
|
if (newHop) {
|
||||||
|
newHop.style.transition = 'background 0.3s';
|
||||||
|
newHop.style.background = 'var(--accent-bg)';
|
||||||
|
setTimeout(function() { newHop.style.background = ''; }, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染时间线
|
pruneTimeline();
|
||||||
var html = '<div class="trace-timeline">';
|
}
|
||||||
|
|
||||||
for (var i = 0; i < traces.length; i++) {
|
function pruneTimeline() {
|
||||||
var t = traces[i];
|
var timeline = document.querySelector('.trace-timeline');
|
||||||
|
if (!timeline) return;
|
||||||
|
var hops = timeline.querySelectorAll('.trace-hop');
|
||||||
|
while (hops.length > traceMaxDomEntries) {
|
||||||
|
var last = hops[hops.length - 1];
|
||||||
|
var detail = last.nextElementSibling;
|
||||||
|
if (detail && detail.classList.contains('trace-hop-detail')) {
|
||||||
|
detail.remove();
|
||||||
|
}
|
||||||
|
last.remove();
|
||||||
|
hops = timeline.querySelectorAll('.trace-hop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceHopHtml(t, prevT) {
|
||||||
var time = new Date(t.timestamp).toISOString().replace('T', ' ').slice(11, 19);
|
var time = new Date(t.timestamp).toISOString().replace('T', ' ').slice(11, 19);
|
||||||
var isError = t.status === 'error';
|
var isError = t.status === 'error';
|
||||||
var hopKey = time + '|' + t.label;
|
|
||||||
var wasExpanded = expandedHops.indexOf(hopKey) >= 0;
|
|
||||||
var gapHtml = '';
|
var gapHtml = '';
|
||||||
|
|
||||||
// 显示与上一跳的时间间隔
|
if (prevT) {
|
||||||
if (i > 0) {
|
var gap = t.ts - prevT.ts;
|
||||||
var gap = t.ts - traces[i - 1].ts;
|
|
||||||
if (gap > 50) {
|
if (gap > 50) {
|
||||||
gapHtml = '<div style="text-align:center;padding:2px 0;font-size:10px;color:var(--text3);margin-left:48px">↓ ' + (gap > 1000 ? (gap / 1000).toFixed(2) + 's' : gap + 'ms') + '</div>';
|
gapHtml = '<div data-gap style="text-align:center;padding:2px 0;font-size:10px;color:var(--text3);margin-left:48px">↓ ' + (gap > 1000 ? (gap / 1000).toFixed(2) + 's' : gap + 'ms') + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html += gapHtml;
|
return gapHtml +
|
||||||
html +=
|
'<div class="trace-hop ' + (isError ? 'error' : 'success') + '" data-id="' + escHtml(t.id) + '" onclick="toggleTraceHop(this)" title="点击展开详情">' +
|
||||||
'<div class="trace-hop ' + (isError ? 'error' : 'success') + '" onclick="toggleTraceHop(this)" title="点击展开详情">' +
|
|
||||||
'<div class="hop-dot ' + (isError ? 'error' : escHtml(t.service)) + '"></div>' +
|
'<div class="hop-dot ' + (isError ? 'error' : escHtml(t.service)) + '"></div>' +
|
||||||
'<span class="hop-time">' + escHtml(time) + '</span>' +
|
'<span class="hop-time">' + escHtml(time) + '</span>' +
|
||||||
'<span class="hop-service ' + escHtml(t.service) + '">' + escHtml(t.service) + '</span>' +
|
'<span class="hop-service ' + escHtml(t.service) + '">' + escHtml(t.service) + '</span>' +
|
||||||
@@ -6316,7 +6384,7 @@ async function refreshTrace() {
|
|||||||
(t.durationMs > 0 ? '<span class="hop-duration">' + (t.durationMs >= 1000 ? (t.durationMs / 1000).toFixed(2) + 's' : t.durationMs + 'ms') + '</span>' : '') +
|
(t.durationMs > 0 ? '<span class="hop-duration">' + (t.durationMs >= 1000 ? (t.durationMs / 1000).toFixed(2) + 's' : t.durationMs + 'ms') + '</span>' : '') +
|
||||||
'<span class="hop-status">' + (isError ? '❌' : '✅') + '</span>' +
|
'<span class="hop-status">' + (isError ? '❌' : '✅') + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="trace-hop-detail' + (wasExpanded ? ' open' : '') + '">' +
|
'<div class="trace-hop-detail">' +
|
||||||
'<div><strong>时间:</strong> ' + escHtml(t.timestamp) + '</div>' +
|
'<div><strong>时间:</strong> ' + escHtml(t.timestamp) + '</div>' +
|
||||||
'<div><strong>服务:</strong> ' + escHtml(t.service) + '</div>' +
|
'<div><strong>服务:</strong> ' + escHtml(t.service) + '</div>' +
|
||||||
'<div><strong>节点:</strong> ' + escHtml(t.hop) + '</div>' +
|
'<div><strong>节点:</strong> ' + escHtml(t.hop) + '</div>' +
|
||||||
@@ -6327,8 +6395,38 @@ async function refreshTrace() {
|
|||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
function updateTraceStatsEl(statsEl, traces, stats, session) {
|
||||||
contentEl.innerHTML = html;
|
var servicesCount = (stats.services || []).length;
|
||||||
|
var html =
|
||||||
|
'<div class="trace-summary-item">📊 <span class="tsi-val">' + traces.length + '</span> 个追踪节点</div>' +
|
||||||
|
'<div class="trace-summary-item">🖥 <span class="tsi-val">' + servicesCount + '</span> 个服务</div>' +
|
||||||
|
(stats.errors > 0 ? '<div class="trace-summary-item" style="color:var(--red)">❌ <span class="tsi-val">' + stats.errors + '</span> 个错误</div>' : '<div class="trace-summary-item" style="color:var(--green)">✅ 全部成功</div>') +
|
||||||
|
(stats.totalSpanMs > 0 ? '<div class="trace-summary-item">⏱ 总跨度 <span class="tsi-val">' + (stats.totalSpanMs / 1000).toFixed(2) + 's</span></div>' : '') +
|
||||||
|
(stats.totalDurationMs > 0 ? '<div class="trace-summary-item">⏳ 总耗时 <span class="tsi-val">' + (stats.totalDurationMs / 1000).toFixed(2) + 's</span></div>' : '');
|
||||||
|
if (session) {
|
||||||
|
var s = session;
|
||||||
|
html +=
|
||||||
|
'<div class="trace-summary-item">💬 Session: <code style="font-size:10px">' + escHtml((s.session_id || '').substring(0, 20)) + '...</code></div>' +
|
||||||
|
'<div class="trace-summary-item">👤 User: ' + escHtml(s.user_id || '—') + '</div>';
|
||||||
|
}
|
||||||
|
statsEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 SSE 推送的 LLM CallRecord 为 trace 格式
|
||||||
|
function llmCallToTrace(call) {
|
||||||
|
var ts = call.time ? new Date(call.time).getTime() : Date.now();
|
||||||
|
return {
|
||||||
|
id: 'llm-' + ts + '-' + Math.random().toString(36).slice(2, 6),
|
||||||
|
timestamp: new Date(ts).toISOString(),
|
||||||
|
ts: ts,
|
||||||
|
service: 'ai-core',
|
||||||
|
hop: 'llm_call',
|
||||||
|
label: 'LLM 调用: ' + (call.model || 'unknown'),
|
||||||
|
status: call.success ? 'success' : 'error',
|
||||||
|
durationMs: call.duration_ms || call.Duration || 0,
|
||||||
|
detail: call.error || (call.prompt_tokens || 0) + '+' + (call.completion_tokens || 0) + ' tokens',
|
||||||
|
data: call
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTraceHop(el) {
|
function toggleTraceHop(el) {
|
||||||
|
|||||||
+2
-2
@@ -1286,7 +1286,7 @@ app.get('/api/trace/recent', async (req, res) => {
|
|||||||
const traces = [];
|
const traces = [];
|
||||||
|
|
||||||
// LLM 调用 → 追踪节点
|
// LLM 调用 → 追踪节点
|
||||||
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : [];
|
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []);
|
||||||
for (const call of llmCalls) {
|
for (const call of llmCalls) {
|
||||||
const ts = call.time ? new Date(call.time).getTime() : Date.now();
|
const ts = call.time ? new Date(call.time).getTime() : Date.now();
|
||||||
traces.push({
|
traces.push({
|
||||||
@@ -1432,7 +1432,7 @@ app.get('/api/trace/session/:sessionId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LLM 调用记录中如果有 session 相关信息也加入
|
// LLM 调用记录中如果有 session 相关信息也加入
|
||||||
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : [];
|
const llmCalls = Array.isArray(llmResult.body) ? llmResult.body : (llmResult.body?.calls || []);
|
||||||
for (const call of llmCalls) {
|
for (const call of llmCalls) {
|
||||||
const ts = call.time ? new Date(call.time).getTime() : Date.now();
|
const ts = call.time ? new Date(call.time).getTime() : Date.now();
|
||||||
traces.push({
|
traces.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user