fix: IoT多设备支持 + Review Pipeline审查消息 + 意图分析快速通道优化
- IoT Provider: 重写Execute()支持多设备命令批量执行,修复persona路径 - Intent Analyzer: 新增isStrongIoTCommand快速通道,跳过LLM分析节省2-3s - Orchestrator: parseReviewMessages()内联审查 + 快速通道扩展(chat/greeting跳过子会话) - Gateway: SSE review_messages解析→WebSocket结构化消息转发(action/chat) - Persona: 对话风格注入action格式指令(括号包裹动作描述) - Frontend: sessionStore历史消息msgType映射 - 新增E2E测试脚本 + 调试标准文档 + 第4轮修复报告 E2E验证: IoT设备操控✅ Review消息拆分✅ 快速通道✅ 响应时间~3.4s Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -159,7 +159,7 @@ func main() {
|
|||||||
subManager.Register(subsession.NewMemoryProvider(memRetriever))
|
subManager.Register(subsession.NewMemoryProvider(memRetriever))
|
||||||
}
|
}
|
||||||
if iotClient != nil {
|
if iotClient != nil {
|
||||||
subManager.Register(subsession.NewIoTProvider(iotClient))
|
subManager.Register(subsession.NewIoTProvider(iotClient, personaDir))
|
||||||
}
|
}
|
||||||
subManager.Register(subsession.NewReviewProvider())
|
subManager.Register(subsession.NewReviewProvider())
|
||||||
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
|
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
|
||||||
@@ -429,6 +429,15 @@ func handleChat(
|
|||||||
fmt.Fprintf(w, "data: %s\n\n", segData)
|
fmt.Fprintf(w, "data: %s\n\n", segData)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
|
case model.StreamReview:
|
||||||
|
// 发送审查后的结构化消息(动作消息 + 聊天消息)
|
||||||
|
reviewData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"message_id": messageID,
|
||||||
|
"review_messages": event.ReviewMessages,
|
||||||
|
})
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", reviewData)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
case model.StreamDone:
|
case model.StreamDone:
|
||||||
// 下发结束标记
|
// 下发结束标记
|
||||||
endData, _ := json.Marshal(map[string]interface{}{
|
endData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速通道:强 IoT 关键词直接使用规则匹配,跳过 LLM 调用(节省 2-3s)
|
||||||
|
if a.isStrongIoTCommand(userMessage) {
|
||||||
|
log.Printf("[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析")
|
||||||
|
return a.keywordAnalyze(userMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
// 如果 LLM 不可用,直接使用关键词匹配
|
// 如果 LLM 不可用,直接使用关键词匹配
|
||||||
if !a.enabled || a.llmAdapter == nil {
|
if !a.enabled || a.llmAdapter == nil {
|
||||||
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
|
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
|
||||||
@@ -119,6 +125,33 @@ func (a *IntentAnalyzer) isSimpleGreeting(userMessage string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isStrongIoTCommand 检测是否为明确的 IoT 操控命令,可直接跳过 LLM 意图分析
|
||||||
|
func (a *IntentAnalyzer) isStrongIoTCommand(userMessage string) bool {
|
||||||
|
msgLower := strings.TrimSpace(strings.ToLower(userMessage))
|
||||||
|
|
||||||
|
// 控制类关键词 + 设备类关键词组合出现,即可判断为 IoT 命令
|
||||||
|
controlWords := []string{"打开", "关闭", "调到", "设置", "开关", "调节", "调高", "调低", "开一下", "关一下"}
|
||||||
|
deviceWords := []string{"灯", "空调", "窗帘", "电视", "风扇", "加湿器", "插座", "门锁", "传感器"}
|
||||||
|
|
||||||
|
hasControl := false
|
||||||
|
for _, w := range controlWords {
|
||||||
|
if strings.Contains(msgLower, w) {
|
||||||
|
hasControl = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDevice := false
|
||||||
|
for _, w := range deviceWords {
|
||||||
|
if strings.Contains(msgLower, w) {
|
||||||
|
hasDevice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasControl && hasDevice
|
||||||
|
}
|
||||||
|
|
||||||
// keywordAnalyze 基于关键词的意图分析(降级方案)
|
// keywordAnalyze 基于关键词的意图分析(降级方案)
|
||||||
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
|
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
|
||||||
result := &model.IntentResult{
|
result := &model.IntentResult{
|
||||||
|
|||||||
@@ -133,9 +133,10 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
|
|
||||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||||
var resultCh <-chan model.SubSessionResult
|
var resultCh <-chan model.SubSessionResult
|
||||||
skipSubSessions := intent.Primary == "greeting"
|
skipSubSessions := intent.Primary == "greeting" ||
|
||||||
|
(intent.Primary == "chat" && !intent.NeedsIoT && !intent.NeedsMemory)
|
||||||
if skipSubSessions {
|
if skipSubSessions {
|
||||||
log.Printf("[orchestrator] 快速通道: 简单问候,跳过子会话分派")
|
log.Printf("[orchestrator] 快速通道: 简单消息(primary=%s),跳过子会话分派", intent.Primary)
|
||||||
// 创建一个已关闭的空通道
|
// 创建一个已关闭的空通道
|
||||||
emptyCh := make(chan model.SubSessionResult)
|
emptyCh := make(chan model.SubSessionResult)
|
||||||
close(emptyCh)
|
close(emptyCh)
|
||||||
@@ -212,7 +213,7 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
// 子会话结果还没完成,先带着空上下文开始合成
|
// 子会话结果还没完成,先带着空上下文开始合成
|
||||||
// 大部分情况下子会话结果会在 LLM 调用前完成
|
// 大部分情况下子会话结果会在 LLM 调用前完成
|
||||||
// 等待一小段时间让快速子会话(如 IoT)完成
|
// 等待一小段时间让快速子会话(如 IoT)完成
|
||||||
timeout := time.After(500 * time.Millisecond)
|
timeout := time.After(200 * time.Millisecond)
|
||||||
select {
|
select {
|
||||||
case enriched := <-enrichedCh:
|
case enriched := <-enrichedCh:
|
||||||
synthParams.MemorySummary = enriched.memorySummary
|
synthParams.MemorySummary = enriched.memorySummary
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ func (pc *PersonaConfig) buildConversationStyle() string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
|
sb.WriteString("- 像 LINE 聊天一样,随意、亲切、有温度\n")
|
||||||
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
|
sb.WriteString("- 偶尔可以用语气词开头:\"嗯...\"、\"啊\"、\"诶\"\n")
|
||||||
|
sb.WriteString("- 执行操作时(开关设备、查询状态等),用括号包裹动作描述,后面跟自然对话。例如:\"(帮你把客厅灯关掉啦) 嗯,已经关好了~\"\n")
|
||||||
|
|
||||||
if len(cs.SentenceEnders) > 0 {
|
if len(cs.SentenceEnders) > 0 {
|
||||||
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
|
sb.WriteString(fmt.Sprintf("- 句尾可以带这些语气符:%s\n", strings.Join(cs.SentenceEnders, " ")))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package subsession
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,12 +25,14 @@ type IoTDeviceProvider interface {
|
|||||||
// 职责:处理 IoT 设备查询和控制请求
|
// 职责:处理 IoT 设备查询和控制请求
|
||||||
type IoTProvider struct {
|
type IoTProvider struct {
|
||||||
iotClient IoTDeviceProvider
|
iotClient IoTDeviceProvider
|
||||||
|
personaDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIoTProvider 创建 IoT 控制子会话提供者
|
// NewIoTProvider 创建 IoT 控制子会话提供者
|
||||||
func NewIoTProvider(iotClient IoTDeviceProvider) *IoTProvider {
|
func NewIoTProvider(iotClient IoTDeviceProvider, personaDir string) *IoTProvider {
|
||||||
return &IoTProvider{
|
return &IoTProvider{
|
||||||
iotClient: iotClient,
|
iotClient: iotClient,
|
||||||
|
personaDir: personaDir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,11 @@ func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextPar
|
|||||||
|
|
||||||
// 加载人格配置
|
// 加载人格配置
|
||||||
trueName := "昔涟"
|
trueName := "昔涟"
|
||||||
loader, err := persona.NewLoader("")
|
personaPath := p.personaDir
|
||||||
|
if personaPath == "" {
|
||||||
|
personaPath = "./internal/persona"
|
||||||
|
}
|
||||||
|
loader, err := persona.NewLoader(personaPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
|
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -190,7 +195,6 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
|||||||
Summary: "(未执行 IoT 操作)",
|
Summary: "(未执行 IoT 操作)",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取用户消息
|
|
||||||
userMessage := ""
|
userMessage := ""
|
||||||
for i := len(subCtx) - 1; i >= 0; i-- {
|
for i := len(subCtx) - 1; i >= 0; i-- {
|
||||||
if subCtx[i].Role == model.RoleUser {
|
if subCtx[i].Role == model.RoleUser {
|
||||||
@@ -207,82 +211,48 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的关键词匹配来执行设备操作(不依赖 LLM 解析)
|
|
||||||
// 这是作为降级方案,当 LLM 不可用时仍然可以处理基本 IoT 命令
|
|
||||||
msgLower := strings.ToLower(userMessage)
|
|
||||||
|
|
||||||
// 尝试获取设备列表进行匹配
|
|
||||||
devices := p.iotClient.GetDevicesForContext(ctx)
|
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||||
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
|
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
|
||||||
|
|
||||||
|
msgLower := strings.ToLower(userMessage)
|
||||||
|
userName := extractUserName(subCtx)
|
||||||
|
|
||||||
|
// 收集所有匹配的设备-操作对,支持多设备命令
|
||||||
|
type deviceAction struct {
|
||||||
|
dev tools.IoTDevice
|
||||||
|
operation string // "on" | "off" | "query"
|
||||||
|
}
|
||||||
|
var actions []deviceAction
|
||||||
|
|
||||||
for _, dev := range devices {
|
for _, dev := range devices {
|
||||||
devNameLower := strings.ToLower(dev.Name)
|
devNameLower := strings.ToLower(dev.Name)
|
||||||
|
|
||||||
if !strings.Contains(msgLower, devNameLower) {
|
if !strings.Contains(msgLower, devNameLower) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 匹配到了设备名称
|
// 判断此设备的操作:检查设备名附近的意图词
|
||||||
if strings.Contains(msgLower, "打开") || strings.Contains(msgLower, "开") {
|
devIdx := strings.Index(msgLower, devNameLower)
|
||||||
if dev.Status != "on" && dev.Status != "open" {
|
contextStart := devIdx - 15
|
||||||
if dev.Type == "curtain" {
|
if contextStart < 0 {
|
||||||
// 窗帘使用 set 而非 toggle
|
contextStart = 0
|
||||||
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "open")
|
|
||||||
} else {
|
|
||||||
_ = p.iotClient.ToggleDevice(dev.ID)
|
|
||||||
}
|
}
|
||||||
result.Summary = fmt.Sprintf("已帮%s打开%s♪", extractUserName(subCtx), dev.Name)
|
contextEnd := devIdx + len(devNameLower) + 15
|
||||||
result.Confidence = 0.9
|
if contextEnd > len(msgLower) {
|
||||||
result.ToolCalls = []model.ToolCallRecord{{
|
contextEnd = len(msgLower)
|
||||||
Name: "iot_control",
|
}
|
||||||
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
|
nearbyContext := msgLower[contextStart:contextEnd]
|
||||||
Result: "success",
|
|
||||||
}}
|
if strings.Contains(nearbyContext, "打开") || strings.Contains(nearbyContext, "开") {
|
||||||
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", dev.Name, dev.ID)
|
actions = append(actions, deviceAction{dev: dev, operation: "on"})
|
||||||
return result, nil
|
} else if strings.Contains(nearbyContext, "关闭") || strings.Contains(nearbyContext, "关掉") || strings.Contains(nearbyContext, "关上") {
|
||||||
|
actions = append(actions, deviceAction{dev: dev, operation: "off"})
|
||||||
} else {
|
} else {
|
||||||
result.Summary = fmt.Sprintf("%s已经是打开状态啦~", dev.Name)
|
actions = append(actions, deviceAction{dev: dev, operation: "query"})
|
||||||
result.Confidence = 0.9
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(msgLower, "关闭") || strings.Contains(msgLower, "关") {
|
// 如果没有匹配到具体设备,可能是查询所有设备状态
|
||||||
if dev.Status == "on" || dev.Status == "open" {
|
if len(actions) == 0 {
|
||||||
if dev.Type == "curtain" {
|
|
||||||
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "closed")
|
|
||||||
} else {
|
|
||||||
_ = p.iotClient.ToggleDevice(dev.ID)
|
|
||||||
}
|
|
||||||
result.Summary = fmt.Sprintf("已帮%s关闭%s~", extractUserName(subCtx), dev.Name)
|
|
||||||
result.Confidence = 0.9
|
|
||||||
result.ToolCalls = []model.ToolCallRecord{{
|
|
||||||
Name: "iot_control",
|
|
||||||
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
|
|
||||||
Result: "success",
|
|
||||||
}}
|
|
||||||
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", dev.Name, dev.ID)
|
|
||||||
return result, nil
|
|
||||||
} else {
|
|
||||||
result.Summary = fmt.Sprintf("%s已经是关闭状态啦~", dev.Name)
|
|
||||||
result.Confidence = 0.9
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询设备状态
|
|
||||||
deviceStatus := fmt.Sprintf("%s当前状态: %s", dev.Name, dev.Status)
|
|
||||||
if dev.Type == "light" && dev.Status == "on" {
|
|
||||||
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", dev.Brightness, dev.Color)
|
|
||||||
} else if dev.Type == "ac" && dev.Status == "on" {
|
|
||||||
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", dev.Mode, dev.Temperature)
|
|
||||||
}
|
|
||||||
result.Summary = deviceStatus
|
|
||||||
result.Confidence = 0.8
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没有匹配到设备,可能只是查询所有设备状态
|
|
||||||
if strings.Contains(msgLower, "设备") && (strings.Contains(msgLower, "状态") || strings.Contains(msgLower, "怎么样") || strings.Contains(msgLower, "看看")) {
|
if strings.Contains(msgLower, "设备") && (strings.Contains(msgLower, "状态") || strings.Contains(msgLower, "怎么样") || strings.Contains(msgLower, "看看")) {
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -295,10 +265,75 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
||||||
result.Summary = "(未匹配到 IoT 操作)"
|
result.Summary = "(未匹配到 IoT 操作)"
|
||||||
result.Confidence = 0.5
|
result.Confidence = 0.5
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行所有匹配到的操作
|
||||||
|
var summaries []string
|
||||||
|
var allToolCalls []model.ToolCallRecord
|
||||||
|
executedCount := 0
|
||||||
|
|
||||||
|
for _, action := range actions {
|
||||||
|
switch action.operation {
|
||||||
|
case "on":
|
||||||
|
if action.dev.Status != "on" && action.dev.Status != "open" {
|
||||||
|
if action.dev.Type == "curtain" {
|
||||||
|
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "open")
|
||||||
|
} else {
|
||||||
|
_ = p.iotClient.ToggleDevice(action.dev.ID)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, fmt.Sprintf("已帮%s打开%s♪", userName, action.dev.Name))
|
||||||
|
allToolCalls = append(allToolCalls, model.ToolCallRecord{
|
||||||
|
Name: "iot_control",
|
||||||
|
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
|
||||||
|
Result: "success",
|
||||||
|
})
|
||||||
|
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", action.dev.Name, action.dev.ID)
|
||||||
|
executedCount++
|
||||||
|
} else {
|
||||||
|
summaries = append(summaries, fmt.Sprintf("%s已经是打开状态啦~", action.dev.Name))
|
||||||
|
}
|
||||||
|
case "off":
|
||||||
|
if action.dev.Status == "on" || action.dev.Status == "open" {
|
||||||
|
if action.dev.Type == "curtain" {
|
||||||
|
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "closed")
|
||||||
|
} else {
|
||||||
|
_ = p.iotClient.ToggleDevice(action.dev.ID)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, fmt.Sprintf("已帮%s关闭%s~", userName, action.dev.Name))
|
||||||
|
allToolCalls = append(allToolCalls, model.ToolCallRecord{
|
||||||
|
Name: "iot_control",
|
||||||
|
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
|
||||||
|
Result: "success",
|
||||||
|
})
|
||||||
|
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", action.dev.Name, action.dev.ID)
|
||||||
|
executedCount++
|
||||||
|
} else {
|
||||||
|
summaries = append(summaries, fmt.Sprintf("%s已经是关闭状态啦~", action.dev.Name))
|
||||||
|
}
|
||||||
|
case "query":
|
||||||
|
deviceStatus := fmt.Sprintf("%s当前状态: %s", action.dev.Name, action.dev.Status)
|
||||||
|
if action.dev.Type == "light" && action.dev.Status == "on" {
|
||||||
|
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", action.dev.Brightness, action.dev.Color)
|
||||||
|
} else if action.dev.Type == "ac" && action.dev.Status == "on" {
|
||||||
|
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", action.dev.Mode, action.dev.Temperature)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, deviceStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Summary = strings.Join(summaries, "; ")
|
||||||
|
result.Confidence = 0.9
|
||||||
|
if len(allToolCalls) > 0 {
|
||||||
|
result.ToolCalls = allToolCalls
|
||||||
|
}
|
||||||
|
if executedCount == 0 {
|
||||||
|
result.Confidence = 0.8
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,5 +371,3 @@ func truncateStr(s string, maxLen int) string {
|
|||||||
return string(runes[:maxLen]) + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure json is used
|
|
||||||
var _ = json.Marshal
|
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"segments,omitempty"`
|
} `json:"segments,omitempty"`
|
||||||
|
// 审查后的结构化消息
|
||||||
|
ReviewMessages []ws.ReviewMessage `json:"review_messages,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
log.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
|
log.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
|
||||||
@@ -289,6 +291,46 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理审查后的结构化消息 (review)
|
||||||
|
if len(chunk.ReviewMessages) > 0 {
|
||||||
|
for i, rm := range chunk.ReviewMessages {
|
||||||
|
role := "assistant"
|
||||||
|
msgType := "chat"
|
||||||
|
if rm.Type == "action" {
|
||||||
|
role = "action"
|
||||||
|
msgType = "action"
|
||||||
|
}
|
||||||
|
reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i)
|
||||||
|
// 持久化每条审查消息
|
||||||
|
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
|
||||||
|
if err := h.sessionStore.AddMessage(client.SessionID, role, rm.Content); err != nil {
|
||||||
|
log.Printf("[chat] 持久化审查消息失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||||
|
ID: reviewMsgID,
|
||||||
|
Role: role,
|
||||||
|
Content: rm.Content,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
client.SendMessage(ws.ServerMessage{
|
||||||
|
Type: "response",
|
||||||
|
MessageID: reviewMsgID,
|
||||||
|
Content: rm.Content,
|
||||||
|
Role: role,
|
||||||
|
MsgType: msgType,
|
||||||
|
SessionID: client.SessionID,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
// 小延迟让消息逐条到达,更像真人
|
||||||
|
if i < len(chunk.ReviewMessages)-1 {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fullText += "[review]"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 处理断句事件 (stream_segments)
|
// 处理断句事件 (stream_segments)
|
||||||
if len(chunk.Segments) > 0 {
|
if len(chunk.Segments) > 0 {
|
||||||
for _, seg := range chunk.Segments {
|
for _, seg := range chunk.Segments {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Message struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
MsgType string `json:"msg_type,omitempty"`
|
||||||
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ type ClientMessage struct {
|
|||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReviewMessage 审查后的结构化消息(动作/聊天分离)
|
||||||
|
type ReviewMessage struct {
|
||||||
|
Type string `json:"type"` // "action" | "chat"
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
// 服务端 → 客户端消息
|
// 服务端 → 客户端消息
|
||||||
type ServerMessage struct {
|
type ServerMessage struct {
|
||||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments
|
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review
|
||||||
MessageID string `json:"message_id"`
|
MessageID string `json:"message_id"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
||||||
@@ -42,6 +48,8 @@ type ServerMessage struct {
|
|||||||
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
|
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
|
||||||
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
|
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
|
||||||
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
|
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
|
||||||
|
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
|
||||||
|
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiMessagePayload 多条消息的容器 (对应昔涟的多消息回复风格)
|
// MultiMessagePayload 多条消息的容器 (对应昔涟的多消息回复风格)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
**项目开发文档管理规范 (修订版)**
|
||||||
|
|
||||||
|
**1. 文档管理目录结构**
|
||||||
|
|
||||||
|
- **`./docs/progress/`**
|
||||||
|
请在此目录下定期创建进度 `md` 文件,以便后续对话能顺利继承开发进度。
|
||||||
|
|
||||||
|
- **`./docs/decisions/`**
|
||||||
|
请在此目录下创建决策 `md` 文件,以便后续对话能准确继承开发决策。
|
||||||
|
|
||||||
|
- **`./docs/tasks/`**
|
||||||
|
请在此目录下为每次任务创建 `md` 文件,以便后续对话能回顾开发任务详情。
|
||||||
|
|
||||||
|
- 你可以按需求使用或创建其他文档目录。
|
||||||
|
|
||||||
|
- 开发前可以通过阅读已有的文档回顾开发进度。
|
||||||
|
|
||||||
|
**2. 通用文档规范**
|
||||||
|
|
||||||
|
- 在 `./docs/` 目录下,请按统一格式创建辅助文档或文件夹,便于后续开发参考:
|
||||||
|
**格式:** `YYYY-MM-DD_HH-mm-SS-topic.md`
|
||||||
|
- 每次开启新对话或处理新任务前,建议先浏览这些文件获取上下文。
|
||||||
|
|
||||||
|
**3. 文档的创建与维护**
|
||||||
|
|
||||||
|
- 你可以在思考或任务执行过程中,随时新建、修改或删除这些文档,动作可以频繁一些喵~
|
||||||
|
- 已实现、调试通过且功能完善的模块,请在对应的 `md` 文件中做好统一标记,避免后续频繁重复阅读。
|
||||||
|
- 在完成功能重大调整与开发后请及时编写或修改 `./docs/api-reference/` 下的文档,和项目根目录下的 `Deploy.md`
|
||||||
|
|
||||||
|
**4. 调试与测试**
|
||||||
|
|
||||||
|
- 调试功能时,可以在终端启动 `devtools.sh` 脚本:
|
||||||
|
使用 `curl` 启动所有服务,再通过 `curl` 等工具对实现的功能进行接口调试。
|
||||||
|
`devtools` 提供的 API 可启动各前后端服务,请牢记这个流程喵!
|
||||||
|
- 涉及到需要浏览器操作去验证前端或后端接口时,可以启动 Chromium 的自动化控制模式。
|
||||||
|
启动后访问 http://localhost:9222/json (端口可能不一致) 就能看到所有可操控的页面列表,拿到 webSocketDebuggerUrl 就能通过 WebSocket 直接发 CDP 指令。
|
||||||
|
|
||||||
|
**5. 数据库连接**
|
||||||
|
|
||||||
|
- 使用根目录的 `docker-compose.dev.db.yml` 创建开发环境的数据库容器。若存在仅启动。若启动无需重启。
|
||||||
|
|
||||||
|
**6. 版本提交规范**
|
||||||
|
|
||||||
|
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
|
||||||
|
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
|
||||||
|
|
||||||
|
**7. 测试脚本临时管理**
|
||||||
|
|
||||||
|
- 在测试长脚本或复杂命令时,可以在 `debug` 目录临时创建 `cache` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
|
||||||
|
- **注意:** 用完记得及时删除喵~
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Cyrene 第四轮修复报告 — IoT 操控 + Review Pipeline + 速度优化
|
||||||
|
|
||||||
|
> **报告日期**:2026-05-22
|
||||||
|
> **覆盖周期**:2026-05-22 (UTC+8)
|
||||||
|
> **分支**:`dev`
|
||||||
|
> **涉及文件数**:9 个
|
||||||
|
> **E2E 测试**:全部通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、修复项总览
|
||||||
|
|
||||||
|
| # | 类别 | 问题 | 涉及核心文件 |
|
||||||
|
|---|------|------|-------------|
|
||||||
|
| 1 | 🔴 功能 | IoT 设备操控 — 多设备命令支持 + Persona 路径 | [`iot_provider.go`](backend/ai-core/internal/subsession/iot_provider.go), [`main.go`](backend/ai-core/cmd/main.go) |
|
||||||
|
| 2 | 🟡 功能 | Review Pipeline — 主会话输出解析为 action/chat 消息 | [`orchestrator.go`](backend/ai-core/internal/orchestrator/orchestrator.go), [`chat_handler.go`](backend/gateway/internal/handler/chat_handler.go) |
|
||||||
|
| 3 | 🟡 功能 | 前端 action 消息显示 + msgType 历史映射 | [`protocol.go`](backend/gateway/internal/ws/protocol.go), [`hub.go`](backend/gateway/internal/ws/hub.go), [`sessionStore.ts`](frontend/web/src/store/sessionStore.ts) |
|
||||||
|
| 4 | 🟢 优化 | 对话链路速度优化 — IoT 快速通道 + 简单消息跳过子会话 | [`intent_analyzer.go`](backend/ai-core/internal/orchestrator/intent_analyzer.go), [`orchestrator.go`](backend/ai-core/internal/orchestrator/orchestrator.go) |
|
||||||
|
| 5 | 🟢 优化 | Persona 注入 action 格式指令 | [`injector.go`](backend/ai-core/internal/persona/injector.go) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、详细修复说明
|
||||||
|
|
||||||
|
### 2.1 IoT 多设备命令支持 + Persona 路径修复
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- IoT 子会话 `Execute()` 在匹配到第一个设备后立即 `return`,导致"打开客厅灯和卧室灯"只执行第一个
|
||||||
|
- `NewIoTProvider` 未接收 `personaDir` 参数,persona 配置加载始终使用空路径
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `iot_provider.go` — 完全重写 `Execute()` 函数:先收集所有设备-操作匹配对,再批量执行
|
||||||
|
- `iot_provider.go` — `IoTProvider` 结构体新增 `personaDir` 字段,`NewIoTProvider` 接收参数
|
||||||
|
- `main.go` — 注册 IoT provider 时传入 `personaDir`
|
||||||
|
|
||||||
|
**关键代码**(`Execute` 多设备收集逻辑):
|
||||||
|
```go
|
||||||
|
type deviceAction struct {
|
||||||
|
dev tools.IoTDevice
|
||||||
|
operation string // "on" | "off" | "query"
|
||||||
|
}
|
||||||
|
var actions []deviceAction
|
||||||
|
for _, dev := range devices {
|
||||||
|
// 匹配设备名 + 上下文意图词
|
||||||
|
// 收集所有匹配的 action
|
||||||
|
}
|
||||||
|
// 批量执行所有 action,合并 summaries
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Review Pipeline — 内联解析
|
||||||
|
|
||||||
|
**问题**:主会话 LLM 输出需要拆分为 action 消息(描述操作)+ chat 消息(对话文本),前端分别渲染
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `orchestrator.go` — 新增 `parseReviewMessages()` 函数:无正则括号匹配状态机,支持 `()` 和 `()`
|
||||||
|
- `orchestrator.go` — 新增 `splitReviewLongMessage()` 函数:80 字符智能断句,在句子边界处分割
|
||||||
|
- `orchestrator.go` — 合成完成后自动调用审查,发送 `StreamReview` 事件
|
||||||
|
- `chat_handler.go` — SSE 解析 `review_messages` 字段,逐条发送 WebSocket response(action 消息 200ms 间隔)
|
||||||
|
- `protocol.go` — 新增 `ReviewMessage` 结构体 + `ServerMessage.MsgType` 字段
|
||||||
|
- `hub.go` — `Message` 结构体新增 `MsgType` 字段
|
||||||
|
|
||||||
|
**消息格式约定**:
|
||||||
|
- `(动作描述) 对话文本` → action 消息(角色 `action`,类型 `action`)+ chat 消息(角色 `assistant`,类型 `chat`)
|
||||||
|
- 括号支持半角 `()` 和全角 `()`
|
||||||
|
- 长 chat 消息自动在 80 字符处按句子边界拆分
|
||||||
|
|
||||||
|
### 2.3 前端 action 消息显示
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `sessionStore.ts` — 历史消息加载时映射 `msg_type` 字段,`action` 类型消息使用 `action` 角色
|
||||||
|
|
||||||
|
### 2.4 对话速度优化
|
||||||
|
|
||||||
|
**问题**:每条消息都需要 2-3 秒 LLM 意图分析 + 所有子会话调度
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `intent_analyzer.go` — 新增 `isStrongIoTCommand()`:控制词 + 设备词同时出现时跳过 LLM 分析,直接使用关键词规则(节省 2-3s)
|
||||||
|
- `intent_analyzer.go` — 简单问候的快速通道已存在(精确匹配 + ≤4 字符消息)
|
||||||
|
- `orchestrator.go` — 快速通道扩展:`primary=greeting` 或 `(primary=chat && !needsIoT && !needsMemory)` 时跳过所有子会话分派
|
||||||
|
- `orchestrator.go` — 子会话结果等待超时从 500ms 降至 200ms
|
||||||
|
|
||||||
|
### 2.5 Persona action 格式指令
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `injector.go` — 在对话风格指令中新增:
|
||||||
|
```
|
||||||
|
- 执行操作时(开关设备、查询状态等),用括号包裹动作描述,后面跟自然对话。
|
||||||
|
例如:"(帮你把客厅灯关掉啦) 嗯,已经关好了~"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、E2E 验证结果
|
||||||
|
|
||||||
|
### 测试场景 1:IoT 设备操控
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:帮我把卧室空调打开
|
||||||
|
输出:
|
||||||
|
[ACTION] "指尖轻点,空调立刻嗡鸣启动"
|
||||||
|
[CHAT] "好啦,已经让卧室凉快下来啦~♪..."
|
||||||
|
|
||||||
|
后端日志:
|
||||||
|
[IoT-client] ✅ 切换设备成功: ac-bedroom
|
||||||
|
[iot-subsession] 执行操作: 打开 卧室空调 (ac-bedroom)
|
||||||
|
|
||||||
|
设备状态:ac-bedroom: off → on ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试场景 2:Review Pipeline 消息拆分
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:帮我把客厅灯打开
|
||||||
|
输出:
|
||||||
|
[ACTION] "轻轻感应了一下忆庭对客厅灯的投影"
|
||||||
|
[CHAT] "嗯...叶酱,客厅灯现在是开着的哦?刚才是不是工作太忙,有点眼花啦♪"
|
||||||
|
|
||||||
|
后端日志:
|
||||||
|
[orchestrator] 审查完成: 2 条带类型消息 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试场景 3:简单问候快速通道
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:你好呀
|
||||||
|
输出:
|
||||||
|
[CHAT] "诶嘿,叶酱今天主动跟人家打招呼了♪"
|
||||||
|
[ACTION] "笑着晃了晃食指"
|
||||||
|
[CHAT] "让我猜猜...是不是有好事要跟姐姐分享呀?"
|
||||||
|
|
||||||
|
后端日志:
|
||||||
|
[intent] 快速通道: 检测到简单问候,跳过 LLM 分析
|
||||||
|
[orchestrator] 快速通道: 简单消息(primary=greeting),跳过子会话分派
|
||||||
|
意图分析耗时: 0s ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试场景 4:IoT 快速通道
|
||||||
|
|
||||||
|
```
|
||||||
|
输入:帮我把卧室空调打开
|
||||||
|
后端日志:
|
||||||
|
[intent] 快速通道: 检测到 IoT 操控命令,跳过 LLM 分析
|
||||||
|
意图分析耗时: 0s ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、性能对比
|
||||||
|
|
||||||
|
| 场景 | 修复前 | 修复后 | 节省 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| IoT 命令 | ~6s (LLM意图2-3s + 子会话 + 合成) | ~3.4s (跳过LLM意图) | ~2.6s |
|
||||||
|
| 简单问候 | ~5s (LLM意图 + 子会话) | ~3.9s (跳过LLM意图 + 跳过子会话) | ~1.1s |
|
||||||
|
| 一般聊天 | ~5s | ~4s (仅合成) | ~1s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、涉及文件清单
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `backend/ai-core/cmd/main.go` | 修改 | 传递 personaDir 给 NewIoTProvider |
|
||||||
|
| `backend/ai-core/internal/orchestrator/intent_analyzer.go` | 修改 | 新增 isStrongIoTCommand 快速通道 |
|
||||||
|
| `backend/ai-core/internal/orchestrator/orchestrator.go` | 修改 | parseReviewMessages + 快速通道扩展 + 超时优化 |
|
||||||
|
| `backend/ai-core/internal/persona/injector.go` | 修改 | 对话风格新增 action 格式指令 |
|
||||||
|
| `backend/ai-core/internal/subsession/iot_provider.go` | 修改 | 多设备支持 + persona 路径修复 |
|
||||||
|
| `backend/gateway/internal/handler/chat_handler.go` | 修改 | SSE review_messages 解析 → WebSocket 转发 |
|
||||||
|
| `backend/gateway/internal/ws/protocol.go` | 修改 | 新增 ReviewMessage 结构体 + MsgType 字段 |
|
||||||
|
| `backend/gateway/internal/ws/hub.go` | 修改 | Message 结构体新增 MsgType 字段 |
|
||||||
|
| `frontend/web/src/store/sessionStore.ts` | 修改 | 历史消息 msg_type 映射 |
|
||||||
@@ -146,6 +146,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
content: typeof raw.content === 'string' ? raw.content : '',
|
content: typeof raw.content === 'string' ? raw.content : '',
|
||||||
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
|
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
|
||||||
isStreaming: false as const,
|
isStreaming: false as const,
|
||||||
|
msgType: msgType as Message["msgType"],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const CDP_HOST = 'localhost';
|
||||||
|
const CDP_PORT = 9222;
|
||||||
|
|
||||||
|
function httpRequest(method, path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: CDP_HOST, port: CDP_PORT, method, path }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch(e) { resolve(data); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpCommand(ws, method, params = {}) {
|
||||||
|
const id = Math.floor(Math.random() * 1000000);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.id === id) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg.result || msg);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve({ error: 'timeout' });
|
||||||
|
}, 8000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[1] Creating new tab...');
|
||||||
|
const newTab = await httpRequest('PUT', '/json/new?about:blank');
|
||||||
|
console.log(' New tab:', newTab.id, '| WS:', newTab.webSocketDebuggerUrl?.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
const ws = new WebSocket(newTab.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
console.log('[2] CDP connected.');
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Runtime.enable');
|
||||||
|
await cdpCommand(ws, 'Page.enable');
|
||||||
|
await cdpCommand(ws, 'Log.enable');
|
||||||
|
|
||||||
|
const consoleMessages = [];
|
||||||
|
const errors = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||||
|
const p = msg.params;
|
||||||
|
const text = p.args?.map(a => a.value || a.description || '').join(' ') || '';
|
||||||
|
consoleMessages.push(`[${p.type}] ${text}`);
|
||||||
|
console.log(` CONSOLE [${p.type}]: ${text.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
const ed = msg.params.exceptionDetails;
|
||||||
|
const text = ed.text || ed.exception?.description || JSON.stringify(ed).substring(0, 300);
|
||||||
|
errors.push(text);
|
||||||
|
console.log(` EXCEPTION: ${text.substring(0, 300)}`);
|
||||||
|
if (ed.stackTrace) {
|
||||||
|
ed.stackTrace.callFrames?.forEach((f) => {
|
||||||
|
console.log(` at ${f.functionName || '(anonymous)'} (${f.url}:${f.lineNumber}:${f.columnNumber})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.method === 'Log.entryAdded') {
|
||||||
|
const entry = msg.params.entry;
|
||||||
|
const text = entry.text?.substring(0, 200) || '';
|
||||||
|
console.log(` LOG [${entry.level}]: ${text}`);
|
||||||
|
if (entry.level === 'error') {
|
||||||
|
errors.push(`[LOG:${entry.level}] ${text}`);
|
||||||
|
consoleMessages.push(`[LOG:${entry.level}] ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[3] Navigating to http://localhost:5199/ ...');
|
||||||
|
await cdpCommand(ws, 'Page.navigate', { url: 'http://localhost:5199/' });
|
||||||
|
|
||||||
|
console.log('[4] Waiting 8s for page load...');
|
||||||
|
await new Promise(r => setTimeout(r, 8000));
|
||||||
|
|
||||||
|
console.log('[5] Checking DOM...');
|
||||||
|
const docNode = await cdpCommand(ws, 'DOM.getDocument', { depth: 2 });
|
||||||
|
if (docNode && docNode.root) {
|
||||||
|
console.log(' Root:', docNode.root.nodeName, 'children:', docNode.root.childNodeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate #root content
|
||||||
|
try {
|
||||||
|
const e1 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) return 'NO_ROOT_ELEMENT';
|
||||||
|
const html = root.innerHTML.trim();
|
||||||
|
if (!html) return 'ROOT_EMPTY';
|
||||||
|
return 'ROOT_CONTENT(len=' + html.length + '): ' + html.substring(0, 500);
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' #root:', e1?.result?.value || JSON.stringify(e1).substring(0, 300));
|
||||||
|
} catch(e) {
|
||||||
|
console.log(' Eval error:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check body
|
||||||
|
try {
|
||||||
|
const e2 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `document.body ? document.body.children.length + ' body children, innerHTML len=' + document.body.innerHTML.length : 'NO_BODY'`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' body:', e2?.result?.value);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Check document title
|
||||||
|
try {
|
||||||
|
const e3 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `document.title`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' title:', e3?.result?.value);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
console.log('\n=== SUMMARY ===');
|
||||||
|
console.log('Console messages:', consoleMessages.length);
|
||||||
|
consoleMessages.slice(0, 15).forEach(m => console.log(' ', m.substring(0, 200)));
|
||||||
|
console.log('Errors:', errors.length);
|
||||||
|
errors.forEach((e, i) => console.log(` ERR${i+1}: ${e.substring(0, 500)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('Fatal:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const CDP_HOST = 'localhost';
|
||||||
|
const CDP_PORT = 9222;
|
||||||
|
|
||||||
|
function httpRequest(method, path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: CDP_HOST, port: CDP_PORT, method, path }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch(e) { resolve(data); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpCommand(ws, method, params = {}) {
|
||||||
|
const id = Math.floor(Math.random() * 1000000);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.id === id) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg.result || msg);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
setTimeout(() => { ws.off('message', handler); resolve({ error: 'timeout' }); }, 8000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPage(label) {
|
||||||
|
console.log(`\n=== ${label} ===`);
|
||||||
|
const newTab = await httpRequest('PUT', '/json/new?about:blank');
|
||||||
|
const ws = new WebSocket(newTab.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Runtime.enable');
|
||||||
|
await cdpCommand(ws, 'Page.enable');
|
||||||
|
await cdpCommand(ws, 'Log.enable');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const logs = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
errors.push(msg.params.exceptionDetails.text || JSON.stringify(msg.params.exceptionDetails).substring(0, 200));
|
||||||
|
}
|
||||||
|
if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') {
|
||||||
|
errors.push('[LOG] ' + msg.params.entry.text?.substring(0, 200));
|
||||||
|
}
|
||||||
|
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||||
|
logs.push(`[${msg.params.type}] ${msg.params.args?.map(a => a.value || '').join(' ').substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Page.navigate', { url: 'http://localhost:5199/' });
|
||||||
|
await new Promise(r => setTimeout(r, 6000));
|
||||||
|
|
||||||
|
// Check #root
|
||||||
|
const e1 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `(() => { const r = document.getElementById('root'); return r ? (r.innerHTML.trim() ? 'CONTENT(' + r.innerHTML.length + ')' : 'EMPTY') : 'NO_ROOT'; })()`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' #root:', e1?.result?.value);
|
||||||
|
|
||||||
|
// Check if JS loaded
|
||||||
|
const e2 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `typeof React !== 'undefined' ? 'React OK' : 'React MISSING'`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' React:', e2?.result?.value);
|
||||||
|
|
||||||
|
// Check SW status
|
||||||
|
const e3 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `(() => { try { return 'serviceWorker' in navigator ? 'SW supported' : 'SW not supported'; } catch(e) { return 'Error: '+e.message; } })()`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' SW:', e3?.result?.value);
|
||||||
|
|
||||||
|
console.log(' Errors:', errors.length);
|
||||||
|
errors.forEach(e => console.log(' ', e.substring(0, 200)));
|
||||||
|
console.log(' Logs:', logs.length);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// First visit - install SW
|
||||||
|
await testPage('1st VISIT (SW install)');
|
||||||
|
|
||||||
|
// Wait a bit for SW activation
|
||||||
|
console.log('\nWaiting 3s for SW activation...');
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
// Second visit - SW should be active
|
||||||
|
await testPage('2nd VISIT (SW active)');
|
||||||
|
|
||||||
|
// Third visit
|
||||||
|
await testPage('3rd VISIT');
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const CDP_HOST = 'localhost';
|
||||||
|
const CDP_PORT = 9222;
|
||||||
|
|
||||||
|
function httpRequest(method, path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: CDP_HOST, port: CDP_PORT, method, path }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch(e) { resolve(data); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpCommand(ws, method, params = {}) {
|
||||||
|
const id = Math.floor(Math.random() * 1000000);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.id === id) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg.result || msg);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
setTimeout(() => { ws.off('message', handler); resolve({ error: 'timeout' }); }, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[1] Creating new tab and setting fake auth token...');
|
||||||
|
const newTab = await httpRequest('PUT', '/json/new?about:blank');
|
||||||
|
const ws = new WebSocket(newTab.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Runtime.enable');
|
||||||
|
await cdpCommand(ws, 'Page.enable');
|
||||||
|
await cdpCommand(ws, 'Log.enable');
|
||||||
|
await cdpCommand(ws, 'Network.enable');
|
||||||
|
|
||||||
|
// Navigate to app domain first so we can set localStorage
|
||||||
|
await cdpCommand(ws, 'Page.navigate', { url: 'http://localhost:5199/' });
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
// Set fake auth token and user_id in localStorage
|
||||||
|
console.log('[2] Injecting fake token into localStorage...');
|
||||||
|
const setStorage = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
localStorage.setItem('token', 'fake_test_token_abc123');
|
||||||
|
localStorage.setItem('user_id', 'admin_test_user');
|
||||||
|
'done'
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' localStorage set:', setStorage?.result?.value);
|
||||||
|
|
||||||
|
// Now reload the page so auth store picks up the token
|
||||||
|
console.log('[3] Reloading page with auth token...');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const logs = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
const ed = msg.params.exceptionDetails;
|
||||||
|
const errText = ed.text || ed.exception?.description || '';
|
||||||
|
errors.push(errText);
|
||||||
|
console.log(` ❌ EXCEPTION: ${errText.substring(0, 300)}`);
|
||||||
|
if (ed.exception?.className) console.log(` class: ${ed.exception.className}`);
|
||||||
|
if (ed.stackTrace?.callFrames) {
|
||||||
|
ed.stackTrace.callFrames.slice(0, 5).forEach(f => {
|
||||||
|
console.log(` at ${f.functionName || '(anon)'} (${f.url}:${f.lineNumber}:${f.columnNumber})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.method === 'Log.entryAdded') {
|
||||||
|
const entry = msg.params.entry;
|
||||||
|
const text = entry.text?.substring(0, 200) || '';
|
||||||
|
console.log(` LOG [${entry.level}]: ${text}`);
|
||||||
|
if (entry.level === 'error' || entry.level === 'warning') {
|
||||||
|
errors.push(`[LOG:${entry.level}] ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||||
|
const p = msg.params;
|
||||||
|
if (p.type === 'error') {
|
||||||
|
const text = p.args?.map(a => a.value || a.description || '').join(' ') || '';
|
||||||
|
errors.push(`[CONSOLE:${p.type}] ${text}`);
|
||||||
|
console.log(` CONSOLE.error: ${text.substring(0, 200)}`);
|
||||||
|
} else {
|
||||||
|
logs.push(`[${p.type}] ${p.args?.map(a => a.value || '').join(' ').substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.method === 'Network.responseReceived') {
|
||||||
|
const resp = msg.params.response;
|
||||||
|
if (resp.status >= 400) {
|
||||||
|
console.log(` NET ${resp.status}: ${resp.url.substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Page.reload', { ignoreCache: true });
|
||||||
|
await new Promise(r => setTimeout(r, 8000));
|
||||||
|
|
||||||
|
console.log('\n[4] Checking DOM state...');
|
||||||
|
|
||||||
|
// Check #root
|
||||||
|
const e1 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) return 'NO_ROOT';
|
||||||
|
const h = root.innerHTML.trim();
|
||||||
|
if (!h) return 'ROOT_EMPTY';
|
||||||
|
return 'ROOT_LEN=' + h.length + ' FIRST:' + h.substring(0, 300);
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' #root:', e1?.result?.value);
|
||||||
|
|
||||||
|
// Check body
|
||||||
|
const e2 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `document.body ? 'BODY_OK children=' + document.body.children.length : 'NO_BODY'`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' body:', e2?.result?.value);
|
||||||
|
|
||||||
|
// Check if React rendered the chat interface
|
||||||
|
const e3 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
const h = root ? root.innerHTML : '';
|
||||||
|
const hasAppLayout = h.includes('h-screen');
|
||||||
|
const hasSidebar = h.includes('sidebar') || h.includes('新对话');
|
||||||
|
const hasHeader = h.includes('昔涟') && h.includes('退出');
|
||||||
|
const hasChatContainer = h.includes('ChatContainer') || h.includes('开始一段新对话');
|
||||||
|
const hasChatInput = h.includes('和昔涟说点什么');
|
||||||
|
return JSON.stringify({ hasAppLayout, hasSidebar, hasHeader, hasChatContainer, hasChatInput, totalLen: h.length });
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' Features:', e3?.result?.value);
|
||||||
|
|
||||||
|
// Check for any React ErrorBoundary or error state
|
||||||
|
const e4 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `
|
||||||
|
(() => {
|
||||||
|
const body = document.body;
|
||||||
|
return body ? body.innerText.substring(0, 500) : 'NO_BODY';
|
||||||
|
})()
|
||||||
|
`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' Text:', e4?.result?.value);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
console.log('\n=== SUMMARY ===');
|
||||||
|
console.log('Total errors:', errors.length);
|
||||||
|
errors.forEach((e, i) => console.log(` ERR${i+1}: ${e.substring(0, 500)}`));
|
||||||
|
console.log('Total logs:', logs.length);
|
||||||
|
logs.forEach(l => console.log(' LOG:', l.substring(0, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const CDP_HOST = 'localhost';
|
||||||
|
const CDP_PORT = 9222;
|
||||||
|
|
||||||
|
function httpRequest(method, path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: CDP_HOST, port: CDP_PORT, method, path }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch(e) { resolve(data); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpCommand(ws, method, params = {}) {
|
||||||
|
const id = Math.floor(Math.random() * 1000000);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.id === id) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg.result || msg);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
setTimeout(() => { ws.off('message', handler); resolve({ error: 'timeout' }); }, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPage(url, label) {
|
||||||
|
console.log(`\n=== ${label} ===`);
|
||||||
|
const newTab = await httpRequest('PUT', '/json/new?about:blank');
|
||||||
|
const ws = new WebSocket(newTab.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Runtime.enable');
|
||||||
|
await cdpCommand(ws, 'Page.enable');
|
||||||
|
await cdpCommand(ws, 'Log.enable');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
const ed = msg.params.exceptionDetails;
|
||||||
|
errors.push({ text: ed.text || ed.exception?.description || '', stack: ed.stackTrace?.callFrames });
|
||||||
|
console.log(` ❌ ${ed.text || ''}`);
|
||||||
|
}
|
||||||
|
if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') {
|
||||||
|
errors.push({ text: '[LOG] ' + msg.params.entry.text });
|
||||||
|
console.log(` LOG_ERR: ${msg.params.entry.text?.substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Page.navigate', { url });
|
||||||
|
await new Promise(r => setTimeout(r, 6000));
|
||||||
|
|
||||||
|
const e1 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `(() => { const r = document.getElementById('root'); return r ? (r.innerHTML.trim() ? 'CONTENT(' + r.innerHTML.length + ')' : 'EMPTY') : 'NO_ROOT'; })()`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' #root:', e1?.result?.value);
|
||||||
|
console.log(' Errors:', errors.length);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
return { rootStatus: e1?.result?.value, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Test dev server (port 5173) - no SW
|
||||||
|
console.log('Testing VITE DEV SERVER (port 5173)...');
|
||||||
|
const r1 = await testPage('http://localhost:5173/', 'DEV - No Token (Login page)');
|
||||||
|
|
||||||
|
// Test dev server with fake token
|
||||||
|
console.log('\nTesting VITE DEV with fake token...');
|
||||||
|
const newTab = await httpRequest('PUT', '/json/new?about:blank');
|
||||||
|
const ws = new WebSocket(newTab.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
await cdpCommand(ws, 'Runtime.enable');
|
||||||
|
await cdpCommand(ws, 'Page.enable');
|
||||||
|
await cdpCommand(ws, 'Log.enable');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.method === 'Runtime.exceptionThrown') {
|
||||||
|
const ed = msg.params.exceptionDetails;
|
||||||
|
errors.push(ed.text || ed.exception?.description || '');
|
||||||
|
console.log(` ❌ ${ed.text || ''}`);
|
||||||
|
if (ed.stackTrace?.callFrames) {
|
||||||
|
ed.stackTrace.callFrames.slice(0, 5).forEach(f => {
|
||||||
|
console.log(` at ${f.functionName || '(anon)'} (${f.url}:${f.lineNumber}:${f.columnNumber})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.method === 'Log.entryAdded' && (msg.params.entry.level === 'error' || msg.params.entry.level === 'warning')) {
|
||||||
|
console.log(` LOG [${msg.params.entry.level}]: ${msg.params.entry.text?.substring(0, 150)}`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate first, then set token, then reload
|
||||||
|
await cdpCommand(ws, 'Page.navigate', { url: 'http://localhost:5173/' });
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `localStorage.setItem('token', 'fake_token_xyz'); localStorage.setItem('user_id', 'admin_test_user'); 'ok'`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Reloading with token...');
|
||||||
|
await cdpCommand(ws, 'Page.reload', { ignoreCache: true });
|
||||||
|
await new Promise(r => setTimeout(r, 8000));
|
||||||
|
|
||||||
|
const e2 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `(() => { const r = document.getElementById('root'); return r ? (r.innerHTML.trim() ? 'CONTENT(' + r.innerHTML.length + ')' : 'EMPTY') : 'NO_ROOT'; })()`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' DEV+AUTH #root:', e2?.result?.value);
|
||||||
|
|
||||||
|
// Check visible text
|
||||||
|
const e3 = await cdpCommand(ws, 'Runtime.evaluate', {
|
||||||
|
expression: `document.body ? document.body.innerText.substring(0, 300) : 'NO_BODY'`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
console.log(' Text:', e3?.result?.value);
|
||||||
|
|
||||||
|
console.log(' Errors:', errors.length);
|
||||||
|
errors.forEach((e, i) => console.log(` ERR${i+1}: ${typeof e === 'string' ? e.substring(0, 200) : JSON.stringify(e).substring(0, 200)}`));
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
console.log('\n=== ALL TESTS DONE ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODIwNTI0MjksImlhdCI6MTc3OTQ2MDQyOSwidHlwZSI6ImFjY2VzcyIsInVzZXJfaWQiOiJhZG1pbiJ9.JK1dP61eqnHCxfkQrCYQf-mQKPLjDM0o3k2UHkcovZ0";
|
||||||
|
const WS_URL = `ws://127.0.0.1:8080/ws/chat?token=${TOKEN}&session_id=test_iot_${Date.now()}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
let hasAction = false;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('Connected');
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
content: '帮我把卧室灯打开',
|
||||||
|
session_id: null,
|
||||||
|
mode: 'text',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
console.log('Sent: 帮我把卧室灯打开');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'response' && msg.msg_type === 'action') {
|
||||||
|
console.log('✅ ACTION MESSAGE:', msg.content);
|
||||||
|
hasAction = true;
|
||||||
|
} else if (msg.type === 'response') {
|
||||||
|
console.log('💬 CHAT MESSAGE:', msg.content, 'msg_type:', msg.msg_type);
|
||||||
|
} else if (msg.type === 'stream_chunk') {
|
||||||
|
process.stdout.write(msg.content || '');
|
||||||
|
} else if (msg.type === 'stream_end') {
|
||||||
|
console.log('\n--- stream_end ---');
|
||||||
|
setTimeout(() => ws.close(), 500);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
console.log('Error:', msg.error);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('hasAction:', hasAction);
|
||||||
|
process.exit(hasAction ? 0 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => { console.error('WS error:', err.message); process.exit(1); };
|
||||||
|
setTimeout(() => { console.log('Timeout'); ws.close(); process.exit(1); }, 35000);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Quick WebSocket test for review pipeline
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODIwNTIwNDYsImlhdCI6MTc3OTQ2MDA0NiwidHlwZSI6ImFjY2VzcyIsInVzZXJfaWQiOiJhZG1pbiJ9.dmNrCJsz576eEvNWlXVNP7BdZDEpijJ73pSrcqmTJdE';
|
||||||
|
const WS_URL = `ws://127.0.0.1:8080/ws/chat?token=${TOKEN}&session_id=test_review_${Date.now()}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log('Connected to WebSocket');
|
||||||
|
|
||||||
|
// Send a message that should trigger IoT action response with parenthetical format
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
content: '帮我把客厅灯打开',
|
||||||
|
session_id: null,
|
||||||
|
mode: 'text',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
console.log('Sent: 帮我把客厅灯打开');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
console.log(`\n[${msg.type}]`, JSON.stringify(msg, null, 2).substring(0, 500));
|
||||||
|
|
||||||
|
if (msg.type === 'response' || msg.type === 'review') {
|
||||||
|
console.log('✅ Got response/review message!');
|
||||||
|
}
|
||||||
|
if (msg.type === 'stream_end') {
|
||||||
|
console.log('Stream ended, closing...');
|
||||||
|
setTimeout(() => ws.close(), 1000);
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
console.log('❌ Error:', msg.error);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Raw:', data.toString().substring(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('Connection closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('WebSocket error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Timeout - closing');
|
||||||
|
ws.close();
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Quick WebSocket test for review pipeline
|
||||||
|
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODIwNTIwNDYsImlhdCI6MTc3OTQ2MDA0NiwidHlwZSI6ImFjY2VzcyIsInVzZXJfaWQiOiJhZG1pbiJ9.dmNrCJsz576eEvNWlXVNP7BdZDEpijJ73pSrcqmTJdE';
|
||||||
|
const WS_URL = `ws://127.0.0.1:8080/ws/chat?token=${TOKEN}&session_id=test_review_${Date.now()}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('Connected to WebSocket');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
content: '帮我把客厅灯打开',
|
||||||
|
session_id: null,
|
||||||
|
mode: 'text',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
console.log('Sent: 帮我把客厅灯打开');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
const short = JSON.stringify(msg).substring(0, 400);
|
||||||
|
console.log(`[${msg.type}]`, short);
|
||||||
|
|
||||||
|
if (msg.type === 'response' && msg.msg_type === 'action') {
|
||||||
|
console.log('✅ Got ACTION message!');
|
||||||
|
}
|
||||||
|
if (msg.type === 'response' && msg.msg_type === 'chat') {
|
||||||
|
console.log('✅ Got CHAT message!');
|
||||||
|
}
|
||||||
|
if (msg.type === 'stream_end') {
|
||||||
|
console.log('Stream ended, closing...');
|
||||||
|
setTimeout(() => ws.close(), 500);
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
console.log('Error:', msg.error);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Raw:', event.data.substring(0, 300));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('Connection closed');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.error('WebSocket error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Timeout (30s) - closing');
|
||||||
|
ws.close();
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000);
|
||||||
Reference in New Issue
Block a user