fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置
- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复) - 修复IoT子会话工具调用链路日志缺失 - 新增最终审查子会话(review_provider) 支持消息格式解析拆分 - 实现历史消息持久化(后端存储+前端分页加载) - 前端新增动作消息(ActionMessage)类型和渲染 - 优化对话链路速度(非阻塞子会话+快速问候通道) - JWT密钥环境变量化(无默认值启动panic) - Token自动刷新机制(401拦截器+refresh接口) - WebSocket指数退避重连(jitter+最大10次) - localStorage清理一致性(cyrene_前缀+版本检查) - IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
@@ -8,6 +8,7 @@ docs/
|
||||
.DS_Store
|
||||
dev_must_read.md
|
||||
scripts/tunnel.sh
|
||||
.debug/
|
||||
|
||||
# Test scripts
|
||||
test/
|
||||
|
||||
@@ -46,8 +46,16 @@ REGISTRATION_ENABLED=true
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
JWT_EXPIRY_HOURS=720
|
||||
|
||||
# ========== JWT ==========
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
JWT_EXPIRY_HOURS=720
|
||||
|
||||
# ========== 内部服务认证 ==========
|
||||
INTERNAL_SERVICE_TOKEN=your-internal-token-change-in-production
|
||||
|
||||
# ========== IoT 调试服务 ==========
|
||||
IOT_DEBUG_SERVICE_URL=http://localhost:8083
|
||||
# 优先使用 IOT_SERVICE_URL,如果不存在则回退到 IOT_DEBUG_SERVICE_URL(向后兼容)
|
||||
IOT_SERVICE_URL=http://localhost:8083
|
||||
|
||||
# ========== 后台思考 ==========
|
||||
ENABLE_BACKGROUND_THINKING=true
|
||||
|
||||
@@ -92,7 +92,7 @@ func main() {
|
||||
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
|
||||
log.Printf("IoT 客户端已就绪: %s", cfg.IoTServiceURL)
|
||||
} else {
|
||||
log.Println("IoT 客户端未配置 (IOT_DEBUG_SERVICE_URL 为空)")
|
||||
log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)")
|
||||
}
|
||||
|
||||
// 初始化工具注册中心
|
||||
@@ -161,6 +161,7 @@ func main() {
|
||||
if iotClient != nil {
|
||||
subManager.Register(subsession.NewIoTProvider(iotClient))
|
||||
}
|
||||
subManager.Register(subsession.NewReviewProvider())
|
||||
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
|
||||
|
||||
// 构建新的 Orchestrator (v2.0)
|
||||
@@ -239,7 +240,7 @@ func loadConfig() Config {
|
||||
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
|
||||
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
|
||||
DatabaseURL: buildDatabaseURL(),
|
||||
IoTServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", ""),
|
||||
IoTServiceURL: getEnvWithFallback("IOT_SERVICE_URL", "IOT_DEBUG_SERVICE_URL", ""),
|
||||
AdminNickname: getEnv("ADMIN_NICKNAME", "管理员"),
|
||||
}
|
||||
}
|
||||
@@ -263,6 +264,17 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getEnvWithFallback 获取环境变量,优先使用 primaryKey,如果为空则回退到 fallbackKey
|
||||
func getEnvWithFallback(primaryKey, fallbackKey, defaultVal string) string {
|
||||
if v := os.Getenv(primaryKey); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := os.Getenv(fallbackKey); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
SubSessionGeneral SubSessionType = "general" // 通用对话子会话
|
||||
SubSessionKnowledge SubSessionType = "knowledge" // 知识库查询子会话 (预留)
|
||||
SubSessionWebSearch SubSessionType = "web_search" // 网络搜索子会话 (预留)
|
||||
SubSessionReview SubSessionType = "review" // 最终审查子会话
|
||||
)
|
||||
|
||||
// SubSessionStatus 子会话状态
|
||||
@@ -100,10 +101,11 @@ type MultiMessageItem struct {
|
||||
|
||||
// StreamEvent 流式事件
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType `json:"type"` // delta, segments, done, error
|
||||
Delta string `json:"delta,omitempty"` // 逐 token delta
|
||||
Segments []Segment `json:"segments,omitempty"` // 断句片段
|
||||
Error error `json:"-"` // 内部错误
|
||||
Type StreamEventType `json:"type"` // delta, segments, done, error, review
|
||||
Delta string `json:"delta,omitempty"` // 逐 token delta
|
||||
Segments []Segment `json:"segments,omitempty"` // 断句片段
|
||||
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的带类型消息
|
||||
Error error `json:"-"` // 内部错误
|
||||
}
|
||||
|
||||
// StreamEventType 流式事件类型
|
||||
@@ -114,8 +116,23 @@ const (
|
||||
StreamSegments StreamEventType = "segments"
|
||||
StreamDone StreamEventType = "done"
|
||||
StreamError StreamEventType = "error"
|
||||
StreamReview StreamEventType = "review" // 审查后的带类型消息
|
||||
)
|
||||
|
||||
// ReviewMessageType 审查消息类型
|
||||
type ReviewMessageType string
|
||||
|
||||
const (
|
||||
ReviewMessageAction ReviewMessageType = "action" // 动作消息 (括号内容)
|
||||
ReviewMessageChat ReviewMessageType = "chat" // 聊天消息 (引号/普通内容)
|
||||
)
|
||||
|
||||
// ReviewMessage 审查后的消息
|
||||
type ReviewMessage struct {
|
||||
Type ReviewMessageType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Segment 语音片段
|
||||
type Segment struct {
|
||||
Index int `json:"index"`
|
||||
|
||||
@@ -27,8 +27,21 @@ func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
|
||||
}
|
||||
|
||||
// Analyze 分析用户消息意图
|
||||
// 优先使用 LLM,失败时使用关键词规则降级
|
||||
// 优先使用 LLM,对于简单问候使用关键词快速通道(跳过 LLM 调用)
|
||||
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
|
||||
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
|
||||
if a.isSimpleGreeting(userMessage) {
|
||||
log.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
|
||||
result := &model.IntentResult{
|
||||
Primary: "greeting",
|
||||
NeedsMemory: false,
|
||||
NeedsIoT: false,
|
||||
Sentiment: "positive",
|
||||
Urgency: "low",
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 如果 LLM 不可用,直接使用关键词匹配
|
||||
if !a.enabled || a.llmAdapter == nil {
|
||||
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
|
||||
@@ -67,6 +80,45 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
// isSimpleGreeting 检测是否为简单问候/闲聊,无需复杂子会话分派
|
||||
func (a *IntentAnalyzer) isSimpleGreeting(userMessage string) bool {
|
||||
msgLower := strings.TrimSpace(strings.ToLower(userMessage))
|
||||
|
||||
// 精确匹配简单问候
|
||||
simpleGreetings := []string{
|
||||
"你好", "嗨", "嘿", "哈喽", "hello", "hi", "hey",
|
||||
"早上好", "下午好", "晚上好", "晚安", "早安", "午安",
|
||||
"在吗", "在不在", "在么", "在不",
|
||||
"谢谢", "多谢", "感谢", "thanks", "thank you",
|
||||
"好的", "ok", "okay", "行", "可以", "没问题",
|
||||
"再见", "拜拜", "bye", "byebye", "晚安",
|
||||
"嗯", "哦", "噢", "额",
|
||||
}
|
||||
|
||||
for _, g := range simpleGreetings {
|
||||
if msgLower == g {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检测极短消息(<=4个字符)且不包含IoT/问题关键词
|
||||
runes := []rune(msgLower)
|
||||
if len(runes) <= 4 {
|
||||
// 检查是否有明显需要处理的关键词
|
||||
complexKeywords := []string{"灯", "空调", "窗帘", "设备", "开关", "温度", "亮度",
|
||||
"什么", "怎么", "为什么", "如何", "谁", "哪里",
|
||||
"打开", "关闭", "调到", "设置", "帮我", "查"}
|
||||
for _, kw := range complexKeywords {
|
||||
if strings.Contains(msgLower, kw) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// keywordAnalyze 基于关键词的意图分析(降级方案)
|
||||
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
|
||||
result := &model.IntentResult{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||
@@ -67,12 +68,13 @@ type ProcessResult struct {
|
||||
|
||||
// ProcessInput 处理用户输入 — 新的主入口
|
||||
// 返回流式事件通道
|
||||
// v2.1: 支持非阻塞子会话分派 + 简单问候快速通道 + 审查子会话
|
||||
func (o *Orchestrator) ProcessInput(
|
||||
ctx context.Context,
|
||||
params ProcessParams,
|
||||
) (<-chan model.StreamEvent, error) {
|
||||
|
||||
eventCh := make(chan model.StreamEvent, 100)
|
||||
eventCh := make(chan model.StreamEvent, 200)
|
||||
|
||||
if params.Mode == "" {
|
||||
params.Mode = "text"
|
||||
@@ -87,6 +89,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
}()
|
||||
|
||||
// 1. 意图分析
|
||||
startTime := time.Now()
|
||||
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
|
||||
if err != nil || intent == nil {
|
||||
log.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
|
||||
@@ -97,6 +100,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
Urgency: "low",
|
||||
}
|
||||
}
|
||||
log.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
|
||||
|
||||
// 2. 加载人格配置
|
||||
personaConfig, err := o.personaLoader.Get("cyrene")
|
||||
@@ -114,7 +118,10 @@ func (o *Orchestrator) ProcessInput(
|
||||
userName = params.UserID
|
||||
}
|
||||
|
||||
// 3. 分派子会话(并行执行)
|
||||
// 注入 userID 到 context 供 MemoryProvider 使用
|
||||
subCtx := context.WithValue(ctx, "userID", params.UserID)
|
||||
|
||||
// 3. 分派子会话(并行执行,非阻塞:先启动合成再等待子会话结果)
|
||||
createParams := subsession.CreateContextParams{
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
@@ -124,43 +131,99 @@ func (o *Orchestrator) ProcessInput(
|
||||
Nickname: userName,
|
||||
}
|
||||
|
||||
// 注入 userID 到 context 供 MemoryProvider 使用
|
||||
subCtx := context.WithValue(ctx, "userID", params.UserID)
|
||||
|
||||
resultCh := o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
|
||||
// 4. 收集子会话结果
|
||||
var results []model.SubSessionResult
|
||||
for result := range resultCh {
|
||||
results = append(results, result)
|
||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting"
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单问候,跳过子会话分派")
|
||||
// 创建一个已关闭的空通道
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
} else {
|
||||
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] 子会话全部完成: 收集到 %d 个结果", len(results))
|
||||
|
||||
// 5. 汇总子会话结果
|
||||
agg := AggregateResults(results)
|
||||
|
||||
// 6. 构建对话历史
|
||||
// 4. 先构建基础综合参数(不含子会话结果),开始合成
|
||||
history := o.contextBuilder.GetHistory(params.SessionID, 20)
|
||||
|
||||
// 7. 构建完整人格提示词
|
||||
systemPrompt := personaConfig.BuildSystemPrompt(userName, 1)
|
||||
|
||||
// 8. 构建综合参数
|
||||
// 构建初始综合参数(无子会话结果)
|
||||
synthParams := SynthesizeParams{
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
UserMessage: params.Message,
|
||||
Nickname: userName,
|
||||
PersonaPrompt: systemPrompt,
|
||||
DialogHistory: history,
|
||||
MemorySummary: agg.MemorySummary,
|
||||
ThoughtOutline: agg.ThoughtOutline,
|
||||
IoTSummary: agg.IoTSummary,
|
||||
Mode: params.Mode,
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
UserMessage: params.Message,
|
||||
Nickname: userName,
|
||||
PersonaPrompt: systemPrompt,
|
||||
DialogHistory: history,
|
||||
Mode: params.Mode,
|
||||
}
|
||||
|
||||
// 9. 调用 Synthesizer 流式生成最终回复
|
||||
// 非阻塞收集子会话结果:使用 goroutine + channel
|
||||
// 主流程先开始 LLM 合成,子会话结果到达后再逐步注入
|
||||
type enrichedParams struct {
|
||||
memorySummary string
|
||||
thoughtOutline string
|
||||
iotSummary string
|
||||
}
|
||||
enrichedCh := make(chan enrichedParams, 1)
|
||||
|
||||
go func() {
|
||||
defer close(enrichedCh)
|
||||
var enriched enrichedParams
|
||||
|
||||
for result := range resultCh {
|
||||
if result.Error != "" {
|
||||
log.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case model.SubSessionMemory:
|
||||
enriched.memorySummary = result.Summary
|
||||
if result.Details != "" {
|
||||
enriched.memorySummary += "\n" + result.Details
|
||||
}
|
||||
log.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
|
||||
case model.SubSessionGeneral:
|
||||
enriched.thoughtOutline = result.Summary
|
||||
if result.Details != "" {
|
||||
enriched.thoughtOutline += "\n" + result.Details
|
||||
}
|
||||
log.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
|
||||
case model.SubSessionIoT:
|
||||
enriched.iotSummary = result.Summary
|
||||
log.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
enrichedCh <- enriched
|
||||
log.Printf("[orchestrator] 子会话全部完成: 结果已收集")
|
||||
}()
|
||||
|
||||
// 注入已到达的子会话结果(如果在合成开始前就有结果到达)
|
||||
// 启动合成(可能此时还没有子会话结果,先带着空上下文开始)
|
||||
select {
|
||||
case enriched := <-enrichedCh:
|
||||
synthParams.MemorySummary = enriched.memorySummary
|
||||
synthParams.ThoughtOutline = enriched.thoughtOutline
|
||||
synthParams.IoTSummary = enriched.iotSummary
|
||||
default:
|
||||
// 子会话结果还没完成,先带着空上下文开始合成
|
||||
// 大部分情况下子会话结果会在 LLM 调用前完成
|
||||
// 等待一小段时间让快速子会话(如 IoT)完成
|
||||
timeout := time.After(500 * time.Millisecond)
|
||||
select {
|
||||
case enriched := <-enrichedCh:
|
||||
synthParams.MemorySummary = enriched.memorySummary
|
||||
synthParams.ThoughtOutline = enriched.thoughtOutline
|
||||
synthParams.IoTSummary = enriched.iotSummary
|
||||
case <-timeout:
|
||||
log.Printf("[orchestrator] 子会话超时等待,以当前上下文开始合成")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 调用 Synthesizer 流式生成最终回复
|
||||
chunkCh, err := o.synthesizer.Synthesize(ctx, synthParams)
|
||||
if err != nil {
|
||||
log.Printf("[orchestrator] 综合器启动失败: %v", err)
|
||||
@@ -171,7 +234,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
return
|
||||
}
|
||||
|
||||
// 10. 流式输出 delta
|
||||
// 6. 流式输出 delta
|
||||
var fullContent string
|
||||
segmenter := llm.NewSegmenter()
|
||||
var segments []model.Segment
|
||||
@@ -215,7 +278,19 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 发送断句信息
|
||||
// 7. 审查完整回复文本,生成带类型的消息列表
|
||||
if fullContent != "" {
|
||||
reviewMessages := parseReviewMessages(fullContent)
|
||||
if len(reviewMessages) > 0 {
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamReview,
|
||||
ReviewMessages: reviewMessages,
|
||||
}
|
||||
log.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 发送断句信息
|
||||
if len(segments) > 0 {
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamSegments,
|
||||
@@ -223,17 +298,17 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 完成
|
||||
// 9. 完成
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamDone,
|
||||
}
|
||||
|
||||
// 13. 后处理:缓存回复
|
||||
// 10. 后处理:缓存回复
|
||||
if fullContent != "" {
|
||||
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
|
||||
}
|
||||
|
||||
// 14. 异步提取记忆
|
||||
// 11. 异步提取记忆
|
||||
if o.memoryExtractor != nil && fullContent != "" {
|
||||
go o.memoryExtractor.ExtractAndStore(
|
||||
context.Background(),
|
||||
@@ -244,13 +319,152 @@ func (o *Orchestrator) ProcessInput(
|
||||
)
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, sub_results=%d",
|
||||
intent.Primary, len(fullContent), len(results))
|
||||
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
|
||||
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
|
||||
}()
|
||||
|
||||
return eventCh, nil
|
||||
}
|
||||
|
||||
// parseReviewMessages 解析完整回复文本,拆分为带类型的消息
|
||||
// 用于审查子会话的轻量版本(内联到 orchestrator 以减少一次子会话调度开销)
|
||||
func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
|
||||
// 简单状态机:逐行或按括号匹配提取(使用 rune 切片正确处理 Unicode)
|
||||
remaining := text
|
||||
for len(remaining) > 0 {
|
||||
// 查找括号动作 (xxx)或 (xxx)
|
||||
actionStart := -1 // byte 位置
|
||||
actionEnd := -1 // byte 位置(括号之后)
|
||||
actionContent := ""
|
||||
|
||||
runes := []rune(remaining)
|
||||
for ri, r := range runes {
|
||||
if r == '(' || r == '(' {
|
||||
actionStart = len(string(runes[:ri]))
|
||||
closeRune := ')'
|
||||
if r == '(' {
|
||||
closeRune = ')'
|
||||
}
|
||||
// 查找匹配的闭合括号
|
||||
for rj := ri + 1; rj < len(runes); rj++ {
|
||||
if runes[rj] == closeRune {
|
||||
actionEnd = len(string(runes[:rj+1]))
|
||||
actionContent = string(runes[ri+1 : rj])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if actionStart >= 0 {
|
||||
// 括号前的普通文本
|
||||
if actionStart > 0 {
|
||||
prefix := strings.TrimSpace(remaining[:actionStart])
|
||||
if prefix != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, prefix)...)
|
||||
}
|
||||
}
|
||||
// 括号内作为 action
|
||||
content := strings.TrimSpace(actionContent)
|
||||
if content != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageAction,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
remaining = remaining[actionEnd:]
|
||||
} else {
|
||||
// 没有括号,剩余全部作为 chat
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 && text != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageChat,
|
||||
Content: strings.TrimSpace(text),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// splitReviewLongMessage 将长消息按句子边界拆分为多条短消息
|
||||
func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
const maxLen = 80 // 最大字符数(按 rune 计数)
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
|
||||
for start < len(runes) {
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
// 尝试在句子边界处分割
|
||||
if end < len(runes) {
|
||||
lastBreak := -1
|
||||
// 先找句号、感叹号、问号
|
||||
for i := end - 1; i >= start+maxLen/2; i-- {
|
||||
ch := runes[i]
|
||||
if ch == '。' || ch == '!' || ch == '?' || ch == '.' || ch == '!' || ch == '?' || ch == ';' || ch == ';' || ch == '\n' {
|
||||
lastBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 再找逗号
|
||||
if lastBreak < 0 {
|
||||
for i := end - 1; i >= start+maxLen/2; i-- {
|
||||
ch := runes[i]
|
||||
if ch == ',' || ch == ',' || ch == ' ' || ch == ' ' {
|
||||
lastBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastBreak > 0 {
|
||||
end = lastBreak + 1
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(string(runes[start:end]))
|
||||
if chunk != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
start = end
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// ProcessInputSync 同步处理用户输入(兼容旧接口)
|
||||
func (o *Orchestrator) ProcessInputSync(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -198,7 +198,10 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[iot-provider] 📥 开始处理 IoT 子会话: userMessage=%s", truncateStr(userMessage, 80))
|
||||
|
||||
if p.iotClient == nil {
|
||||
log.Printf("[iot-provider] ⚠️ IoT 客户端未配置,无法控制设备")
|
||||
result.Summary = "(IoT 客户端未配置,无法控制设备)"
|
||||
return result, nil
|
||||
}
|
||||
@@ -209,6 +212,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
|
||||
// 尝试获取设备列表进行匹配
|
||||
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
|
||||
|
||||
for _, dev := range devices {
|
||||
devNameLower := strings.ToLower(dev.Name)
|
||||
@@ -291,6 +295,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
||||
result.Summary = "(未匹配到 IoT 操作)"
|
||||
result.Confidence = 0.5
|
||||
return result, nil
|
||||
@@ -321,5 +326,14 @@ func acModeLabel(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// truncateStr 截断字符串用于日志
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
// Ensure json is used
|
||||
var _ = json.Marshal
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package subsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
)
|
||||
|
||||
// ReviewProvider 最终审查子会话提供者
|
||||
// 职责:解析编排器输出文本,将其拆分为带类型的消息(action/chat),
|
||||
// 分割长消息为短消息,输出格式化的消息列表供前端渲染。
|
||||
type ReviewProvider struct{}
|
||||
|
||||
// NewReviewProvider 创建审查子会话提供者
|
||||
func NewReviewProvider() *ReviewProvider {
|
||||
return &ReviewProvider{}
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Type() model.SubSessionType {
|
||||
return model.SubSessionReview
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) CanHandle(_ context.Context, _ *model.IntentResult, _ string) bool {
|
||||
// 审查提供者始终可用于处理综合后的文本
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Priority() int {
|
||||
return 1 // 最高优先级,最先处理输出
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Timeout() time.Duration {
|
||||
return 5 * time.Second // 审查很快,无需长时间
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) CreateContext(_ context.Context, params CreateContextParams) ([]model.LLMMessage, error) {
|
||||
// Review 不依赖 LLM 上下文,直接处理文本
|
||||
return []model.LLMMessage{
|
||||
{Role: model.RoleSystem, Content: "最终审查子会话 - 格式化输出"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Execute(_ context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error) {
|
||||
// 提取待审查的文本(从最后一条 user 消息中获取,由 Orchestrator 注入)
|
||||
text := ""
|
||||
for i := len(subCtx) - 1; i >= 0; i-- {
|
||||
if subCtx[i].Role == model.RoleUser {
|
||||
text = subCtx[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return &model.SubSessionResult{
|
||||
Type: model.SubSessionReview,
|
||||
Summary: "(无需审查,文本为空)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
reviewMessages := parseReviewText(text)
|
||||
|
||||
log.Printf("[review-provider] 审查完成: 输入 %d 字符 → %d 条消息",
|
||||
len([]rune(text)), len(reviewMessages))
|
||||
|
||||
// 构建摘要
|
||||
var parts []string
|
||||
for _, rm := range reviewMessages {
|
||||
typeLabel := "💬"
|
||||
if rm.Type == model.ReviewMessageAction {
|
||||
typeLabel = "⚡"
|
||||
}
|
||||
runes := []rune(rm.Content)
|
||||
preview := rm.Content
|
||||
if len(runes) > 30 {
|
||||
preview = string(runes[:30]) + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %s", typeLabel, preview))
|
||||
}
|
||||
|
||||
result := &model.SubSessionResult{
|
||||
Type: model.SubSessionReview,
|
||||
Summary: fmt.Sprintf("审查完成: %d 条消息", len(reviewMessages)),
|
||||
Details: strings.Join(parts, "\n"),
|
||||
Confidence: 0.95,
|
||||
Metadata: map[string]any{
|
||||
"review_messages": reviewMessages,
|
||||
},
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseReviewText 解析原始文本,提取带类型的消息
|
||||
// 规则:
|
||||
// - (xxx)或 (xxx) → action 类型消息
|
||||
// - "xxx" 或 "xxx" → chat 类型消息(提取引号内容)
|
||||
// - 普通文本 → chat 类型消息
|
||||
// - 长消息 (>80 字符) → 按句子边界拆分为多条
|
||||
func parseReviewText(text string) []model.ReviewMessage {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
|
||||
// 模式1: 匹配括号内容作为 action — (...)或 (...)
|
||||
actionPattern := regexp.MustCompile(`[((]([^))]+)[))]`)
|
||||
// 模式2: 匹配引号内容 — "..."
|
||||
quotePattern := regexp.MustCompile(`[""]([^""]+)[""]`)
|
||||
// 模式3: 匹配方括号动作 — 【...】
|
||||
bracketPattern := regexp.MustCompile(`【([^】]+)】`)
|
||||
|
||||
// 先收集所有匹配的位置
|
||||
type matchRange struct {
|
||||
start int
|
||||
end int
|
||||
typ model.ReviewMessageType
|
||||
text string
|
||||
}
|
||||
|
||||
var matches []matchRange
|
||||
|
||||
// 收集括号动作
|
||||
for _, m := range actionPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageAction,
|
||||
text: text[m[2]:m[3]], // 括号内文本
|
||||
})
|
||||
}
|
||||
|
||||
// 收集方括号动作
|
||||
for _, m := range bracketPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageAction,
|
||||
text: text[m[2]:m[3]],
|
||||
})
|
||||
}
|
||||
|
||||
// 收集引号内容
|
||||
for _, m := range quotePattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageChat,
|
||||
text: text[m[2]:m[3]],
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有匹配,整个文本作为 chat
|
||||
if len(matches) == 0 {
|
||||
return splitLongMessage(model.ReviewMessageChat, strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
// 简单排序(按出现顺序)
|
||||
for i := 0; i < len(matches); i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[i].start > matches[j].start {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理匹配之间的普通文本
|
||||
pos := 0
|
||||
for _, m := range matches {
|
||||
// 匹配前的普通文本
|
||||
if m.start > pos {
|
||||
plainText := strings.TrimSpace(text[pos:m.start])
|
||||
if plainText != "" {
|
||||
messages = append(messages, splitLongMessage(model.ReviewMessageChat, plainText)...)
|
||||
}
|
||||
}
|
||||
// 添加匹配项
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: m.typ,
|
||||
Content: strings.TrimSpace(m.text),
|
||||
})
|
||||
pos = m.end
|
||||
}
|
||||
|
||||
// 剩余文本
|
||||
if pos < len(text) {
|
||||
remaining := strings.TrimSpace(text[pos:])
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageChat,
|
||||
Content: strings.TrimSpace(text),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// splitLongMessage 将长消息按句子边界拆分为多条短消息
|
||||
func splitLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
const maxLen = 80 // 最大字符数(按 rune 计数)
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
|
||||
for start < len(runes) {
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
// 尝试在句子边界处分割
|
||||
chunk := string(runes[start:end])
|
||||
|
||||
// 如果这不是最后一个 chunk,在句子边界处切割
|
||||
if end < len(runes) {
|
||||
// 从后往前找最近的句子分隔符
|
||||
lastSentenceBreak := -1
|
||||
for i := len(chunk) - 1; i >= len(chunk)/2; i-- {
|
||||
ch := runes[start+i]
|
||||
if ch == '。' || ch == '!' || ch == '?' || ch == '.' || ch == '!' || ch == '?' || ch == ';' || ch == ';' || ch == '\n' {
|
||||
lastSentenceBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 如果没有找到句子分隔符,找逗号或空格
|
||||
if lastSentenceBreak < 0 {
|
||||
for i := len(chunk) - 1; i >= len(chunk)/2; i-- {
|
||||
ch := runes[start+i]
|
||||
if ch == ',' || ch == ',' || ch == ' ' || ch == ' ' {
|
||||
lastSentenceBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastSentenceBreak > 0 {
|
||||
chunk = string(runes[start : start+lastSentenceBreak+1])
|
||||
end = start + lastSentenceBreak + 1
|
||||
}
|
||||
}
|
||||
|
||||
chunk = strings.TrimSpace(chunk)
|
||||
if chunk != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
|
||||
start = end
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
@@ -45,7 +45,11 @@ type IoTClient struct {
|
||||
// NewIoTClient 创建 IoT 客户端
|
||||
func NewIoTClient(baseURL string) *IoTClient {
|
||||
if baseURL == "" {
|
||||
baseURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
||||
baseURL = getEnv("IOT_SERVICE_URL", "")
|
||||
if baseURL == "" {
|
||||
baseURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
}
|
||||
return &IoTClient{
|
||||
baseURL: baseURL,
|
||||
@@ -134,21 +138,27 @@ func (c *IoTClient) GetDevice(ctx context.Context, id string) (*IoTDevice, error
|
||||
|
||||
// ToggleDevice 切换设备开关状态
|
||||
func (c *IoTClient) ToggleDevice(id string) error {
|
||||
log.Printf("[IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建切换请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -157,21 +167,26 @@ func (c *IoTClient) ToggleDevice(id string) error {
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[IoT-client] ✅ 切换设备成功: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
|
||||
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
|
||||
log.Printf("[IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
|
||||
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"field": field,
|
||||
"value": value,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建设置请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -179,11 +194,13 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -192,8 +209,10 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp.Error != "" {
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
|
||||
}
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -202,6 +221,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,24 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
// 注意:JWT_SECRET 和 INTERNAL_SERVICE_TOKEN 必须在环境变量中设置,否则启动时 panic
|
||||
func Load() *Config {
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
panic("致命错误: 环境变量 JWT_SECRET 未设置,服务拒绝启动。请在 .env 文件中设置 JWT_SECRET。")
|
||||
}
|
||||
|
||||
internalServiceToken := os.Getenv("INTERNAL_SERVICE_TOKEN")
|
||||
if internalServiceToken == "" {
|
||||
panic("致命错误: 环境变量 INTERNAL_SERVICE_TOKEN 未设置,服务拒绝启动。请在 .env 文件中设置 INTERNAL_SERVICE_TOKEN。")
|
||||
}
|
||||
|
||||
// IoT 服务 URL:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL(向后兼容)
|
||||
iotServiceURL := os.Getenv("IOT_SERVICE_URL")
|
||||
if iotServiceURL == "" {
|
||||
iotServiceURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Env: getEnv("ENV", "development"),
|
||||
Port: getEnv("GATEWAY_PORT", "8080"),
|
||||
@@ -93,7 +110,7 @@ func Load() *Config {
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPass: getEnv("REDIS_PASSWORD", ""),
|
||||
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
JWTSecret: jwtSecret,
|
||||
JWTExpiryHours: time.Duration(getEnvInt("JWT_EXPIRY_HOURS", 720)) * time.Hour,
|
||||
|
||||
// 管理员账户 (开发阶段使用)
|
||||
@@ -108,7 +125,7 @@ func Load() *Config {
|
||||
|
||||
MemoryServiceURL: getEnv("MEMORY_SERVICE_URL", "http://localhost:8091"),
|
||||
|
||||
IoTDebugServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083"),
|
||||
IoTDebugServiceURL: iotServiceURL,
|
||||
|
||||
VoiceServiceURL: getEnv("VOICE_SERVICE_URL", "http://localhost:8093"),
|
||||
|
||||
@@ -122,7 +139,7 @@ func Load() *Config {
|
||||
SessionIdleTimeoutMin: getEnvInt("SESSION_IDLE_TIMEOUT_MIN", 30),
|
||||
|
||||
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
|
||||
InternalServiceToken: getEnv("INTERNAL_SERVICE_TOKEN", "cyrene-internal-token-change-me"),
|
||||
InternalServiceToken: internalServiceToken,
|
||||
|
||||
AllowedOrigins: parseAllowedOrigins(getEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5199,http://localhost:3000")),
|
||||
|
||||
@@ -140,10 +157,11 @@ func (c *Config) DatabaseURL() string {
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
// GenerateToken 生成JWT token (短期 access token)
|
||||
func (c *Config) GenerateToken(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"type": "access",
|
||||
"exp": time.Now().Add(c.JWTExpiryHours).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
@@ -151,6 +169,18 @@ func (c *Config) GenerateToken(userID string) (string, error) {
|
||||
return token.SignedString([]byte(c.JWTSecret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken 生成 refresh token (长期有效,30天)
|
||||
func (c *Config) GenerateRefreshToken(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"type": "refresh",
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30天
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(c.JWTSecret))
|
||||
}
|
||||
|
||||
// ValidateToken 验证JWT token
|
||||
func (c *Config) ValidateToken(tokenString string) (string, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -172,6 +202,33 @@ func (c *Config) ValidateToken(tokenString string) (string, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 验证 refresh token
|
||||
func (c *Config) ValidateRefreshToken(tokenString string) (string, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(c.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return "", jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
// 验证类型必须是 "refresh"
|
||||
tokenType, _ := claims["type"].(string)
|
||||
if tokenType != "refresh" {
|
||||
return "", fmt.Errorf("无效的刷新令牌类型")
|
||||
}
|
||||
|
||||
userID, _ := claims["user_id"].(string)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -108,11 +108,19 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 refresh_token (长期有效)
|
||||
refreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"nickname": req.Nickname,
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"refresh_token": refreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"nickname": req.Nickname,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,10 +194,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 refresh_token (长期有效)
|
||||
refreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"refresh_token": refreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,18 +235,38 @@ func (h *AuthHandler) verifyUserPassword(username, password string) (bool, error
|
||||
}
|
||||
|
||||
// RefreshToken 刷新令牌
|
||||
// 支持两种方式:
|
||||
// 1. 在 Authorization header 中传入有效的 access_token (可以已过期但 refresh_token 有效)
|
||||
// 2. 在请求体中传入 refresh_token
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || len(authHeader) < 8 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
|
||||
tokenString := authHeader[7:] // 去掉 "Bearer "
|
||||
userID, err := h.cfg.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
|
||||
return
|
||||
// 优先从请求体获取 refresh_token
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" {
|
||||
uid, err := h.cfg.ValidateRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
userID = uid
|
||||
} else {
|
||||
// 回退:从 Authorization header 获取 access_token 并验证
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || len(authHeader) < 8 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := authHeader[7:] // 去掉 "Bearer "
|
||||
uid, err := h.cfg.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
userID = uid
|
||||
}
|
||||
|
||||
newToken, err := h.cfg.GenerateToken(userID)
|
||||
@@ -239,8 +275,16 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新的 refresh_token
|
||||
newRefreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": newToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"token": newToken,
|
||||
"refresh_token": newRefreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,21 +17,24 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// ChatHandler 聊天处理器
|
||||
type ChatHandler struct {
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
upgrader websocket.Upgrader
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
sessionStore *store.SessionStore
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewChatHandler 创建聊天处理器
|
||||
func NewChatHandler(cfg *config.Config, hub *ws.Hub) *ChatHandler {
|
||||
func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.SessionStore) *ChatHandler {
|
||||
return &ChatHandler{
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
sessionStore: sessionStore,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
@@ -123,6 +126,13 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
mode = "text"
|
||||
}
|
||||
|
||||
// 持久化用户消息到数据库(在 WebSocket 发送之前)
|
||||
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
|
||||
if err := h.sessionStore.AddMessage(client.SessionID, "user", msg.Content); err != nil {
|
||||
log.Printf("[chat] 持久化用户消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录用户消息
|
||||
h.hub.RecordMessage(client.SessionID, "user", msg.Content)
|
||||
|
||||
@@ -359,8 +369,14 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// 缓存完整响应
|
||||
// 持久化 AI 回复到数据库(在 WebSocket 发送之前)
|
||||
if fullText != "" {
|
||||
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
|
||||
if err := h.sessionStore.AddMessage(client.SessionID, "assistant", fullText); err != nil {
|
||||
log.Printf("[chat] 持久化 AI 回复失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||
ID: msgID,
|
||||
Role: "assistant",
|
||||
|
||||
@@ -285,8 +285,22 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
offset := 0
|
||||
if o := c.Query("offset"); o != "" {
|
||||
parsed := 0
|
||||
for _, ch := range o {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed > 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if h.useDB {
|
||||
messages, err := h.store.GetMessages(sessionID, limit)
|
||||
messages, err := h.store.GetMessages(sessionID, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
@@ -536,7 +550,7 @@ func (h *SessionHandler) ExportSession(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 获取所有消息 (不限制数量,导出全部)
|
||||
messages, err := h.store.GetMessages(sessionID, 0)
|
||||
messages, err := h.store.GetMessages(sessionID, 0, 0)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
|
||||
@@ -27,7 +27,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
authHandler := handler.NewAuthHandler(cfg, authDB)
|
||||
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
||||
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
||||
chatHandler := handler.NewChatHandler(cfg, hub)
|
||||
chatHandler := handler.NewChatHandler(cfg, hub, sessionStore)
|
||||
webhookHandler := handler.NewWebhookHandler(cfg, hub)
|
||||
notificationHandler := handler.NewNotificationHandler(cfg, hub)
|
||||
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
|
||||
|
||||
@@ -211,18 +211,21 @@ func (s *SessionStore) AddMessage(sessionID, role, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages 获取会话的消息列表(按时间正序)
|
||||
func (s *SessionStore) GetMessages(sessionID string, limit int) ([]Message, error) {
|
||||
// GetMessages 获取会话的消息列表(按时间正序,支持分页)
|
||||
func (s *SessionStore) GetMessages(sessionID string, limit, offset int) ([]Message, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, session_id, role, content, created_at
|
||||
FROM messages WHERE session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
sessionID, limit,
|
||||
LIMIT $2 OFFSET $3`,
|
||||
sessionID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询消息失败: %w", err)
|
||||
|
||||
@@ -553,7 +553,11 @@ func (h *Hub) pollAndBroadcastIoT() {
|
||||
h.mu.RUnlock()
|
||||
|
||||
if url == "" {
|
||||
url = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
||||
url = getEnv("IOT_SERVICE_URL", "")
|
||||
if url == "" {
|
||||
url = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
}
|
||||
|
||||
devices, err := fetchIoTDevices(url)
|
||||
|
||||
@@ -14,9 +14,15 @@ type Config struct {
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
func Load() *Config {
|
||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
||||
iotURL := os.Getenv("IOT_SERVICE_URL")
|
||||
if iotURL == "" {
|
||||
iotURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8092"),
|
||||
IoTServiceURL: getEnv("IOT_SERVICE_URL", "http://localhost:8083"),
|
||||
IoTServiceURL: iotURL,
|
||||
DataDir: getEnv("DATA_DIR", "/tmp/cyrene_data"),
|
||||
DBUrl: getEnv("DB_URL", ""),
|
||||
}
|
||||
|
||||
@@ -102,21 +102,27 @@ func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
|
||||
|
||||
// ToggleDevice 切换设备开关状态
|
||||
func (c *IoTClient) ToggleDevice(id string) error {
|
||||
log.Printf("[tool-engine:IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
|
||||
if err != nil {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建切换请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -125,21 +131,26 @@ func (c *IoTClient) ToggleDevice(id string) error {
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[tool-engine:IoT-client] ✅ 切换设备成功: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
|
||||
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
|
||||
log.Printf("[tool-engine:IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
|
||||
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"field": field,
|
||||
"value": value,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
|
||||
if err != nil {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建设置请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -147,11 +158,13 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -160,8 +173,10 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp.Error != "" {
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
|
||||
}
|
||||
log.Printf("[tool-engine:IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -170,6 +185,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[tool-engine:IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Vendored
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round 11 API Contract Test - with rate-limit awareness"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
BASE = "http://localhost:8080/api/v1"
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
TOKEN = ""
|
||||
ADMIN_TOKEN = ""
|
||||
SESS_ID = ""
|
||||
|
||||
def req(method, path, data=None, auth=None, ct="application/json"):
|
||||
url = f"{BASE}{path}"
|
||||
headers = {"Content-Type": ct}
|
||||
if auth:
|
||||
headers["Authorization"] = f"Bearer {auth}"
|
||||
body = None
|
||||
if data is not None:
|
||||
body = json.dumps(data).encode()
|
||||
try:
|
||||
r = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
return resp.status, resp.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
def test(name, method, path, expected, data=None, auth=None, skip_msg=None):
|
||||
global PASS, FAIL
|
||||
if skip_msg:
|
||||
print(f" SKIP | {name}: {skip_msg}")
|
||||
return None, None
|
||||
code, body = req(method, path, data, auth)
|
||||
status = "PASS" if code == expected else "FAIL"
|
||||
if status == "PASS":
|
||||
PASS += 1
|
||||
else:
|
||||
FAIL += 1
|
||||
body_preview = body[:100].replace('\n',' ') if body else ""
|
||||
print(f" {status} | {name} | expected={expected} got={code} | {body_preview}")
|
||||
return code, body
|
||||
|
||||
print("=" * 60)
|
||||
print(" Round 11 API Contract Test Suite")
|
||||
print(f" Started at {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("=" * 60)
|
||||
|
||||
# === Part 1: Health ===
|
||||
print("\n--- Part 1: Health ---")
|
||||
test("GET /health", "GET", "/health", 200)
|
||||
test("HEAD /health", "HEAD", "/health", 200)
|
||||
|
||||
# === Part 2: Auth Register ===
|
||||
print("\n--- Part 2: Auth Register ---")
|
||||
UN = f"testapi_{int(time.time())}"
|
||||
PW = "TestPass123!"
|
||||
|
||||
test("Register missing username", "POST", "/auth/register", 400,
|
||||
{"password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
test("Register missing password", "POST", "/auth/register", 400,
|
||||
{"username":"testuser","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
test("Register username too short", "POST", "/auth/register", 400,
|
||||
{"username":"ab","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
test("Register username too long", "POST", "/auth/register", 400,
|
||||
{"username":"a"*33,"password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
test("Register username special chars", "POST", "/auth/register", 400,
|
||||
{"username":"user@name!","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
test("Register password too short", "POST", "/auth/register", 400,
|
||||
{"username":"test_abc","password":"Ab1!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
|
||||
time.sleep(1)
|
||||
|
||||
# Normal registration
|
||||
code, body = test("Register normal", "POST", "/auth/register", 201,
|
||||
{"username":UN,"password":PW,"email":"test@test.com","nickname":"TestUser","verify_code":"000000"})
|
||||
|
||||
time.sleep(1)
|
||||
# Duplicate
|
||||
test("Register duplicate", "POST", "/auth/register", 409,
|
||||
{"username":UN,"password":PW,"email":"test@test.com","nickname":"TestUser","verify_code":"000000"})
|
||||
|
||||
# === Part 3: Auth Login ===
|
||||
print("\n--- Part 3: Auth Login ---")
|
||||
time.sleep(1)
|
||||
test("Login wrong username", "POST", "/auth/login", 401,
|
||||
{"username":"nonexistent_user_99","password":"TestPass123!"})
|
||||
time.sleep(1)
|
||||
test("Login wrong password", "POST", "/auth/login", 401,
|
||||
{"username":UN,"password":"WrongPass999!"})
|
||||
time.sleep(1)
|
||||
|
||||
# Correct login
|
||||
code, body = req("POST", "/auth/login", {"username":UN,"password":PW})
|
||||
if code == 200:
|
||||
data = json.loads(body)
|
||||
TOKEN = data.get("token","")
|
||||
print(f" PASS | Login correct | 200 | user_id={data.get('user_id')} token={'OK' if TOKEN else 'MISSING'}")
|
||||
PASS += 1
|
||||
else:
|
||||
print(f" FAIL | Login correct | expected=200 got={code} | {body[:100]}")
|
||||
FAIL += 1
|
||||
|
||||
time.sleep(1)
|
||||
# Admin login
|
||||
code, body = req("POST", "/auth/login", {"username":"admin","password":"admin123"})
|
||||
if code == 200:
|
||||
data = json.loads(body)
|
||||
ADMIN_TOKEN = data.get("token","")
|
||||
print(f" PASS | Admin login | 200 | token={'OK' if ADMIN_TOKEN else 'MISSING'}")
|
||||
PASS += 1
|
||||
else:
|
||||
print(f" FAIL | Admin login | expected=200 got={code} | {body[:100]}")
|
||||
FAIL += 1
|
||||
|
||||
# JWT validation
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("JWT valid (refresh)", "POST", "/auth/refresh", 200, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
test("Login missing password", "POST", "/auth/login", 400, {"username":"testuser"})
|
||||
time.sleep(1)
|
||||
test("Login empty body", "POST", "/auth/login", 400, {})
|
||||
time.sleep(1)
|
||||
test("Login username format invalid", "POST", "/auth/login", 400, {"username":"ab","password":"Test123!"})
|
||||
|
||||
# === Part 4: Session API ===
|
||||
print("\n--- Part 4: Session API ---")
|
||||
test("Sessions list no auth", "GET", "/sessions", 401)
|
||||
test("Sessions create no auth", "POST", "/sessions", 401, {"title":"Test"})
|
||||
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Sessions list with auth", "GET", "/sessions", 200, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
code, body = test("Sessions create", "POST", "/sessions", 201, {"title":"Round 11 Test"}, auth=TOKEN)
|
||||
if code == 201:
|
||||
try:
|
||||
SESS_ID = json.loads(body).get("id","")
|
||||
except: pass
|
||||
|
||||
if SESS_ID:
|
||||
time.sleep(1)
|
||||
test("Sessions get existing", "GET", f"/sessions/{SESS_ID}", 200, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
test("Sessions get non-existent", "GET", "/sessions/session_nonexistent123", 404, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
test("Sessions create empty title", "POST", "/sessions", 201, {}, auth=TOKEN)
|
||||
|
||||
if SESS_ID:
|
||||
time.sleep(1)
|
||||
test("Sessions delete existing", "DELETE", f"/sessions/{SESS_ID}", 200, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
test("Sessions delete non-existent", "DELETE", "/sessions/session_nonexistent", 200, auth=TOKEN)
|
||||
|
||||
time.sleep(1)
|
||||
test("Messages get non-existent session", "GET", "/sessions/session_nonexistent/messages", 200, auth=TOKEN)
|
||||
else:
|
||||
print(" SKIP: No token available for session tests")
|
||||
|
||||
# === Part 5: Files ===
|
||||
print("\n--- Part 5: Files API ---")
|
||||
test("Files list no auth", "GET", "/files", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Files list with auth", "GET", "/files", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Files get non-existent", "GET", "/files/file_nonexistent", 404, auth=TOKEN)
|
||||
|
||||
# === Part 6: Knowledge ===
|
||||
print("\n--- Part 6: Knowledge API ---")
|
||||
test("Knowledge bases no auth", "GET", "/knowledge/bases", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Knowledge bases with auth", "GET", "/knowledge/bases", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Knowledge get non-existent", "GET", "/knowledge/bases/kb_nonexistent", 404, auth=TOKEN)
|
||||
|
||||
# === Part 7: Automation ===
|
||||
print("\n--- Part 7: Automation API ---")
|
||||
test("Automation rules no auth", "GET", "/automation/rules", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Automation rules with auth", "GET", "/automation/rules", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Automation scenes with auth", "GET", "/automation/scenes", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Automation get non-existent rule", "GET", "/automation/rules/rule_nonexistent", 404, auth=TOKEN)
|
||||
|
||||
# === Part 8: Reminders ===
|
||||
print("\n--- Part 8: Reminders API ---")
|
||||
test("Reminders list no auth", "GET", "/reminders", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Reminders list with auth (needs user_id)", "GET", "/reminders?user_id=test_user", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Reminders create missing fields", "POST", "/reminders", 400, {}, auth=TOKEN)
|
||||
|
||||
# === Part 9: Briefings ===
|
||||
print("\n--- Part 9: Briefings API ---")
|
||||
test("Briefings get no auth", "GET", "/briefings", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Briefings get with auth", "GET", "/briefings?user_id=test_user", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Briefings latest with auth", "GET", "/briefings/latest?user_id=test_user", 200, auth=TOKEN)
|
||||
|
||||
# === Part 10: Notifications ===
|
||||
print("\n--- Part 10: Notifications API ---")
|
||||
test("Notifications push no auth", "POST", "/notifications/push", 401, {"message":"test"})
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Notifications push empty", "POST", "/notifications/push", 400, {}, auth=TOKEN)
|
||||
|
||||
# === Part 11: Memories ===
|
||||
print("\n--- Part 11: Memories API ---")
|
||||
test("Memories search no auth", "GET", "/memory/search", 401)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Memories search with auth", "GET", "/memory/search?q=test", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Memories list with auth", "GET", "/memory", 200, auth=TOKEN)
|
||||
time.sleep(1)
|
||||
test("Memories add missing fields", "POST", "/memory", 400, {}, auth=TOKEN)
|
||||
|
||||
# === Part 12: Voice ===
|
||||
print("\n--- Part 12: Voice API ---")
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Voice status with auth", "GET", "/voice/status", 200, auth=TOKEN)
|
||||
|
||||
# === Part 13: Admin ===
|
||||
print("\n--- Part 13: Admin endpoints ---")
|
||||
if ADMIN_TOKEN:
|
||||
time.sleep(1)
|
||||
test("Admin sessions with admin", "GET", "/admin/sessions", 200, auth=ADMIN_TOKEN)
|
||||
time.sleep(1)
|
||||
test("Admin sessions/active", "GET", "/admin/sessions/active", 200, auth=ADMIN_TOKEN)
|
||||
if TOKEN:
|
||||
time.sleep(1)
|
||||
test("Admin sessions without admin", "GET", "/admin/sessions", 403, auth=TOKEN)
|
||||
|
||||
# === Part 14: Invalid token ===
|
||||
print("\n--- Part 14: Invalid token ---")
|
||||
test("Sessions invalid token", "GET", "/sessions", 401, auth="invalidtoken12345")
|
||||
test("Sessions expired/malformed token", "GET", "/sessions", 401, auth="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8k")
|
||||
|
||||
# === Part 15: Token refresh ===
|
||||
print("\n--- Part 15: Token refresh ---")
|
||||
test("Refresh no auth", "POST", "/auth/refresh", 401)
|
||||
test("Refresh invalid token", "POST", "/auth/refresh", 401, auth="invalidtoken")
|
||||
|
||||
# === Summary ===
|
||||
print("\n" + "=" * 60)
|
||||
print(" TEST SUMMARY")
|
||||
print("=" * 60)
|
||||
TOTAL = PASS + FAIL
|
||||
print(f"Total: {TOTAL} | Passed: {PASS} | Failed: {FAIL}")
|
||||
print(f"Success rate: {PASS/TOTAL*100:.1f}%" if TOTAL > 0 else "No tests run")
|
||||
print(f"\nTest user: {UN}")
|
||||
print(f"Token: {'available' if TOKEN else 'MISSING'}")
|
||||
print(f"Admin token: {'available' if ADMIN_TOKEN else 'MISSING'}")
|
||||
|
||||
# Save env
|
||||
if TOKEN:
|
||||
with open("/tmp/round11_testenv", "w") as f:
|
||||
f.write(f"TOKEN={TOKEN}\n")
|
||||
f.write(f"ADMIN_TOKEN={ADMIN_TOKEN}\n")
|
||||
f.write(f"TEST_USER={UN}\n")
|
||||
Vendored
+383
@@ -0,0 +1,383 @@
|
||||
#!/bin/bash
|
||||
# Round 11 API Contract Test Script
|
||||
# Tests all API endpoints for correctness, error handling, and boundary conditions
|
||||
|
||||
BASE="http://localhost:8080/api/v1"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RESULTS=""
|
||||
|
||||
# Helper: run a test and record result
|
||||
test_case() {
|
||||
local name="$1"
|
||||
local expected="$2"
|
||||
local method="$3"
|
||||
local url="$4"
|
||||
local data="$5"
|
||||
local auth="$6"
|
||||
|
||||
if [ -n "$data" ]; then
|
||||
resp=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
|
||||
-H "Content-Type: application/json" \
|
||||
${auth:+-H "Authorization: Bearer $auth"} \
|
||||
-d "$data" 2>&1)
|
||||
else
|
||||
resp=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
|
||||
-H "Content-Type: application/json" \
|
||||
${auth:+-H "Authorization: Bearer $auth"} 2>&1)
|
||||
fi
|
||||
|
||||
actual=$(echo "$resp" | tail -1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS+="PASS | $name | $expected | $actual\n"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS+="FAIL | $name | $expected | $actual | body=${body:0:120}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "============================================"
|
||||
echo " Round 11 API Contract Test Suite"
|
||||
echo " Gateway: $BASE"
|
||||
echo " Started at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# PART 1: Health + Public
|
||||
# ============================================================
|
||||
echo "--- Part 1: Health & Public Endpoints ---"
|
||||
|
||||
test_case "GET /health" "200" "GET" "$BASE/health"
|
||||
test_case "HEAD /health" "200" "HEAD" "$BASE/health"
|
||||
|
||||
# ============================================================
|
||||
# PART 2: Auth - Register
|
||||
# ============================================================
|
||||
echo "--- Part 2: Auth Register ---"
|
||||
|
||||
# Use unique username to avoid conflicts
|
||||
UN="testapi_$(date +%s)"
|
||||
PW="TestPass123!"
|
||||
|
||||
test_case "Register: missing username" "400" "POST" "$BASE/auth/register" \
|
||||
'{"password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: missing password" "400" "POST" "$BASE/auth/register" \
|
||||
'{"username":"testuser","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: username too short (<3)" "400" "POST" "$BASE/auth/register" \
|
||||
'{"username":"ab","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: username too long (>32)" "400" "POST" "$BASE/auth/register" \
|
||||
'{"username":"abcdefghijklmnopqrstuvwxyz1234567890","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: username with special chars" "400" "POST" "$BASE/auth/register" \
|
||||
'{"username":"user@name!","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: password too short (<6)" "400" "POST" "$BASE/auth/register" \
|
||||
'{"username":"test_abc","password":"Ab1!","email":"test@test.com","nickname":"Test","verify_code":"000000"}'
|
||||
|
||||
test_case "Register: normal registration" "201" "POST" "$BASE/auth/register" \
|
||||
"{\"username\":\"$UN\",\"password\":\"$PW\",\"email\":\"test@test.com\",\"nickname\":\"TestUser\",\"verify_code\":\"000000\"}"
|
||||
|
||||
# Try to register same username again
|
||||
test_case "Register: duplicate username" "409" "POST" "$BASE/auth/register" \
|
||||
"{\"username\":\"$UN\",\"password\":\"$PW\",\"email\":\"test@test.com\",\"nickname\":\"TestUser\",\"verify_code\":\"000000\"}"
|
||||
|
||||
# ============================================================
|
||||
# PART 3: Auth - Login
|
||||
# ============================================================
|
||||
echo "--- Part 3: Auth Login ---"
|
||||
|
||||
test_case "Login: wrong username" "401" "POST" "$BASE/auth/login" \
|
||||
'{"username":"nonexistent_user_99","password":"TestPass123!"}'
|
||||
|
||||
test_case "Login: wrong password" "401" "POST" "$BASE/auth/login" \
|
||||
"{\"username\":\"$UN\",\"password\":\"WrongPass999!\"}"
|
||||
|
||||
# Correct login
|
||||
LOGIN_RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$UN\",\"password\":\"$PW\"}")
|
||||
LOGIN_CODE=$(echo "$LOGIN_RESP" | tail -1)
|
||||
LOGIN_BODY=$(echo "$LOGIN_RESP" | sed '$d')
|
||||
|
||||
if [ "$LOGIN_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json;print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||
USER_ID=$(echo "$LOGIN_BODY" | python3 -c "import sys,json;print(json.load(sys.stdin).get('user_id',''))" 2>/dev/null)
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS+="PASS | Login: correct credentials | 200 | 200\n"
|
||||
echo " -> Got token OK, user_id=$USER_ID"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS+="FAIL | Login: correct credentials | 200 | $LOGIN_CODE | body=${LOGIN_BODY:0:120}\n"
|
||||
echo " -> Login FAILED: $LOGIN_CODE - $LOGIN_BODY"
|
||||
TOKEN=""
|
||||
fi
|
||||
|
||||
# Admin login
|
||||
ADMIN_RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}')
|
||||
ADMIN_CODE=$(echo "$ADMIN_RESP" | tail -1)
|
||||
ADMIN_BODY=$(echo "$ADMIN_RESP" | sed '$d')
|
||||
if [ "$ADMIN_CODE" = "200" ]; then
|
||||
ADMIN_TOKEN=$(echo "$ADMIN_BODY" | python3 -c "import sys,json;print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS+="PASS | Login: admin credentials | 200 | 200\n"
|
||||
echo " -> Admin token OK"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS+="FAIL | Login: admin credentials | 200 | $ADMIN_CODE | body=${ADMIN_BODY:0:120}\n"
|
||||
echo " -> Admin login FAILED: $ADMIN_CODE"
|
||||
ADMIN_TOKEN=""
|
||||
fi
|
||||
|
||||
# Validate JWT token - try to use it
|
||||
if [ -n "$TOKEN" ]; then
|
||||
REFRESH_RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/auth/refresh" \
|
||||
-H "Authorization: Bearer $TOKEN")
|
||||
REFRESH_CODE=$(echo "$REFRESH_RESP" | tail -1)
|
||||
if [ "$REFRESH_CODE" = "200" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS+="PASS | JWT token valid (refresh) | 200 | 200\n"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS+="FAIL | JWT token valid (refresh) | 200 | $REFRESH_CODE\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Login: missing fields
|
||||
test_case "Login: missing password" "400" "POST" "$BASE/auth/login" \
|
||||
'{"username":"testuser"}'
|
||||
|
||||
test_case "Login: empty body" "400" "POST" "$BASE/auth/login" '{}'
|
||||
|
||||
test_case "Login: username format invalid" "400" "POST" "$BASE/auth/login" \
|
||||
'{"username":"ab","password":"Test123!"}'
|
||||
|
||||
# ============================================================
|
||||
# PART 4: Session API
|
||||
# ============================================================
|
||||
echo "--- Part 4: Session API ---"
|
||||
|
||||
# Unauthenticated
|
||||
test_case "Sessions: list no auth" "401" "GET" "$BASE/sessions"
|
||||
test_case "Sessions: create no auth" "401" "POST" "$BASE/sessions" '{"title":"Test"}'
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
# Authenticated - List
|
||||
test_case "Sessions: list with auth" "200" "GET" "$BASE/sessions" "" "$TOKEN"
|
||||
|
||||
# Create session
|
||||
SESS_RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/sessions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"title":"Round 11 Test Session"}')
|
||||
SESS_CODE=$(echo "$SESS_RESP" | tail -1)
|
||||
SESS_BODY=$(echo "$SESS_RESP" | sed '$d')
|
||||
SESS_ID=$(echo "$SESS_BODY" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
|
||||
if [ "$SESS_CODE" = "201" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS+="PASS | Sessions: create | 201 | 201\n"
|
||||
echo " -> Created session: $SESS_ID"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS+="FAIL | Sessions: create | 201 | $SESS_CODE\n"
|
||||
fi
|
||||
|
||||
# Get session
|
||||
if [ -n "$SESS_ID" ]; then
|
||||
test_case "Sessions: get existing" "200" "GET" "$BASE/sessions/$SESS_ID" "" "$TOKEN"
|
||||
fi
|
||||
|
||||
# Get non-existent session
|
||||
test_case "Sessions: get non-existent" "404" "GET" "$BASE/sessions/session_nonexistent123" "" "$TOKEN"
|
||||
|
||||
# Create with empty title (should default)
|
||||
test_case "Sessions: create empty title" "201" "POST" "$BASE/sessions" '{}' "$TOKEN"
|
||||
|
||||
# Delete session
|
||||
if [ -n "$SESS_ID" ]; then
|
||||
test_case "Sessions: delete existing" "200" "DELETE" "$BASE/sessions/$SESS_ID" "" "$TOKEN"
|
||||
fi
|
||||
|
||||
# Delete non-existent
|
||||
test_case "Sessions: delete non-existent" "200" "DELETE" "$BASE/sessions/session_nonexistent" "" "$TOKEN"
|
||||
|
||||
# Get messages for non-existent session
|
||||
test_case "Sessions: messages non-existent" "200" "GET" "$BASE/sessions/session_nonexistent/messages" "" "$TOKEN"
|
||||
|
||||
# Test cross-user access
|
||||
if [ -n "$ADMIN_TOKEN" ] && [ -n "$SESS_ID" ]; then
|
||||
echo " -> Testing cross-user access..."
|
||||
fi
|
||||
else
|
||||
FAIL=$((FAIL + 6))
|
||||
echo " -> SKIPPED (no token)"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 5: Files API
|
||||
# ============================================================
|
||||
echo "--- Part 5: Files API ---"
|
||||
test_case "Files: list no auth" "401" "GET" "$BASE/files"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Files: list with auth" "200" "GET" "$BASE/files" "" "$TOKEN"
|
||||
test_case "Files: get non-existent" "404" "GET" "$BASE/files/file_nonexistent" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 2))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 6: Knowledge API
|
||||
# ============================================================
|
||||
echo "--- Part 6: Knowledge API ---"
|
||||
test_case "Knowledge: list bases no auth" "401" "GET" "$BASE/knowledge/bases"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Knowledge: list bases with auth" "200" "GET" "$BASE/knowledge/bases" "" "$TOKEN"
|
||||
test_case "Knowledge: get non-existent" "404" "GET" "$BASE/knowledge/bases/kb_nonexistent" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 2))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 7: Automation API
|
||||
# ============================================================
|
||||
echo "--- Part 7: Automation API ---"
|
||||
test_case "Automation: list rules no auth" "401" "GET" "$BASE/automation/rules"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Automation: list rules with auth" "200" "GET" "$BASE/automation/rules" "" "$TOKEN"
|
||||
test_case "Automation: list scenes with auth" "200" "GET" "$BASE/automation/scenes" "" "$TOKEN"
|
||||
test_case "Automation: get non-existent rule" "404" "GET" "$BASE/automation/rules/rule_nonexistent" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 3))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 8: Reminders API
|
||||
# ============================================================
|
||||
echo "--- Part 8: Reminders API ---"
|
||||
test_case "Reminders: list no auth" "401" "GET" "$BASE/reminders"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Reminders: list with auth" "200" "GET" "$BASE/reminders" "" "$TOKEN"
|
||||
test_case "Reminders: create missing fields" "400" "POST" "$BASE/reminders" '{}' "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 2))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 9: Briefings API
|
||||
# ============================================================
|
||||
echo "--- Part 9: Briefings API ---"
|
||||
test_case "Briefings: get no auth" "401" "GET" "$BASE/briefings"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Briefings: get with auth" "200" "GET" "$BASE/briefings" "" "$TOKEN"
|
||||
test_case "Briefings: latest with auth" "200" "GET" "$BASE/briefings/latest" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 2))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 10: Notifications API
|
||||
# ============================================================
|
||||
echo "--- Part 10: Notifications API ---"
|
||||
test_case "Notifications: push no auth" "401" "POST" "$BASE/notifications/push" '{"message":"test"}'
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Notifications: push empty" "400" "POST" "$BASE/notifications/push" '{}' "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 11: Memories API
|
||||
# ============================================================
|
||||
echo "--- Part 11: Memories API ---"
|
||||
test_case "Memories: search no auth" "401" "GET" "$BASE/memory/search"
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Memories: search with auth" "200" "GET" "$BASE/memory/search?q=test" "" "$TOKEN"
|
||||
test_case "Memories: list with auth" "200" "GET" "$BASE/memory" "" "$TOKEN"
|
||||
test_case "Memories: add missing fields" "400" "POST" "$BASE/memory" '{}' "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 3))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 12: Voice API
|
||||
# ============================================================
|
||||
echo "--- Part 12: Voice API ---"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Voice: status with auth" "200" "GET" "$BASE/voice/status" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 13: Admin endpoints
|
||||
# ============================================================
|
||||
echo "--- Part 13: Admin endpoints ---"
|
||||
if [ -n "$ADMIN_TOKEN" ]; then
|
||||
test_case "Admin: sessions with admin" "200" "GET" "$BASE/admin/sessions" "" "$ADMIN_TOKEN"
|
||||
test_case "Admin: sessions/active with admin" "200" "GET" "$BASE/admin/sessions/active" "" "$ADMIN_TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 2))
|
||||
fi
|
||||
|
||||
# Non-admin user trying admin
|
||||
if [ -n "$TOKEN" ]; then
|
||||
test_case "Admin: sessions without admin" "403" "GET" "$BASE/admin/sessions" "" "$TOKEN"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PART 14: Invalid token
|
||||
# ============================================================
|
||||
echo "--- Part 14: Invalid Token ---"
|
||||
test_case "Sessions: invalid token" "401" "GET" "$BASE/sessions" "" "invalidtoken12345"
|
||||
test_case "Sessions: expired/malformed" "401" "GET" "$BASE/sessions" "" "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8k"
|
||||
|
||||
# ============================================================
|
||||
# PART 15: Token refresh
|
||||
# ============================================================
|
||||
echo "--- Part 15: Token Refresh ---"
|
||||
test_case "Refresh: no auth" "401" "POST" "$BASE/auth/refresh"
|
||||
test_case "Refresh: invalid token" "401" "POST" "$BASE/auth/refresh" "" "invalidtoken"
|
||||
|
||||
# ============================================================
|
||||
# Summary
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " TEST SUMMARY"
|
||||
echo "============================================"
|
||||
TOTAL=$((PASS + FAIL))
|
||||
echo "Total: $TOTAL | Passed: $PASS | Failed: $FAIL"
|
||||
echo ""
|
||||
|
||||
echo -e "$RESULTS" | column -t -s '|'
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Test run completed at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "============================================"
|
||||
|
||||
# Save the token and user info for reference
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo "TOKEN=$TOKEN" > /tmp/round11_testenv
|
||||
echo "USER_ID=$USER_ID" >> /tmp/round11_testenv
|
||||
echo "ADMIN_TOKEN=$ADMIN_TOKEN" >> /tmp/round11_testenv
|
||||
echo "TEST_USER=$UN" >> /tmp/round11_testenv
|
||||
fi
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick CDP check: read root innerHTML"""
|
||||
import json
|
||||
import urllib.request
|
||||
from websocket import create_connection
|
||||
|
||||
req = urllib.request.Request('http://127.0.0.1:9225/json/list')
|
||||
resp = json.loads(urllib.request.urlopen(req, timeout=5))
|
||||
ws_url = None
|
||||
for p in resp:
|
||||
if 'localhost:5199' in p.get('url', ''):
|
||||
ws_url = p['webSocketDebuggerUrl']
|
||||
break
|
||||
|
||||
if not ws_url:
|
||||
print("No page found!")
|
||||
exit(1)
|
||||
|
||||
ws = create_connection(ws_url, timeout=10)
|
||||
|
||||
def cdp(method, params, msg_id):
|
||||
payload = json.dumps({"id": msg_id, "method": method, "params": params})
|
||||
ws.send(payload)
|
||||
|
||||
def recv_id(tid, timeout=3):
|
||||
ws.settimeout(timeout)
|
||||
while True:
|
||||
try:
|
||||
m = json.loads(ws.recv())
|
||||
if m.get("id") == tid:
|
||||
return m.get("result", {})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
cdp("Runtime.enable", {}, 1)
|
||||
recv_id(1)
|
||||
|
||||
# Get root innerHTML
|
||||
cdp("Runtime.evaluate", {"expression": "document.getElementById('root').innerHTML.substring(0, 1000)", "returnByValue": True}, 10)
|
||||
r = recv_id(10)
|
||||
if r:
|
||||
html = r.get("result", {}).get("value", "null")
|
||||
print("=== Root innerHTML (前1000字符) ===")
|
||||
print(html)
|
||||
else:
|
||||
print("No result")
|
||||
|
||||
# Root child count
|
||||
cdp("Runtime.evaluate", {"expression": "document.getElementById('root').children.length", "returnByValue": True}, 11)
|
||||
r = recv_id(11)
|
||||
val = r.get("result", {}).get("value", "?") if r else "?"
|
||||
print("\nRoot children:", val)
|
||||
|
||||
ws.close()
|
||||
Vendored
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP 前端页面验证:截图 + 控制台错误 + 登录测试"""
|
||||
import json, time, base64, os
|
||||
from websocket import create_connection
|
||||
|
||||
PAGE_URL = "http://localhost:5199/"
|
||||
CDP_WS = "ws://127.0.0.1:9225/devtools/browser/b2fca0da-35d6-4180-8413-eddf53753c6a"
|
||||
|
||||
def send_cmd(ws, method, params=None, msg_id=1):
|
||||
payload = json.dumps({"id": msg_id, "method": method, "params": params or {}})
|
||||
ws.send(payload)
|
||||
|
||||
def recv_until(ws, timeout=5):
|
||||
ws.settimeout(timeout)
|
||||
results = []
|
||||
try:
|
||||
while True:
|
||||
data = ws.recv()
|
||||
results.append(data)
|
||||
except:
|
||||
pass
|
||||
return results
|
||||
|
||||
def find_event(msgs, method):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get("method") == method:
|
||||
return d
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def find_result(msgs, msg_id):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get("id") == msg_id:
|
||||
return d
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
print("=== CDP 前端页面验证 ===")
|
||||
|
||||
# 1. 连接到浏览器
|
||||
ws = create_connection(CDP_WS, timeout=10)
|
||||
print(f" Connected to CDP")
|
||||
|
||||
# 2. 获取已有页面的 targetId (Navigated page)
|
||||
pages_resp = os.popen('curl -s http://127.0.0.1:9225/json').read()
|
||||
pages = json.loads(pages_resp)
|
||||
target_id = None
|
||||
for p in pages:
|
||||
if p.get("url","").startswith("http://localhost:5199"):
|
||||
target_id = p["id"]
|
||||
print(f" Found page: {p['title'][:80]} id={target_id}")
|
||||
break
|
||||
|
||||
if not target_id:
|
||||
print(" No existing page found, creating new one...")
|
||||
send_cmd(ws, "Target.createTarget", {"url": PAGE_URL})
|
||||
results = recv_until(ws, 3)
|
||||
print(f" Target.createTarget results: {results}")
|
||||
|
||||
# 3. 连接到页面 target (通过 Target.attachToTarget)
|
||||
print(f"\n Attaching to target {target_id}...")
|
||||
send_cmd(ws, "Target.attachToTarget", {"targetId": target_id, "flatten": True}, 1)
|
||||
results = recv_until(ws, 3)
|
||||
result = find_result(results, 1)
|
||||
if result:
|
||||
session_id = result.get("result",{}).get("sessionId","")
|
||||
print(f" Attached, sessionId={session_id[:20]}...")
|
||||
else:
|
||||
print(f" Attach failed: {results}")
|
||||
ws.close()
|
||||
exit(1)
|
||||
|
||||
# 4. 启用 Runtime 和 Console
|
||||
print("\n--- Enabling domains ---")
|
||||
for method, sid, mid in [("Runtime.enable", session_id, 2), ("Page.enable", session_id, 3), ("Log.enable", session_id, 4)]:
|
||||
payload = json.dumps({"id": mid, "method": method, "params": {}, "sessionId": session_id})
|
||||
ws.send(payload)
|
||||
results = recv_until(ws, 3)
|
||||
print(f" Enable results: {len(results)} messages")
|
||||
|
||||
# 5. 截图
|
||||
print("\n--- Taking screenshot ---")
|
||||
send_cmd(ws, "Page.captureScreenshot", {"format": "png"}, 10)
|
||||
# send with sessionId
|
||||
payload = json.dumps({"id": 5, "method": "Page.captureScreenshot", "params": {"format": "png"}, "sessionId": session_id})
|
||||
ws.send(payload)
|
||||
results = recv_until(ws, 5)
|
||||
screen_result = find_result(results, 5)
|
||||
if screen_result:
|
||||
img_data = screen_result.get("result",{}).get("data","")
|
||||
if img_data:
|
||||
img_bytes = base64.b64decode(img_data)
|
||||
img_path = "/tmp/cyrene_screenshot_round12.png"
|
||||
with open(img_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
print(f" Screenshot saved: {img_path} ({len(img_bytes)} bytes)")
|
||||
else:
|
||||
print(f" No image data: {str(screen_result)[:200]}")
|
||||
else:
|
||||
print(f" Screenshot failed: {results}")
|
||||
|
||||
# 6. 获取控制台日志
|
||||
print("\n--- Console logs ---")
|
||||
ws.settimeout(2)
|
||||
console_logs = []
|
||||
for i in range(15): # 最多等15秒
|
||||
try:
|
||||
data = ws.recv()
|
||||
try:
|
||||
d = json.loads(data)
|
||||
if d.get("method") in ("Runtime.consoleAPICalled", "Log.entryAdded"):
|
||||
entry = d.get("params",{}).get("entry",{}) if d["method"]=="Log.entryAdded" else d.get("params",{})
|
||||
level = entry.get("level","log") if d["method"]=="Log.entryAdded" else d["params"].get("type","log")
|
||||
text = entry.get("text","") if d["method"]=="Log.entryAdded" else " ".join([a.get("value","") for a in d["params"].get("args",[])])
|
||||
url = entry.get("url","") if d["method"]=="Log.entryAdded" else ""
|
||||
console_logs.append(f"[{level}] {text[:200]} {url}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
break
|
||||
|
||||
if console_logs:
|
||||
for l in console_logs:
|
||||
print(f" {l}")
|
||||
else:
|
||||
print(" No console messages captured. Let me poll...")
|
||||
|
||||
# 7. 使用 Runtime.evaluate 获取页面状态
|
||||
print("\n--- Evaluating page state ---")
|
||||
checks = [
|
||||
('document.title', "document.title"),
|
||||
('document.readyState', "document.readyState"),
|
||||
('Root element exists', "document.getElementById('root') ? 'yes' : 'no'"),
|
||||
('Body children count', "document.body ? document.body.children.length : -1"),
|
||||
('Has login form?', "document.querySelector('form') ? 'yes' : 'no'"),
|
||||
('Has error boundary?', "document.querySelector('[class*=error]') ? 'yes' : 'no'"),
|
||||
('Window errors', "window.__LAST_ERROR__ || 'none'"),
|
||||
]
|
||||
for label, expr in checks:
|
||||
payload = json.dumps({
|
||||
"id": 100 + len(console_logs),
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {"expression": expr, "returnByValue": True},
|
||||
"sessionId": session_id
|
||||
})
|
||||
ws.send(payload)
|
||||
results = recv_until(ws, 2)
|
||||
r = find_result(results, 100 + len(console_logs))
|
||||
val = r.get("result",{}).get("result",{}).get("value","?") if r else "?"
|
||||
print(f" {label}: {val}")
|
||||
|
||||
# 8. 测试登录流程 (登录到 localhost:5199)
|
||||
print("\n--- Testing login flow ---")
|
||||
TOKEN = open("/tmp/cyrene_test_token.txt").read().strip()
|
||||
login_js = f"""
|
||||
(async function() {{
|
||||
try {{
|
||||
const resp = await fetch('http://localhost:8080/api/v1/auth/login', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{username:'yeij0942',password:'Jiang1143218570'}})
|
||||
}});
|
||||
const data = await resp.json();
|
||||
return JSON.stringify({{status: resp.status, hasToken: !!data.token, userId: data.user_id}});
|
||||
}} catch(e) {{
|
||||
return 'Error: ' + e.message;
|
||||
}}
|
||||
}})()
|
||||
"""
|
||||
payload = json.dumps({
|
||||
"id": 200,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {"expression": login_js, "returnByValue": True, "awaitPromise": True},
|
||||
"sessionId": session_id
|
||||
})
|
||||
ws.send(payload)
|
||||
results = recv_until(ws, 5)
|
||||
r = find_result(results, 200)
|
||||
if r:
|
||||
val = r.get("result",{}).get("result",{}).get("value","?")
|
||||
print(f" Login test result: {val}")
|
||||
else:
|
||||
print(f" Login test failed: {results}")
|
||||
|
||||
# 9. 页面上 JavaScript 错误检测
|
||||
print("\n--- Checking for JS errors via Runtime.exceptionThrown ---")
|
||||
ws.settimeout(1)
|
||||
error_msgs = []
|
||||
for i in range(5):
|
||||
try:
|
||||
data = ws.recv()
|
||||
d = json.loads(data)
|
||||
if d.get("method") == "Runtime.exceptionThrown":
|
||||
exc = d.get("params",{}).get("exceptionDetails",{})
|
||||
error_msgs.append(f" ERROR: {exc.get('text','')} at {exc.get('url','')}:{exc.get('lineNumber','')}")
|
||||
except:
|
||||
break
|
||||
|
||||
if error_msgs:
|
||||
for e in error_msgs:
|
||||
print(e)
|
||||
else:
|
||||
print(" No JS exceptions detected")
|
||||
|
||||
ws.close()
|
||||
print("\n[DONE]")
|
||||
Vendored
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP 前端验证:截图 + 控制台检查 + 页面状态"""
|
||||
import json, time, base64, urllib.request, os
|
||||
|
||||
# Step 1: 导航到前端页面 (PUT /json/new)
|
||||
print("=== CDP 前端验证 ===")
|
||||
print("Navigating to page...")
|
||||
req = urllib.request.Request("http://127.0.0.1:9225/json/new?url=http://localhost:5199/", method="PUT")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
page_data = json.loads(resp.read())
|
||||
page_id = page_data.get("id","")
|
||||
ws_url = page_data.get("webSocketDebuggerUrl","")
|
||||
print(f" Page: id={page_id} title={page_data.get('title','')[:80]}")
|
||||
print(f" WS_URL: {ws_url[:80]}")
|
||||
except Exception as e:
|
||||
print(f" Navigation failed: {e}")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
# Step 2: 连接到页面 WebSocket
|
||||
from websocket import create_connection
|
||||
ws = create_connection(ws_url, timeout=10)
|
||||
print(" Connected to page WebSocket")
|
||||
|
||||
def cdp(method, params=None, msg_id=1):
|
||||
payload = json.dumps({"id": msg_id, "method": method, "params": params or {}})
|
||||
ws.send(payload)
|
||||
|
||||
def recv_msgs(timeout=3):
|
||||
ws.settimeout(timeout)
|
||||
msgs = []
|
||||
try:
|
||||
while True:
|
||||
msgs.append(ws.recv())
|
||||
except:
|
||||
pass
|
||||
return msgs
|
||||
|
||||
def find_result(msgs, msg_id):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get("id") == msg_id:
|
||||
return d.get("result",{})
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Step 3: 启用 domains
|
||||
print("\nEnabling domains...")
|
||||
cdp("Runtime.enable", {}, 1)
|
||||
cdp("Page.enable", {}, 2)
|
||||
cdp("Log.enable", {}, 3)
|
||||
recv_msgs(2)
|
||||
print(" Domains enabled")
|
||||
|
||||
# Step 4: 等待页面完全加载后截图
|
||||
print("\nWaiting for page to load...")
|
||||
time.sleep(3)
|
||||
cdp("Page.captureScreenshot", {"format": "png"}, 10)
|
||||
msgs = recv_msgs(5)
|
||||
r = find_result(msgs, 10)
|
||||
if r and r.get("data"):
|
||||
img = base64.b64decode(r["data"])
|
||||
with open("/tmp/cyrene_screenshot_round12.png", "wb") as f:
|
||||
f.write(img)
|
||||
print(f" Screenshot saved: {len(img)} bytes")
|
||||
else:
|
||||
print(f" Screenshot failed: {str(r)[:200]}")
|
||||
|
||||
# Step 5: 获取控制台日志
|
||||
print("\nConsole messages (Log.entryAdded):")
|
||||
ws.settimeout(2)
|
||||
logs = []
|
||||
for i in range(15):
|
||||
try:
|
||||
data = ws.recv()
|
||||
d = json.loads(data)
|
||||
if d.get("method") == "Log.entryAdded":
|
||||
entry = d["params"]["entry"]
|
||||
level = entry.get("level","log")
|
||||
text = entry.get("text","")
|
||||
url = entry.get("url","")
|
||||
logs.append(f" [{level}] {text[:200]} ({url})")
|
||||
elif d.get("method") == "Runtime.exceptionThrown":
|
||||
exc = d["params"].get("exceptionDetails",{})
|
||||
logs.append(f" [EXCEPTION] {exc.get('text','')}")
|
||||
except:
|
||||
break
|
||||
|
||||
if logs:
|
||||
for l in logs:
|
||||
print(l)
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
# Step 6: 页面状态检查
|
||||
print("\nPage state:")
|
||||
checks = [
|
||||
("title", "document.title"),
|
||||
("readyState", "document.readyState"),
|
||||
("root exists?", "document.getElementById('root') ? 'yes' : 'no'"),
|
||||
("body children", "document.body ? document.body.children.length : -1"),
|
||||
("login form?", "document.querySelector('form') ? 'yes' : 'no'"),
|
||||
("all text content (first 300)", "document.body ? (document.body.innerText || '').substring(0, 300) : 'no body'"),
|
||||
]
|
||||
for idx, (label, expr) in enumerate(checks):
|
||||
cdp("Runtime.evaluate", {"expression": expr, "returnByValue": True}, 100 + idx)
|
||||
msgs = recv_msgs(2)
|
||||
r = find_result(msgs, 100 + idx)
|
||||
val = r.get("result",{}).get("value","?") if r else "?"
|
||||
print(f" {label}: {val}")
|
||||
|
||||
# Step 7: 前端 JS 错误检测
|
||||
print("\nJS Errors (Runtime.exceptionThrown):")
|
||||
ws.settimeout(1)
|
||||
errors = []
|
||||
for i in range(5):
|
||||
try:
|
||||
d = json.loads(ws.recv())
|
||||
if d.get("method") == "Runtime.exceptionThrown":
|
||||
exc = d["params"]["exceptionDetails"]
|
||||
errors.append(f" {exc.get('text','')} at {exc.get('url','')}:{exc.get('lineNumber','')}")
|
||||
except:
|
||||
break
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(e)
|
||||
else:
|
||||
print(" No JS exceptions detected")
|
||||
|
||||
ws.close()
|
||||
print("\n[DONE]")
|
||||
Vendored
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP v3: 深度诊断前端白屏问题 — 检查 DOM、网络、JS 模块加载"""
|
||||
import json, time, base64, urllib.request, os
|
||||
|
||||
# Step 1: 打开新页面
|
||||
print("=== Step 1: 导航到前端页面 ===")
|
||||
req = urllib.request.Request("http://127.0.0.1:9225/json/new?url=http://localhost:5199/", method="PUT")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
page_data = json.loads(resp.read())
|
||||
page_id = page_data.get("id","")
|
||||
ws_url = page_data.get("webSocketDebuggerUrl","")
|
||||
print(f" Page ID: {page_id}")
|
||||
print(f" WS URL: {ws_url[:80]}")
|
||||
|
||||
from websocket import create_connection
|
||||
ws = create_connection(ws_url, timeout=10)
|
||||
|
||||
def cdp(method, params=None, msg_id=1):
|
||||
ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
|
||||
|
||||
def recv_all(timeout=3):
|
||||
ws.settimeout(timeout)
|
||||
msgs = []
|
||||
try:
|
||||
while True:
|
||||
msgs.append(ws.recv())
|
||||
except:
|
||||
pass
|
||||
return msgs
|
||||
|
||||
def find_result(msgs, msg_id):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get("id") == msg_id:
|
||||
return d.get("result",{})
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Step 2: 启用所有 domains
|
||||
print("\n=== Step 2: 启用所有 domains ===")
|
||||
cdp("Page.enable", {}, 1)
|
||||
cdp("Network.enable", {}, 2)
|
||||
cdp("Runtime.enable", {}, 3)
|
||||
cdp("Log.enable", {}, 4)
|
||||
cdp("DOM.enable", {}, 5)
|
||||
recv_all(1)
|
||||
|
||||
# Step 3: 重新 navigate 以捕获网络事件
|
||||
print("\n=== Step 3: 重新导航 ===")
|
||||
cdp("Page.navigate", {"url": "http://localhost:5199/"}, 10)
|
||||
time.sleep(3)
|
||||
|
||||
# 收集在此期间的所有消息
|
||||
print(" 收集网络/运行时事件...")
|
||||
all_msgs = recv_all(5)
|
||||
|
||||
# 分析网络事件
|
||||
print("\n=== Step 4: 网络请求分析 ===")
|
||||
requests = {}
|
||||
for m in all_msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
except:
|
||||
continue
|
||||
method = d.get("method","")
|
||||
params = d.get("params",{})
|
||||
|
||||
if method == "Network.requestWillBeSent":
|
||||
req_info = params.get("request", {})
|
||||
url = req_info.get("url","")
|
||||
req_id = params.get("requestId","")
|
||||
rtype = params.get("type","")
|
||||
requests[req_id] = {"url": url, "type": rtype, "status": "pending"}
|
||||
|
||||
elif method == "Network.responseReceived":
|
||||
req_id = params.get("requestId","")
|
||||
resp = params.get("response",{})
|
||||
status = resp.get("status",0)
|
||||
mime = resp.get("mimeType","")
|
||||
if req_id in requests:
|
||||
requests[req_id]["status"] = status
|
||||
requests[req_id]["mime"] = mime
|
||||
|
||||
elif method == "Network.loadingFailed":
|
||||
req_id = params.get("requestId","")
|
||||
err = params.get("errorText","")
|
||||
if req_id in requests:
|
||||
requests[req_id]["status"] = "FAILED"
|
||||
requests[req_id]["error"] = err
|
||||
|
||||
elif method == "Runtime.exceptionThrown":
|
||||
exc = params.get("exceptionDetails",{})
|
||||
print(f" [EXCEPTION] {exc.get('text','')} at {exc.get('url','')}:{exc.get('lineNumber','')}")
|
||||
|
||||
elif method == "Log.entryAdded":
|
||||
entry = params.get("entry",{})
|
||||
print(f" [CONSOLE:{entry.get('level','log')}] {entry.get('text','')[:200]}")
|
||||
|
||||
elif method == "Runtime.consoleAPICalled":
|
||||
args = params.get("args",[])
|
||||
msg_type = params.get("type","log")
|
||||
texts = []
|
||||
for a in args:
|
||||
t = a.get("value","") or a.get("description","")
|
||||
texts.append(str(t)[:150])
|
||||
print(f" [CONSOLE:{msg_type}] {' '.join(texts)}")
|
||||
|
||||
print(f"\n Total network requests: {len(requests)}")
|
||||
for rid, info in requests.items():
|
||||
status_str = str(info.get("status","?"))
|
||||
type_str = info.get("type","?")
|
||||
url_short = info.get("url","")[-80:]
|
||||
error_str = f" ERROR={info.get('error','')}" if info.get("error") else ""
|
||||
print(f" [{type_str}] {status_str} {url_short}{error_str}")
|
||||
|
||||
# Step 5: 运行时评估 - 深度检查
|
||||
print("\n=== Step 5: 运行时深度评估 ===")
|
||||
checks = [
|
||||
("window.location.href", "window.location.href"),
|
||||
("document.readyState", "document.readyState"),
|
||||
("document.title", "document.title"),
|
||||
("root element", "document.getElementById('root') ? 'EXISTS' : 'NULL'"),
|
||||
("body innerHTML length", "document.body ? document.body.innerHTML.length : -1"),
|
||||
("body children count", "document.body ? document.body.children.length : -1"),
|
||||
("head children count", "document.head ? document.head.children.length : -1"),
|
||||
("all scripts", "Array.from(document.querySelectorAll('script')).map(s => ({src:s.src,type:s.type,async:s.async,defer:s.defer})).slice(0,5)"),
|
||||
("React DOM check", "typeof React !== 'undefined' ? 'React global found' : (document.querySelector('#root') ? 'root exists but no React global' : 'root missing')"),
|
||||
("SW registration check", "'serviceWorker' in navigator ? 'SW API available' : 'NO SW API'"),
|
||||
("localStorage token", "localStorage.getItem('token') || 'no token'"),
|
||||
]
|
||||
|
||||
for idx, (label, expr) in enumerate(checks):
|
||||
msg_id = 100 + idx
|
||||
cdp("Runtime.evaluate", {"expression": expr, "returnByValue": True}, msg_id)
|
||||
recv_msgs = recv_all(2)
|
||||
r = find_result(recv_msgs, msg_id)
|
||||
|
||||
if r:
|
||||
val = r.get("result",{}).get("value","?")
|
||||
# 处理 object 类型
|
||||
if isinstance(val, dict) and "objectId" in r.get("result",{}):
|
||||
val = r["result"].get("description","[object]")
|
||||
err = r.get("result",{}).get("description","") or ""
|
||||
print(f" {label}: {val}")
|
||||
if r.get("result",{}).get("type") == "object":
|
||||
# Try to get properties
|
||||
obj_id = r["result"].get("objectId","")
|
||||
if obj_id:
|
||||
cdp("Runtime.getProperties", {"objectId": obj_id, "ownProperties": True}, 900 + idx)
|
||||
prop_msgs = recv_all(2)
|
||||
prop_r = find_result(prop_msgs, 900 + idx)
|
||||
if prop_r:
|
||||
for prop in prop_r.get("result",[]):
|
||||
v = prop.get("value",{})
|
||||
print(f" .{prop.get('name','?')} = {v.get('value', v.get('description','?'))}")
|
||||
else:
|
||||
print(f" {label}: NO RESULT")
|
||||
|
||||
# Step 6: 截图
|
||||
print("\n=== Step 6: 截图 ===")
|
||||
cdp("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": True}, 20)
|
||||
recv_msgs = recv_all(5)
|
||||
r = find_result(recv_msgs, 20)
|
||||
if r and r.get("data"):
|
||||
img = base64.b64decode(r["data"])
|
||||
fp = "/tmp/cyrene_screenshot_round12_v3.png"
|
||||
with open(fp, "wb") as f:
|
||||
f.write(img)
|
||||
print(f" Screenshot: {len(img)} bytes -> {fp}")
|
||||
else:
|
||||
print(f" Screenshot failed: {str(r)[:200]}")
|
||||
|
||||
# Step 7: 检查是否有 JS 异常
|
||||
print("\n=== Step 7: 最终 JS 异常检查 ===")
|
||||
ws.settimeout(1)
|
||||
has_exception = False
|
||||
for i in range(5):
|
||||
try:
|
||||
d = json.loads(ws.recv())
|
||||
if d.get("method") == "Runtime.exceptionThrown":
|
||||
exc = d["params"]["exceptionDetails"]
|
||||
print(f" [EXCEPTION] text={exc.get('text','')}")
|
||||
print(f" url={exc.get('url','')}")
|
||||
print(f" line={exc.get('lineNumber','')} col={exc.get('columnNumber','')}")
|
||||
stack = exc.get("stackTrace",{})
|
||||
for frame in stack.get("callFrames",[]):
|
||||
print(f" at {frame.get('functionName','')} {frame.get('url','')}:{frame.get('lineNumber','')}")
|
||||
has_exception = True
|
||||
except:
|
||||
break
|
||||
|
||||
if not has_exception:
|
||||
print(" No exceptions caught")
|
||||
|
||||
ws.close()
|
||||
print("\n[DONE v3]")
|
||||
Vendored
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round 12: 聊天 E2E + 子服务连通性测试"""
|
||||
import json, urllib.request, urllib.error, sys
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
TOKEN_FILE = "/tmp/cyrene_test_token.txt"
|
||||
SID_FILE = "/tmp/cyrene_test_sid.txt"
|
||||
|
||||
def req(method, path, body=None, token=None, timeout=10):
|
||||
url = f"{BASE}{path}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
r = urllib.request.Request(url, data=data, method=method)
|
||||
r.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
r.add_header("Authorization", f"Bearer {token}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=timeout)
|
||||
return resp.status, resp.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
# ====== Step 1: Admin Login ======
|
||||
print("=== Step 1: Admin Login ===")
|
||||
status, body = req("POST", "/api/v1/auth/login",
|
||||
{"username":"yeij0942","password":"Jiang1143218570"})
|
||||
print(f" status={status}")
|
||||
d = json.loads(body)
|
||||
token = d.get("token", "")
|
||||
print(f" user_id={d.get('user_id')} has_token={'token' in d} token_len={len(token)}")
|
||||
|
||||
# ====== Step 2: 所有服务健康检查 ======
|
||||
print("\n=== Step 2: 子服务健康检查 ===")
|
||||
services = [
|
||||
("ai-core (8081)", "http://localhost:8081/api/v1/health"),
|
||||
("memory-service (8091)", "http://localhost:8091/api/v1/health"),
|
||||
("tool-engine (8092)", "http://localhost:8092/api/v1/health"),
|
||||
("voice-service (8093)", "http://localhost:8093/api/v1/health"),
|
||||
("gateway (8080)", "http://localhost:8080/api/v1/health"),
|
||||
]
|
||||
for name, url in services:
|
||||
r = urllib.request.Request(url)
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
body = resp.read().decode()
|
||||
print(f" [{resp.status}] {name}: {body[:120]}")
|
||||
except Exception as e:
|
||||
print(f" [FAIL] {name}: {e}")
|
||||
|
||||
# ====== Step 3: 获取/创建 Session ======
|
||||
print("\n=== Step 3: Session 管理 ===")
|
||||
status, body = req("GET", "/api/v1/sessions", token=token)
|
||||
print(f" GET /sessions status={status}")
|
||||
try:
|
||||
sessions = json.loads(body)
|
||||
if isinstance(sessions, list) and len(sessions) > 0:
|
||||
sid = sessions[0].get("id", "")
|
||||
print(f" existing session: {sid}")
|
||||
else:
|
||||
# 创建
|
||||
status, body = req("POST", "/api/v1/sessions", {"user_id": "admin"}, token=token)
|
||||
print(f" POST /sessions status={status} body={body[:200]}")
|
||||
sid = json.loads(body).get("id", "")
|
||||
print(f" new session: {sid}")
|
||||
except Exception as e:
|
||||
print(f" parse error: {e}")
|
||||
sid = ""
|
||||
|
||||
# ====== Step 4: 聊天消息发送 (测试 ai-core 转发) ======
|
||||
print(f"\n=== Step 4: 聊天消息发送 (session={sid}) ===")
|
||||
if sid and token:
|
||||
msg_body = {
|
||||
"session_id": sid,
|
||||
"message": "你好,请简单介绍一下你自己",
|
||||
"mode": "text"
|
||||
}
|
||||
status, body = req("POST", "/api/v1/chat", msg_body, token=token)
|
||||
print(f" POST /api/v1/chat status={status}")
|
||||
if status == 404:
|
||||
print(f" WARNING: /api/v1/chat 端点返回 404 - 端点可能未注册!")
|
||||
# 检查 gateway 日志
|
||||
import subprocess
|
||||
result = subprocess.run(["grep", "-i", "chat", "/tmp/gateway.log"], capture_output=True, text=True)
|
||||
print(f" gateway log (chat): {result.stdout[:500]}")
|
||||
else:
|
||||
print(f" response: {body[:500]}")
|
||||
else:
|
||||
print(" SKIPPED: no session or token")
|
||||
|
||||
# ====== Step 5: 直接调用 ai-core chat ======
|
||||
print("\n=== Step 5: 直接调用 ai-core /api/v1/chat ===")
|
||||
ai_body = {
|
||||
"user_id": "admin",
|
||||
"session_id": sid or "test_session_001",
|
||||
"message": "Hello",
|
||||
"mode": "text"
|
||||
}
|
||||
r = urllib.request.Request("http://localhost:8081/api/v1/chat",
|
||||
data=json.dumps(ai_body).encode(),
|
||||
method="POST")
|
||||
r.add_header("Content-Type", "application/json")
|
||||
r.add_header("Accept", "text/event-stream")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=30)
|
||||
print(f" ai-core status={resp.status}")
|
||||
# 读取前5行 SSE
|
||||
lines = []
|
||||
for i in range(5):
|
||||
line = resp.readline().decode().strip()
|
||||
lines.append(line)
|
||||
print(f" first 5 SSE lines: {lines}")
|
||||
except Exception as e:
|
||||
print(f" ai-core FAIL: {e}")
|
||||
|
||||
# ====== Step 6: memory-service 直调测试 ======
|
||||
print("\n=== Step 6: memory-service 直调测试 ===")
|
||||
r = urllib.request.Request("http://localhost:8091/api/v1/memory/search",
|
||||
data=json.dumps({"query": "test", "user_id": "admin", "limit": 5}).encode(),
|
||||
method="POST")
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
print(f" memory/search status={resp.status} body={resp.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" memory/search FAIL: {e}")
|
||||
|
||||
# ====== Step 7: tool-engine 直调测试 ======
|
||||
print("\n=== Step 7: tool-engine 直调测试 ===")
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools",
|
||||
data=json.dumps({"action": "list"}).encode(),
|
||||
method="POST")
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
print(f" tools/list status={resp.status} body={resp.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" tools/list FAIL: {e}")
|
||||
|
||||
# ====== Step 8: Gateway 路由检查 ======
|
||||
print("\n=== Step 8: Gateway 路由检查 (哪些chat端点注册了) ===")
|
||||
for path in ["/api/v1/chat", "/api/v1/chat/stream", "/api/v1/chat/send"]:
|
||||
status, _ = req("POST", path, {"message": "test"}, token=token)
|
||||
print(f" POST {path}: {status}")
|
||||
|
||||
# ====== Step 9: WebSocket 端点检查 ======
|
||||
print("\n=== Step 9: WebSocket 端点基本测试 ===")
|
||||
import socket
|
||||
# 先检查 /ws/chat 是否可访问 (HTTP层面)
|
||||
r = urllib.request.Request("http://localhost:8080/ws/chat")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=3)
|
||||
print(f" GET /ws/chat (no upgrade): {resp.status}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" GET /ws/chat HTTP status: {e.code} body={e.read().decode()[:100]}")
|
||||
except Exception as e:
|
||||
print(f" GET /ws/chat: {e}")
|
||||
|
||||
# 保存 token 和 sid
|
||||
with open(TOKEN_FILE, "w") as f:
|
||||
f.write(token)
|
||||
with open(SID_FILE, "w") as f:
|
||||
f.write(sid or "")
|
||||
print(f"\n[DONE] Token saved to {TOKEN_FILE}, sid={sid}")
|
||||
Vendored
+267
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""第16轮诊断:IoT&A 自动化规则引擎 + 控件CDP 测试脚本"""
|
||||
import json, urllib.request, urllib.error, sys, time, subprocess
|
||||
|
||||
GATEWAY = "http://localhost:8080"
|
||||
CDP = "http://127.0.0.1:9225"
|
||||
|
||||
def req(method, path, body=None, headers=None):
|
||||
url = f"{GATEWAY}{path}"
|
||||
if headers is None:
|
||||
headers = {}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
else:
|
||||
data = None
|
||||
try:
|
||||
r = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
try:
|
||||
return json.loads(body)
|
||||
except:
|
||||
return {"error": body, "status": e.code}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_token():
|
||||
result = req("POST", "/api/v1/auth/login", {"username": "yeij0942", "password": "Jiang1143218570"})
|
||||
return result.get("token", "")
|
||||
|
||||
print("=" * 60)
|
||||
print("1. JWT Token 获取")
|
||||
print("=" * 60)
|
||||
token = get_token()
|
||||
if token:
|
||||
print(f"✅ Token 获取成功: {token[:40]}...")
|
||||
else:
|
||||
print("❌ Token 获取失败")
|
||||
sys.exit(1)
|
||||
|
||||
auth_headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("2. Automation 规则引擎 API CRUD 测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 2a. GET Rules (should be empty)
|
||||
print("\n2a. GET /api/v1/automation/rules")
|
||||
result = req("GET", "/api/v1/automation/rules", headers=auth_headers)
|
||||
count = result.get("count", -1)
|
||||
print(f" count={count} {'✅' if count is not None else '⚠️'}")
|
||||
|
||||
# 2b. POST Create Rule
|
||||
print("\n2b. POST /api/v1/automation/rules (创建规则)")
|
||||
create_body = {
|
||||
"name": "测试规则-关灯",
|
||||
"description": "每天晚上22点关闭客厅灯",
|
||||
"trigger_type": "schedule",
|
||||
"trigger_config": {"time": "22:00", "days": ["mon","tue","wed","thu","fri"]},
|
||||
"actions": [{"type": "set_device", "device_id": "light-livingroom", "property": "status", "value": "off"}]
|
||||
}
|
||||
result = req("POST", "/api/v1/automation/rules", body=create_body, headers=auth_headers)
|
||||
rule_id = result.get("rule", {}).get("id", "")
|
||||
print(f" success={result.get('success')} rule_id={rule_id[:16] if rule_id else 'N/A'}...")
|
||||
if not result.get("success"):
|
||||
print(f" ERROR: {json.dumps(result, indent=2, ensure_ascii=False)[:500]}")
|
||||
|
||||
# 2c. GET Rules after create
|
||||
if rule_id:
|
||||
print("\n2c. GET /api/v1/automation/rules")
|
||||
result = req("GET", "/api/v1/automation/rules", headers=auth_headers)
|
||||
name0 = result.get("rules", [{}])[0].get("name", "N/A") if result.get("rules") else "N/A"
|
||||
print(f" count={result.get('count')} first_name={name0}")
|
||||
|
||||
# 2d. GET single
|
||||
print(f"\n2d. GET /api/v1/automation/rules/{rule_id}")
|
||||
result = req("GET", f"/api/v1/automation/rules/{rule_id}", headers=auth_headers)
|
||||
r = result.get("rule", {})
|
||||
print(f" name={r.get('name')} trigger_type={r.get('trigger_type')} enabled={r.get('enabled')}")
|
||||
|
||||
# 2e. PUT Update
|
||||
print(f"\n2e. PUT /api/v1/automation/rules/{rule_id}")
|
||||
result = req("PUT", f"/api/v1/automation/rules/{rule_id}", body={"name": "测试规则-已更新", "enabled": False}, headers=auth_headers)
|
||||
r = result.get("rule", {})
|
||||
print(f" success={result.get('success')} name={r.get('name')} enabled={r.get('enabled')}")
|
||||
|
||||
# 2f. DELETE
|
||||
print(f"\n2f. DELETE /api/v1/automation/rules/{rule_id}")
|
||||
result = req("DELETE", f"/api/v1/automation/rules/{rule_id}", headers=auth_headers)
|
||||
print(f" success={result.get('success')}")
|
||||
|
||||
# 2g. Verify deleted
|
||||
print("\n2g. GET /api/v1/automation/rules (验证删除)")
|
||||
result = req("GET", "/api/v1/automation/rules", headers=auth_headers)
|
||||
print(f" count={result.get('count')} {'✅' if result.get('count') == 0 else '⚠️'}")
|
||||
|
||||
# 2h. Unauthenticated
|
||||
print("\n2h. GET /api/v1/automation/rules (未认证)")
|
||||
result = req("GET", "/api/v1/automation/rules")
|
||||
print(f" error={result.get('error','N/A')[:60]}")
|
||||
|
||||
# 2i. Manual trigger
|
||||
print("\n2i. POST /api/v1/automation/rules/:id/trigger (手动触发)")
|
||||
cbody = {"name": "触发测试", "trigger_type": "manual", "actions": [{"type": "notify", "title": "测试", "body": "通知"}]}
|
||||
result = req("POST", "/api/v1/automation/rules", body=cbody, headers=auth_headers)
|
||||
trid = result.get("rule", {}).get("id", "")
|
||||
if trid:
|
||||
result = req("POST", f"/api/v1/automation/rules/{trid}/trigger", headers=auth_headers)
|
||||
print(f" success={result.get('success')} msg={result.get('message','')}")
|
||||
req("DELETE", f"/api/v1/automation/rules/{trid}", headers=auth_headers)
|
||||
else:
|
||||
print(" ❌ 创建失败")
|
||||
|
||||
# 2j. Scenes
|
||||
print("\n2j. GET /api/v1/automation/scenes")
|
||||
result = req("GET", "/api/v1/automation/scenes", headers=auth_headers)
|
||||
print(f" count={result.get('count')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("3. IoT 调试服务测试")
|
||||
print("=" * 60)
|
||||
|
||||
res = json.loads(urllib.request.urlopen("http://localhost:8083/api/v1/devices").read())
|
||||
print(f"3a. 设备总数: {res.get('total', 0)}")
|
||||
for d in res.get("devices", []):
|
||||
print(f" {d['name']} ({d['type']}): {d.get('status','')}")
|
||||
|
||||
print("\n3b. Toggle light-bedroom")
|
||||
r = urllib.request.Request("http://localhost:8083/api/v1/devices/light-bedroom/toggle", method="POST")
|
||||
result = json.loads(urllib.request.urlopen(r).read())
|
||||
dev = result.get("device", {})
|
||||
print(f" action={result.get('action')} {dev.get('name')} status={dev.get('status')}")
|
||||
|
||||
print("\n3c. Set temperature (ac-livingroom)")
|
||||
r = urllib.request.Request("http://localhost:8083/api/v1/devices/ac-livingroom/set",
|
||||
data=json.dumps({"field": "temperature", "value": 24}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
result = json.loads(urllib.request.urlopen(r).read())
|
||||
dev = result.get("device", {})
|
||||
print(f" {dev.get('name')} temperature={dev.get('temperature')}°C")
|
||||
|
||||
print("\n3d. History")
|
||||
result = json.loads(urllib.request.urlopen("http://localhost:8083/api/v1/devices/light-bedroom/history").read())
|
||||
print(f" device_id={result.get('device_id')} history={len(result.get('history',[]))} entries")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("4. CDP 前端 IoT 控件验证")
|
||||
print("=" * 60)
|
||||
|
||||
pages = json.loads(urllib.request.urlopen(f"{CDP}/json").read())
|
||||
target = None
|
||||
for p in pages:
|
||||
if "localhost:5199" in p.get("url", ""):
|
||||
target = p
|
||||
break
|
||||
|
||||
if target:
|
||||
ws_url = target.get("webSocketDebuggerUrl", "")
|
||||
print(f"4a. 目标页面: {target.get('title','')[:60]}")
|
||||
print(f"4b. WebSocket: {ws_url[:80]}...")
|
||||
|
||||
# Use websockets to execute JS
|
||||
print("\n4c. CDP Runtime.evaluate 检查 IoTStatusBar")
|
||||
try:
|
||||
import asyncio
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
print(" ⚠️ websockets not installed, trying pip install...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "websockets", "-q"], timeout=30)
|
||||
import websockets
|
||||
|
||||
async def cdp_eval():
|
||||
async with websockets.connect(ws_url, max_size=2**24) as ws:
|
||||
# Runtime.enable
|
||||
await ws.send(json.dumps({"id": 1, "method": "Runtime.enable"}))
|
||||
await asyncio.wait_for(ws.recv(), timeout=5)
|
||||
|
||||
# Evaluate JS
|
||||
js_code = """
|
||||
(function() {
|
||||
var r = {};
|
||||
var allDivs = document.querySelectorAll('div');
|
||||
r.totalDivs = allDivs.length;
|
||||
r.iotTexts = [];
|
||||
for (var i = 0; i < allDivs.length; i++) {
|
||||
var t = allDivs[i].textContent || '';
|
||||
if (t.indexOf('IoT') !== -1 || t.indexOf('iot') !== -1) {
|
||||
r.iotTexts.push(t.substring(0, 100));
|
||||
if (r.iotTexts.length >= 10) break;
|
||||
}
|
||||
}
|
||||
r.hasRoot = !!document.getElementById('root');
|
||||
r.title = document.title;
|
||||
r.bodyText = (document.body ? document.body.innerText : '').substring(0, 300);
|
||||
return JSON.stringify(r);
|
||||
})()
|
||||
"""
|
||||
await ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate",
|
||||
"params": {"expression": js_code, "returnByValue": True}}))
|
||||
resp = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(resp)
|
||||
result_val = data.get("result", {}).get("result", {}).get("value", "N/A")
|
||||
return result_val
|
||||
|
||||
result = asyncio.new_event_loop().run_until_complete(cdp_eval())
|
||||
parsed = json.loads(result) if result and result != "N/A" else {}
|
||||
print(f" title={parsed.get('title','')}")
|
||||
print(f" hasRoot={parsed.get('hasRoot')} totalDivs={parsed.get('totalDivs')}")
|
||||
print(f" iotTexts={parsed.get('iotTexts',[])}")
|
||||
print(f" bodyText(first 300): {parsed.get('bodyText','')}")
|
||||
except Exception as e:
|
||||
print(f" ❌ CDP 错误: {e}")
|
||||
else:
|
||||
print("4a. ❌ 未找到 localhost:5199 页面")
|
||||
for p in pages:
|
||||
print(f" {p.get('url','?')[:100]}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("5. tool-engine IoT 工具执行测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 5a. iot_query
|
||||
print("\n5a. tool-engine iot_query")
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools/execute",
|
||||
data=json.dumps({"tool": "iot_query", "arguments": {}}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
result = json.loads(urllib.request.urlopen(r, timeout=10).read())
|
||||
print(f" output: {result.get('output','')[:200]}")
|
||||
if result.get('error'):
|
||||
print(f" error: {result['error'][:200]}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {e}")
|
||||
|
||||
# 5b. iot_control toggle
|
||||
print("\n5b. tool-engine iot_control (toggle ac-livingroom)")
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools/execute",
|
||||
data=json.dumps({"tool": "iot_control", "arguments": {"device_id": "ac-livingroom", "action": "toggle"}}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
result = json.loads(urllib.request.urlopen(r, timeout=10).read())
|
||||
print(f" output: {result.get('output','')[:200]}")
|
||||
if result.get('error'):
|
||||
print(f" error: {result['error'][:200]}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {e}")
|
||||
|
||||
# 5c. iot_control set_temperature
|
||||
print("\n5c. tool-engine iot_control (set_temperature 28)")
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools/execute",
|
||||
data=json.dumps({"tool": "iot_control", "arguments": {"device_id": "ac-livingroom", "action": "set_temperature", "value": 28}}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
result = json.loads(urllib.request.urlopen(r, timeout=10).read())
|
||||
print(f" output: {result.get('output','')[:200]}")
|
||||
if result.get('error'):
|
||||
print(f" error: {result['error'][:200]}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("诊断测试完成")
|
||||
print("=" * 60)
|
||||
Vendored
+437
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
安全审计测试脚本 - 第13轮
|
||||
测试 JWT 安全、认证端点安全、输入验证、授权越权、敏感信息泄露
|
||||
"""
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
HEADERS_JSON = {"Content-Type": "application/json"}
|
||||
|
||||
def curl(method, path, headers=None, data=None, expected_status=None, timeout=10):
|
||||
"""执行 curl 请求并返回 (status_code, response_body, response_headers)"""
|
||||
cmd = ["curl", "-s", "-w", "\n%{http_code}", "-X", method, f"{BASE}{path}", "--max-time", str(timeout)]
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
cmd += ["-H", f"{k}: {v}"]
|
||||
if data:
|
||||
cmd += ["-d", data]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+5)
|
||||
output = result.stdout.strip()
|
||||
|
||||
# 分离 body 和 status code
|
||||
if "\n" in output:
|
||||
lines = output.rsplit("\n", 1)
|
||||
body = lines[0]
|
||||
status = lines[1]
|
||||
else:
|
||||
body = ""
|
||||
status = output
|
||||
|
||||
try:
|
||||
status = int(status)
|
||||
except ValueError:
|
||||
status = 0
|
||||
|
||||
# 单独获取响应头
|
||||
hdr_cmd = ["curl", "-s", "-I", "-X", method, f"{BASE}{path}", "--max-time", str(timeout)]
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
hdr_cmd += ["-H", f"{k}: {v}"]
|
||||
hdr_result = subprocess.run(hdr_cmd, capture_output=True, text=True, timeout=timeout+5)
|
||||
resp_headers = {}
|
||||
for line in hdr_result.stdout.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
resp_headers[key.strip().lower()] = val.strip()
|
||||
|
||||
return status, body, resp_headers
|
||||
|
||||
def print_result(test_name, status, body, expected_status=None, detail=""):
|
||||
"""打印测试结果"""
|
||||
body_short = body[:200] if body else "(empty)"
|
||||
status_icon = "✅" if (expected_status is None or status == expected_status) else "❌"
|
||||
print(f"{status_icon} [{status}] {test_name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
print(f" Response: {body_short}")
|
||||
print()
|
||||
|
||||
def test_group(name):
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {name}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
# ============================================================
|
||||
# 0. 先获取有效的 admin token
|
||||
# ============================================================
|
||||
print("🔑 获取管理员 token...")
|
||||
s, b, _ = curl("POST", "/api/v1/auth/login", HEADERS_JSON,
|
||||
json.dumps({"username": "yeij0942", "password": "Jiang1143218570"}))
|
||||
if s == 200:
|
||||
admin_token = json.loads(b).get("token", "")
|
||||
print(f" 管理员 token 获取成功: {admin_token[:30]}...")
|
||||
else:
|
||||
print(f" ❌ 获取失败: {s} {b}")
|
||||
sys.exit(1)
|
||||
|
||||
# ============================================================
|
||||
# 1. JWT 令牌安全性审计
|
||||
# ============================================================
|
||||
test_group("1. JWT 令牌安全性审计")
|
||||
|
||||
# 1.1 无 token 访问受保护端点
|
||||
s, b, h = curl("GET", "/api/v1/sessions", expected_status=401)
|
||||
print_result("1.1 无 token 访问受保护端点", s, b, 401)
|
||||
|
||||
# 1.2 伪造 token
|
||||
fake_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYWRtaW4iLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MH0.fake-signature"
|
||||
s, b, h = curl("GET", "/api/v1/sessions", {"Authorization": f"Bearer {fake_token}"}, expected_status=401)
|
||||
print_result("1.2 伪造 token (错误签名)", s, b, 401)
|
||||
|
||||
# 1.3 过期 token (手动构造一个已过期的)
|
||||
# 使用 Python 生成一个过期的 JWT
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
def b64url(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||
|
||||
header = b64url(json.dumps({"alg":"HS256","typ":"JWT"}).encode())
|
||||
# exp = 2020-01-01 (1577836800)
|
||||
payload = b64url(json.dumps({"user_id":"admin","exp":1577836800,"iat":1577836800}).encode())
|
||||
signing_input = f"{header}.{payload}".encode()
|
||||
# 使用默认密钥 "change-me-in-production"
|
||||
sig = hmac.new(b"change-me-in-production", signing_input, hashlib.sha256).digest()
|
||||
expired_token = f"{header}.{payload}.{b64url(sig)}"
|
||||
|
||||
s, b, h = curl("GET", "/api/v1/sessions", {"Authorization": f"Bearer {expired_token}"}, expected_status=401)
|
||||
print_result("1.3 过期 token", s, b, 401)
|
||||
|
||||
# 1.4 空 token
|
||||
s, b, h = curl("GET", "/api/v1/sessions", {"Authorization": "Bearer "}, expected_status=401)
|
||||
print_result("1.4 空 token", s, b, 401)
|
||||
|
||||
# 1.5 错误的 Authorization 格式
|
||||
s, b, h = curl("GET", "/api/v1/sessions", {"Authorization": "Basic YWRtaW46cGFzcw=="}, expected_status=401)
|
||||
print_result("1.5 错误格式 (Basic auth)", s, b, 401)
|
||||
|
||||
# 1.6 token 放到 query param 而非 header (WebSocket 用这个)
|
||||
s, b, h = curl("GET", f"/api/v1/sessions?token={admin_token}", expected_status=401)
|
||||
print_result("1.6 token 作为 query param (REST API)", s, b, 401, "REST API 应该只接受 Header 中的 token")
|
||||
|
||||
# 1.7 检查 token 过期时间
|
||||
# 1.7 手动解码 JWT 检查过期时间 (纯标准库)
|
||||
try:
|
||||
parts = admin_token.split(".")
|
||||
if len(parts) >= 2:
|
||||
# 补齐 base64 padding
|
||||
payload_b64 = parts[1]
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += "=" * padding
|
||||
decoded_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
decoded = json.loads(decoded_bytes)
|
||||
exp = decoded.get("exp", 0)
|
||||
iat = decoded.get("iat", 0)
|
||||
hours = (exp - iat) / 3600
|
||||
print(f"1.7 JWT 过期时间分析:")
|
||||
print(f" 签发时间 (iat): {iat} ({time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(iat))})")
|
||||
print(f" 过期时间 (exp): {exp} ({time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(exp))})")
|
||||
print(f" 有效时长: {hours:.0f} 小时 ({hours/24:.1f} 天)")
|
||||
if hours > 168: # 超过7天
|
||||
print(f" ⚠️ 警告: JWT 过期时间过长 ({hours/24:.0f} 天),建议缩短至 24-72 小时")
|
||||
else:
|
||||
print(f" ✅ 过期时间合理")
|
||||
except Exception as e:
|
||||
print(f" ❌ 解码失败: {e}")
|
||||
print()
|
||||
|
||||
# ============================================================
|
||||
# 2. 认证端点安全测试
|
||||
# ============================================================
|
||||
test_group("2. 认证端点安全测试")
|
||||
|
||||
# 2.1 SQL 注入测试 - Login
|
||||
sql_payloads = [
|
||||
("' OR '1'='1", "any"),
|
||||
("admin'--", "any"),
|
||||
("'; DROP TABLE users; --", "any"),
|
||||
("' UNION SELECT 1,2,3,4,5,6 --", "any"),
|
||||
("admin' OR 1=1 --", "any"),
|
||||
("1'='1", "any"),
|
||||
]
|
||||
|
||||
for payload, pwd in sql_payloads:
|
||||
data = json.dumps({"username": payload, "password": pwd})
|
||||
s, b, h = curl("POST", "/api/v1/auth/login", HEADERS_JSON, data, expected_status=None)
|
||||
# 对于 SQL 注入 payload,期望返回 400 (格式无效) 或 401 (认证失败)
|
||||
# 但绝对不能是 200 (成功登录)
|
||||
if s == 200:
|
||||
print_result(f"2.1 SQL注入-Login: {payload[:40]}", s, b, None, "🔴 严重: SQL 注入可能成功!")
|
||||
elif s == 400:
|
||||
print_result(f"2.1 SQL注入-Login: {payload[:40]}", s, b, 400, "✅ 用户名格式校验拦截")
|
||||
else:
|
||||
print_result(f"2.1 SQL注入-Login: {payload[:40]}", s, b, 401)
|
||||
|
||||
# 2.2 SQL 注入测试 - Register
|
||||
for payload in ["' OR '1'='1", "test'--", "'; DROP TABLE users; --"]:
|
||||
data = json.dumps({
|
||||
"username": payload,
|
||||
"password": "test123456",
|
||||
"email": "test@test.com",
|
||||
"nickname": "Test",
|
||||
"verify_code": "000000"
|
||||
})
|
||||
s, b, h = curl("POST", "/api/v1/auth/register", HEADERS_JSON, data)
|
||||
print_result(f"2.2 SQL注入-Register: {payload[:40]}", s, b, 400 if s != 200 else None)
|
||||
|
||||
# 2.3 暴力破解防护测试 (连续错误登录)
|
||||
print("2.3 暴力破解防护测试 (连续10次错误登录):")
|
||||
rate_limited = False
|
||||
for i in range(10):
|
||||
data = json.dumps({"username": "yeij0942", "password": f"wrong_password_{i}"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/login", HEADERS_JSON, data)
|
||||
if s == 429:
|
||||
rate_limited = True
|
||||
print(f" 请求 {i+1}: [{s}] ⚡ 速率限制触发!")
|
||||
break
|
||||
print(f" 请求 {i+1}: [{s}]")
|
||||
if not rate_limited:
|
||||
print(f" ⚠️ 10次错误登录后仍未触发速率限制")
|
||||
else:
|
||||
print(f" ✅ 速率限制在第 {i+1} 次请求时触发")
|
||||
|
||||
# 2.4 用户名枚举测试
|
||||
print("\n2.4 用户名枚举测试:")
|
||||
# 测试不存在的用户
|
||||
data = json.dumps({"username": "nonexistent_user_xyz_12345", "password": "any_password"})
|
||||
s1, b1, _ = curl("POST", "/api/v1/auth/login", HEADERS_JSON, data)
|
||||
# 测试存在的用户 (但密码错误)
|
||||
data = json.dumps({"username": "yeij0942", "password": "wrong_password"})
|
||||
s2, b2, _ = curl("POST", "/api/v1/auth/login", HEADERS_JSON, data)
|
||||
print(f" 不存在用户: [{s1}] {b1[:150]}")
|
||||
print(f" 存在但密码错: [{s2}] {b2[:150]}")
|
||||
if s1 == s2 and "用户名或密码错误" in b1 and "用户名或密码错误" in b2:
|
||||
print(f" ✅ 错误消息一致,防止用户名枚举")
|
||||
elif b1 != b2:
|
||||
print(f" ⚠️ 警告: 错误消息不同,可能存在用户名枚举风险")
|
||||
print()
|
||||
|
||||
# 2.5 注册端点 - 极短用户名
|
||||
data = json.dumps({"username": "ab", "password": "123456", "email": "test@test.com", "nickname": "Test", "verify_code": "000000"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/register", HEADERS_JSON, data)
|
||||
print_result("2.5 极短用户名 (ab, 2字符)", s, b, 400)
|
||||
|
||||
# 2.6 特殊字符用户名
|
||||
for uname in ["test<script>", "test user", "test/user", "test😀user"]:
|
||||
data = json.dumps({"username": uname, "password": "123456", "email": "test@test.com", "nickname": "Test", "verify_code": "000000"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/register", HEADERS_JSON, data)
|
||||
print_result(f"2.6 特殊字符用户名: {uname[:30]}", s, b, 400)
|
||||
|
||||
# 2.7 XSS payload in nickname
|
||||
data = json.dumps({"username": "testuser99", "password": "123456", "email": "test@test.com", "nickname": "<script>alert(1)</script>", "verify_code": "000000"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/register", HEADERS_JSON, data)
|
||||
# nickname 应该不需要特殊的 XSS 过滤,但需要确保后端对其做了转义
|
||||
print_result("2.7 XSS payload in nickname", s, b, 403 if s != 200 else None, "期望:注册被拒绝或昵称被转义/过滤")
|
||||
|
||||
# ============================================================
|
||||
# 3. 输入验证审计
|
||||
# ============================================================
|
||||
test_group("3. 输入验证审计 - Session 端点")
|
||||
|
||||
# 创建普通用户 token (如果可以)
|
||||
# 先尝试注册
|
||||
data = json.dumps({"username": "testuser42", "password": "Test123456", "email": "test42@test.com", "nickname": "Tester42", "verify_code": "000000"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/register", HEADERS_JSON, data)
|
||||
user_token = None
|
||||
if s == 201:
|
||||
user_token = json.loads(b).get("token", "")
|
||||
print(f" 创建测试用户成功,token={user_token[:30]}...")
|
||||
else:
|
||||
print(f" 创建测试用户失败 [{s}]: {b[:100]}")
|
||||
# 尝试登录
|
||||
data = json.dumps({"username": "testuser42", "password": "Test123456"})
|
||||
s, b, h = curl("POST", "/api/v1/auth/login", HEADERS_JSON, data)
|
||||
if s == 200:
|
||||
user_token = json.loads(b).get("token", "")
|
||||
print(f" 登录测试用户成功,token={user_token[:30]}...")
|
||||
else:
|
||||
print(f" 登录测试用户也失败 [{s}]: {b[:100]}")
|
||||
|
||||
# 3.1 创建 session 时携带过长的 title
|
||||
long_title = "A" * 1000
|
||||
data = json.dumps({"title": long_title, "is_main": False})
|
||||
s, b, h = curl("POST", "/api/v1/sessions", {**HEADERS_JSON, "Authorization": f"Bearer {admin_token}"}, data)
|
||||
print_result("3.1 超长 session title (1000字符)", s, b, 201 if s == 201 else None)
|
||||
|
||||
# 3.2 创建 session 时指定其他用户的 user_id (越权)
|
||||
data = json.dumps({"user_id": "user_hacker", "title": "hijacked session"})
|
||||
s, b, h = curl("POST", "/api/v1/sessions", {**HEADERS_JSON, "Authorization": f"Bearer {admin_token}"}, data)
|
||||
print_result("3.2 Session 创建时指定其他 user_id (越权)", s, b, None, "检查是否允许 user_id 覆盖 JWT 身份")
|
||||
if s == 201:
|
||||
resp = json.loads(b)
|
||||
actual_user_id = resp.get("user_id", "")
|
||||
if actual_user_id == "user_hacker":
|
||||
print(f" 🔴 严重: 允许通过请求体覆盖 user_id 为 {actual_user_id}!")
|
||||
else:
|
||||
print(f" ✅ 忽略请求体中的 user_id,使用 JWT 身份: {actual_user_id}")
|
||||
|
||||
# 3.3 列出其他用户的 sessions
|
||||
s, b, h = curl("GET", "/api/v1/sessions?user_id=user_hacker", {"Authorization": f"Bearer {admin_token}"})
|
||||
print_result("3.3 查看其他用户的 session 列表 (越权)", s, b, None, "检查是否允许查询其他用户的 session")
|
||||
if s == 200:
|
||||
sessions_data = json.loads(b)
|
||||
print(f" ⚠️ 可以查看其他用户的 session 列表")
|
||||
|
||||
# 3.4 空消息 / 特殊 Unicode 测试 (通过 WebSocket 模拟)
|
||||
# 由于 WebSocket 测试需要脚本,这里仅做 HTTP 层面的检查
|
||||
|
||||
# 3.5 非法 session ID
|
||||
s, b, h = curl("GET", "/api/v1/sessions/../../../etc/passwd", {"Authorization": f"Bearer {admin_token}"})
|
||||
print_result("3.5 路径遍历 in session ID", s, b, 404)
|
||||
|
||||
s, b, h = curl("GET", "/api/v1/sessions/<script>alert(1)</script>", {"Authorization": f"Bearer {admin_token}"})
|
||||
print_result("3.6 XSS in session ID", s, b, 404)
|
||||
|
||||
# 3.7 超长 session ID
|
||||
long_sid = "session_" + "A" * 500
|
||||
s, b, h = curl("GET", f"/api/v1/sessions/{long_sid}", {"Authorization": f"Bearer {admin_token}"})
|
||||
print_result("3.7 超长 session ID (500字符)", s, b, None)
|
||||
|
||||
# ============================================================
|
||||
# 4. 授权/越权测试
|
||||
# ============================================================
|
||||
test_group("4. 授权/越权测试")
|
||||
|
||||
# 4.1 未认证访问受保护端点
|
||||
endpoints = [
|
||||
("GET", "/api/v1/sessions"),
|
||||
("GET", "/api/v1/files"),
|
||||
("GET", "/api/v1/memory/search"),
|
||||
("GET", "/api/v1/reminders"),
|
||||
("POST", "/api/v1/automation/rules"),
|
||||
]
|
||||
for method, path in endpoints:
|
||||
s, b, h = curl(method, path, expected_status=401)
|
||||
print_result(f"4.1 未认证访问: {method} {path}", s, b, 401)
|
||||
|
||||
# 4.2 普通用户访问管理员 API
|
||||
if user_token:
|
||||
admin_endpoints = [
|
||||
("GET", "/api/v1/admin/sessions"),
|
||||
("GET", "/api/v1/admin/sessions/active"),
|
||||
]
|
||||
for method, path in admin_endpoints:
|
||||
s, b, h = curl(method, path, {"Authorization": f"Bearer {user_token}"}, expected_status=403)
|
||||
print_result(f"4.2 普通用户访问管理员API: {method} {path}", s, b, 403)
|
||||
|
||||
# 4.3 删除其他用户的 session
|
||||
# 先创建一个 session
|
||||
data = json.dumps({"title": "Test Session for Auth Test"})
|
||||
s, b, h = curl("POST", "/api/v1/sessions", {**HEADERS_JSON, "Authorization": f"Bearer {admin_token}"}, data)
|
||||
test_session_id = None
|
||||
if s == 201:
|
||||
test_session_id = json.loads(b).get("id", "")
|
||||
print(f" 创建测试 session: {test_session_id}")
|
||||
|
||||
if test_session_id and user_token:
|
||||
# 用普通用户 token 删除管理员的 session
|
||||
s, b, h = curl("DELETE", f"/api/v1/sessions/{test_session_id}", {"Authorization": f"Bearer {user_token}"})
|
||||
print_result(f"4.3 普通用户删除管理员的 session", s, b, None, "期望 403 或至少不允许删除")
|
||||
if s == 200:
|
||||
print(f" 🔴 严重: 用户可以删除其他用户的 session!")
|
||||
|
||||
# 4.4 查看其他用户的具体 session
|
||||
if test_session_id and user_token:
|
||||
s, b, h = curl("GET", f"/api/v1/sessions/{test_session_id}", {"Authorization": f"Bearer {user_token}"})
|
||||
print_result(f"4.4 普通用户查看管理员的 session", s, b, None, "期望 403 或 404")
|
||||
|
||||
# ============================================================
|
||||
# 5. 敏感信息泄露检查
|
||||
# ============================================================
|
||||
test_group("5. 敏感信息泄露检查")
|
||||
|
||||
# 5.1 检查响应头
|
||||
s, b, h = curl("GET", "/api/v1/health")
|
||||
print("5.1 HTTP 响应头分析:")
|
||||
sensitive_headers = ["server", "x-powered-by", "x-aspnet-version", "x-generator"]
|
||||
for header_name in sensitive_headers:
|
||||
if header_name in h:
|
||||
print(f" ⚠️ 敏感头: {header_name}: {h[header_name]}")
|
||||
else:
|
||||
print(f" ✅ 无敏感头: {header_name}")
|
||||
|
||||
# 5.2 检查安全头
|
||||
security_headers = {
|
||||
"x-content-type-options": "nosniff",
|
||||
"x-frame-options": "DENY",
|
||||
"x-xss-protection": "1; mode=block",
|
||||
"referrer-policy": "strict-origin-when-cross-origin",
|
||||
"strict-transport-security": None,
|
||||
}
|
||||
for header_name, expected in security_headers.items():
|
||||
value = h.get(header_name, "")
|
||||
if value:
|
||||
print(f" ✅ 安全头: {header_name}: {value}")
|
||||
else:
|
||||
print(f" ⚠️ 缺少安全头: {header_name}")
|
||||
|
||||
# 5.3 CORS 配置
|
||||
print("\n5.3 CORS 配置检查:")
|
||||
s, b, h_origin = curl("GET", "/api/v1/health", {"Origin": "https://evil.com"})
|
||||
acao = h_origin.get("access-control-allow-origin", "")
|
||||
acac = h_origin.get("access-control-allow-credentials", "")
|
||||
print(f" Access-Control-Allow-Origin: {acao}")
|
||||
print(f" Access-Control-Allow-Credentials: {acac}")
|
||||
if acac == "true" and acao == "https://evil.com":
|
||||
print(f" 🔴 严重: CORS 允许任何来源携带凭据! 可被 CSRF 攻击利用")
|
||||
elif acac == "true":
|
||||
print(f" ⚠️ 警告: Access-Control-Allow-Credentials 为 true,需确认来源白名单")
|
||||
|
||||
# 5.4 检查错误响应是否泄露内部信息
|
||||
s, b, h = curl("POST", "/api/v1/auth/login", HEADERS_JSON, "invalid_json{{{")
|
||||
print(f"\n5.4 错误响应信息泄露:")
|
||||
print(f" 响应: {b[:300]}")
|
||||
if "panic" in b.lower() or "goroutine" in b.lower() or "stack" in b.lower():
|
||||
print(f" 🔴 严重: 错误响应泄露堆栈跟踪!")
|
||||
elif ".go:" in b and ("/" in b or "\\\\" in b):
|
||||
print(f" ⚠️ 警告: 错误响应可能泄露文件路径!")
|
||||
else:
|
||||
print(f" ✅ 错误响应未泄露内部信息")
|
||||
|
||||
# 5.5 测试 .env 文件是否可访问
|
||||
s, b, h = curl("GET", "/.env")
|
||||
print(f"\n5.5 .env 文件访问: [{s}]")
|
||||
s, b, h = curl("GET", "/api/v1/.env")
|
||||
print(f" /api/v1/.env 访问: [{s}]")
|
||||
s, b, h = curl("GET", "/api/v1/../.env")
|
||||
print(f" /api/v1/../.env 访问: [{s}]")
|
||||
|
||||
# 5.6 测试敏感路径
|
||||
for path in ["/admin", "/wp-admin", "/phpmyadmin", "/api/v1/debug", "/debug/pprof/"]:
|
||||
s, b, h = curl("GET", path)
|
||||
sens = "⚠️" if s != 404 else "✅"
|
||||
print(f" {sens} {path}: [{s}]")
|
||||
|
||||
# 5.7 CSP 配置检查
|
||||
print(f"\n5.7 Content-Security-Policy: {h.get('content-security-policy', 'MISSING')}")
|
||||
|
||||
# 5.8 WebSocket token 通过 query param 传输
|
||||
print(f"\n5.8 WebSocket 认证: Token 通过 query param 传递 (risk of leaking in logs)")
|
||||
print(f" 这是常见做法,但建议使用更短的生命周期或单独的 WS token")
|
||||
|
||||
# ============================================================
|
||||
# 6. 总结
|
||||
# ============================================================
|
||||
test_group("6. 测试完成 - 汇总")
|
||||
print("所有测试已执行完毕。请查看上方结果判断安全问题。")
|
||||
print(f"管理员 token 前30字符: {admin_token[:30]}...")
|
||||
if user_token:
|
||||
print(f"普通用户 token 前30字符: {user_token[:30]}...")
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
安全审计测试脚本 - 第13轮 Phase 2
|
||||
测试被速率限制阻塞的项目: 用户名枚举、注册、越权访问
|
||||
"""
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
HDR = {"Content-Type": "application/json"}
|
||||
|
||||
def curl(method, path, headers=None, data=None):
|
||||
cmd = ["curl", "-s", "-w", "\n%{http_code}", "-X", method, f"{BASE}{path}", "--max-time", "10"]
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
cmd += ["-H", f"{k}: {v}"]
|
||||
if data:
|
||||
cmd += ["-d", data]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
output = result.stdout.strip()
|
||||
lines = output.rsplit("\n", 1)
|
||||
body = lines[0]
|
||||
try:
|
||||
status = int(lines[1])
|
||||
except:
|
||||
status = 0
|
||||
return status, body
|
||||
|
||||
def pr(test, status, body, expected=None):
|
||||
icon = "✅" if (expected is None or status == expected) else "❌"
|
||||
print(f"{icon} [{status}] {test}")
|
||||
print(f" {body[:200]}")
|
||||
if expected and status != expected:
|
||||
print(f" 期望: {expected}, 实际: {status}")
|
||||
print()
|
||||
|
||||
# Get admin token
|
||||
print("🔑 获取管理员 token...")
|
||||
s, b = curl("POST", "/api/v1/auth/login", HDR, json.dumps({"username":"yeij0942","password":"Jiang1143218570"}))
|
||||
admin_token = json.loads(b).get("token","")
|
||||
print(f" token: {admin_token[:30]}...\n")
|
||||
|
||||
time.sleep(2) # 等待一点时间让令牌桶恢复
|
||||
|
||||
# ==========================================
|
||||
# 测试 1: 用户名枚举
|
||||
# ==========================================
|
||||
print("=" * 60)
|
||||
print(" 用户名枚举测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 登录不存在的用户
|
||||
s1, b1 = curl("POST", "/api/v1/auth/login", HDR, json.dumps({"username":"nonexistent_user_xyz_12345","password":"any_password"}))
|
||||
pr("不存在用户登录", s1, b1, 401)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# 登录存在的用户但密码错误
|
||||
s2, b2 = curl("POST", "/api/v1/auth/login", HDR, json.dumps({"username":"yeij0942","password":"wrong_password"}))
|
||||
pr("存在用户但密码错误", s2, b2, 401)
|
||||
|
||||
print(f" 不存在用户消息: {b1}")
|
||||
print(f" 存在用户消息: {b2}")
|
||||
if b1 == b2:
|
||||
print(" ✅ 错误消息一致,防止用户名枚举")
|
||||
else:
|
||||
print(" ⚠️ 错误消息不同,可能存在用户名枚举风险")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# ==========================================
|
||||
# 测试 2: 注册端点详细测试
|
||||
# ==========================================
|
||||
print("\n" + "=" * 60)
|
||||
print(" 注册端点安全测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 2.1 极短用户名 (2字符)
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"ab","password":"123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("极短用户名 (2字符 ab)", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.2 XSS in username
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"<script>alert(1)</script>","password":"123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("XSS in username", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.3 Special chars in username
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"test user","password":"123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("空格在用户名中", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.4 Unicode in username
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"test😀user","password":"123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("Emoji in username", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.5 Forward slash in username (路径遍历)
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"../../etc/passwd","password":"123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("路径遍历 in username", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.6 XSS in nickname (应该允许注册但需要检查nickname是否被过滤)
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"testnick99","password":"Test123456","email":"test99@test.com","nickname":"<script>alert('xss')</script>","verify_code":"000000"
|
||||
}))
|
||||
pr("XSS in nickname", s, b, None)
|
||||
if s == 201:
|
||||
resp = json.loads(b)
|
||||
nn = resp.get("nickname","")
|
||||
print(f" 昵称返回值: {nn}")
|
||||
if "<script>" in nn:
|
||||
print(" ⚠️ 昵称未过滤 XSS!")
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.7 超长用户名
|
||||
long_user = "a" * 100
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":long_user,"password":"Test123456","email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("超长用户名 (100字符)", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.8 超长密码
|
||||
long_pwd = "a" * 500
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"testpwd99","password":long_pwd,"email":"test@test.com","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("超长密码 (500字符)", s, b, None)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2.9 无效邮箱
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"testemail99","password":"Test123456","email":"not-an-email","nickname":"Test","verify_code":"000000"
|
||||
}))
|
||||
pr("无效邮箱格式", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# ==========================================
|
||||
# 测试 3: 创建普通用户并测试越权
|
||||
# ==========================================
|
||||
print("\n" + "=" * 60)
|
||||
print(" 授权/越权测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 注册普通用户
|
||||
s, b = curl("POST", "/api/v1/auth/register", HDR, json.dumps({
|
||||
"username":"testaudit42","password":"Test123456","email":"audit42@test.com","nickname":"Auditor","verify_code":"000000"
|
||||
}))
|
||||
user_token = None
|
||||
if s == 201:
|
||||
user_token = json.loads(b).get("token","")
|
||||
print(f"✅ 创建测试用户成功: user_testaudit42")
|
||||
else:
|
||||
print(f"注册失败 [{s}]: {b[:100]}")
|
||||
# 尝试登录
|
||||
s, b = curl("POST", "/api/v1/auth/login", HDR, json.dumps({"username":"testaudit42","password":"Test123456"}))
|
||||
if s == 200:
|
||||
user_token = json.loads(b).get("token","")
|
||||
print(f"✅ 登录测试用户成功")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
if user_token:
|
||||
# 3.1 普通用户访问 admin API
|
||||
s, b = curl("GET", "/api/v1/admin/sessions", {"Authorization": f"Bearer {user_token}"})
|
||||
pr("普通用户 -> GET /api/v1/admin/sessions", s, b, 403)
|
||||
time.sleep(0.3)
|
||||
|
||||
s, b = curl("GET", "/api/v1/admin/sessions/active", {"Authorization": f"Bearer {user_token}"})
|
||||
pr("普通用户 -> GET /api/v1/admin/sessions/active", s, b, 403)
|
||||
|
||||
# 3.2 创建 session 时尝试指定为 admin
|
||||
time.sleep(0.3)
|
||||
s, b = curl("POST", "/api/v1/sessions", {**HDR, "Authorization": f"Bearer {user_token}"}, json.dumps({
|
||||
"user_id": "admin", "title": "hijack admin session", "is_main": True
|
||||
}))
|
||||
pr("普通用户创建 session 指定 user_id=admin", s, b, None)
|
||||
if s == 201:
|
||||
resp = json.loads(b)
|
||||
uid = resp.get("user_id","")
|
||||
if uid == "admin":
|
||||
print(" 🔴 严重: 普通用户可以创建管理员身份的 session!")
|
||||
else:
|
||||
print(f" ✅ session owner 仍为: {uid}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3.3 普通用户查询 admin 的 session 列表
|
||||
s, b = curl("GET", "/api/v1/sessions?user_id=admin", {"Authorization": f"Bearer {user_token}"})
|
||||
pr("普通用户查询 admin 的 session 列表", s, b, None)
|
||||
if s == 200:
|
||||
sessions = json.loads(b)
|
||||
count = len(sessions.get("sessions",[]))
|
||||
if count > 0:
|
||||
print(f" ⚠️ 可以查看 admin 的 {count} 个 session!")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3.4 先创建一个归属 admin 的 session
|
||||
s, b = curl("POST", "/api/v1/sessions", {**HDR, "Authorization": f"Bearer {admin_token}"}, json.dumps({
|
||||
"title": "Admin Private Session", "is_main": False
|
||||
}))
|
||||
admin_session_id = None
|
||||
if s == 201:
|
||||
admin_session_id = json.loads(b).get("id","")
|
||||
print(f" Admin 的 private session: {admin_session_id}")
|
||||
|
||||
if admin_session_id:
|
||||
time.sleep(0.3)
|
||||
# 普通用户尝试访问 admin 的 session
|
||||
s, b = curl("GET", f"/api/v1/sessions/{admin_session_id}", {"Authorization": f"Bearer {user_token}"})
|
||||
pr(f"普通用户查看 admin 的 session", s, b, 403 if s != 200 else None)
|
||||
if s == 200:
|
||||
print(f" 🔴 严重: 可以查看其他用户的 session!")
|
||||
|
||||
time.sleep(0.3)
|
||||
# 普通用户尝试删除 admin 的 session
|
||||
s, b = curl("DELETE", f"/api/v1/sessions/{admin_session_id}", {"Authorization": f"Bearer {user_token}"})
|
||||
pr(f"普通用户删除 admin 的 session", s, b, 403 if s != 200 else None)
|
||||
if s == 200:
|
||||
print(f" 🔴 严重: 可以删除其他用户的 session!")
|
||||
else:
|
||||
print("⚠️ 无法获取普通用户 token,跳过越权测试")
|
||||
|
||||
# ==========================================
|
||||
# 测试 4: 聊天消息 WebSocket 测试 (HTTP 层面)
|
||||
# ==========================================
|
||||
print("\n" + "=" * 60)
|
||||
print(" 聊天 HTTP 端点测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 4.1 WebSocket 通过 query param 传递 token 测试
|
||||
s, b = curl("GET", "/ws/chat?token=invalid_fake_token_123")
|
||||
pr("WS token 验证 - 无效 token", s, b, 401)
|
||||
time.sleep(0.3)
|
||||
|
||||
s, b = curl("GET", "/ws/chat")
|
||||
pr("WS 无 token", s, b, 401)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 4.2 普通用户能否通过 WS 连接
|
||||
if user_token:
|
||||
s, b = curl("GET", f"/ws/chat?token={user_token}")
|
||||
pr("普通用户 WS 连接 (主对话)", s, b, 403)
|
||||
|
||||
# ==========================================
|
||||
# 测试 5: 文件上传端点测试
|
||||
# ==========================================
|
||||
print("\n" + "=" * 60)
|
||||
print(" 文件上传端点测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 5.1 无文件上传
|
||||
s, b = curl("POST", "/api/v1/files/upload", {"Authorization": f"Bearer {admin_token}"})
|
||||
pr("无文件字段上传", s, b, 400)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 5.2 尝试用 GET 访问上传端点
|
||||
s, b = curl("GET", "/api/v1/files/upload", {"Authorization": f"Bearer {admin_token}"})
|
||||
pr("GET 上传端点 (应405/404)", s, b, 404)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 5.3 普通用户访问文件列表 (如果普通用户token可用)
|
||||
if user_token:
|
||||
s, b = curl("GET", "/api/v1/files", {"Authorization": f"Bearer {user_token}"})
|
||||
pr("普通用户访问文件列表", s, b, None)
|
||||
|
||||
print("\n✅ Phase 2 测试完成")
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round 12 Part 2: WebSocket 测试 + 子服务直调"""
|
||||
import json, urllib.request, urllib.error, socket, struct, base64, os, hashlib, time
|
||||
|
||||
TOKEN = open("/tmp/cyrene_test_token.txt").read().strip()
|
||||
SID = open("/tmp/cyrene_test_sid.txt").read().strip()
|
||||
print(f"TOKEN={TOKEN[:20]}... SID={SID}")
|
||||
|
||||
# ====== Step 1: memory-service 正确端点测试 ======
|
||||
print("\n=== Step 1: memory-service 正确端点 ===")
|
||||
# 1a: GET /api/v1/memories?user_id=admin
|
||||
r = urllib.request.Request("http://localhost:8091/api/v1/memories?user_id=admin")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
print(f" GET /memories: {resp.status} {resp.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" GET /memories: FAIL {e}")
|
||||
|
||||
# 1b: POST /api/v1/memories/query
|
||||
r = urllib.request.Request("http://localhost:8091/api/v1/memories/query",
|
||||
data=json.dumps({"user_id":"admin","query_text":"test","limit":5}).encode())
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
print(f" POST /memories/query: {resp.status} {resp.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" POST /memories/query: FAIL {e}")
|
||||
|
||||
# 1c: 通过 gateway 代理 memory
|
||||
print("\n --- Gateway proxy to memory ---")
|
||||
headers = {"Authorization": f"Bearer {TOKEN}"}
|
||||
# GET /api/v1/memory/search?q=test
|
||||
r = urllib.request.Request("http://localhost:8080/api/v1/memory/search?q=test")
|
||||
r.add_header("Authorization", f"Bearer {TOKEN}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=10)
|
||||
print(f" GW memory/search: {resp.status} {resp.read().decode()[:200]}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" GW memory/search: {e.code} {e.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" GW memory/search: FAIL {e}")
|
||||
|
||||
# ====== Step 2: tool-engine 正确端点测试 ======
|
||||
print("\n=== Step 2: tool-engine 正确端点 ===")
|
||||
# 2a: GET /api/v1/tools
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
body = resp.read().decode()
|
||||
tools_data = json.loads(body)
|
||||
tool_names = [t.get("name","?") for t in tools_data.get("tools",[])]
|
||||
print(f" GET /tools: {resp.status} total={tools_data.get('total')} names={tool_names}")
|
||||
except Exception as e:
|
||||
print(f" GET /tools: FAIL {e}")
|
||||
|
||||
# 2b: POST /api/v1/tools/calculator/execute
|
||||
r = urllib.request.Request("http://localhost:8092/api/v1/tools/calculator/execute",
|
||||
data=json.dumps({"arguments":{"expression":"2+3"}}).encode())
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=5)
|
||||
print(f" POST calc/execute: {resp.status} {resp.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
print(f" POST calc/execute: FAIL {e}")
|
||||
|
||||
# ====== Step 3: WebSocket 测试 (手动构造) ======
|
||||
print("\n=== Step 3: WebSocket 连接测试 ===")
|
||||
WS_KEY = base64.b64encode(os.urandom(16)).decode()
|
||||
|
||||
def ws_handshake():
|
||||
"""Try WebSocket upgrade to /ws/chat?token=..."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
sock.connect(("127.0.0.1", 8080))
|
||||
request = (
|
||||
f"GET /ws/chat?token={TOKEN}&session_id={SID} HTTP/1.1\r\n"
|
||||
f"Host: localhost:8080\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {WS_KEY}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"\r\n"
|
||||
)
|
||||
sock.send(request.encode())
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response:
|
||||
response += sock.recv(4096)
|
||||
headers = response.decode()
|
||||
status_line = headers.split("\r\n")[0]
|
||||
print(f" WS handshake: {status_line}")
|
||||
if "101" in status_line:
|
||||
print(f" ✅ WebSocket 升级成功!")
|
||||
# Send a simple chat message
|
||||
msg = json.dumps({"type":"message","content":"Hello Cyrene!","mode":"text"})
|
||||
import struct as st
|
||||
frame = bytearray()
|
||||
frame.append(0x81) # FIN + text opcode
|
||||
frame.append(0x80 | len(msg)) # MASK + length
|
||||
mask_key = os.urandom(4)
|
||||
frame.extend(mask_key)
|
||||
masked = bytes([msg[i] ^ mask_key[i%4] for i in range(len(msg))])
|
||||
frame.extend(masked)
|
||||
sock.send(bytes(frame))
|
||||
print(f" Sent: {msg}")
|
||||
# Read response
|
||||
time.sleep(3)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
resp_data = sock.recv(4096)
|
||||
print(f" Received {len(resp_data)} bytes: {resp_data[:500]}")
|
||||
except socket.timeout:
|
||||
print(f" ⚠️ No response within 3s - backend may be processing")
|
||||
else:
|
||||
print(f" ❌ WebSocket handshake failed")
|
||||
print(f" Full response:\n{headers[:500]}")
|
||||
except Exception as e:
|
||||
print(f" WS connect FAIL: {e}")
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
ws_handshake()
|
||||
|
||||
# ====== Step 4: ai-core 直接调用的完整 SSE 响应 ======
|
||||
print("\n=== Step 4: ai-core 完整 SSE 响应 ===")
|
||||
ai_body = {"user_id":"admin","session_id":SID,"message":"用一句话介绍你自己","mode":"text"}
|
||||
r = urllib.request.Request("http://localhost:8081/api/v1/chat",
|
||||
data=json.dumps(ai_body).encode(), method="POST")
|
||||
r.add_header("Content-Type", "application/json")
|
||||
r.add_header("Accept", "text/event-stream")
|
||||
try:
|
||||
resp = urllib.request.urlopen(r, timeout=60)
|
||||
full = []
|
||||
for line in resp:
|
||||
line = line.decode().strip()
|
||||
if line.startswith("data:"):
|
||||
full.append(line)
|
||||
print(f" Lines received: {len(full)}")
|
||||
print(f" Last 3 lines: {full[-3:]}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
|
||||
# ====== Step 5: Gateway 通过 memory_handler 代理测试 ======
|
||||
print("\n=== Step 5: Gateway memory proxy ===")
|
||||
for path, qs in [("/api/v1/memory/search","q=hello"), ("/api/v1/memory","")]:
|
||||
url = f"http://localhost:8080{path}"
|
||||
if qs:
|
||||
url += f"?{qs}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {TOKEN}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
print(f" GET {path}: {resp.status} {resp.read().decode()[:200]}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()[:200]
|
||||
print(f" GET {path}: {e.code} {body}")
|
||||
except Exception as e:
|
||||
print(f" GET {path}: FAIL {e}")
|
||||
|
||||
print("\n[DONE]")
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==================== 配置 ====================
|
||||
ROOT_HOST="${CHROMIUM_MANAGER_HOST:-127.0.0.1}"
|
||||
ROOT_PORT="${CHROMIUM_MANAGER_PORT:-19520}"
|
||||
BASE_URL="http://$ROOT_HOST:$ROOT_PORT"
|
||||
|
||||
# ==================== 颜色 ====================
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ==================== 帮助 ====================
|
||||
show_help() {
|
||||
cat << EOF
|
||||
${CYAN}Chromium 调试管理器${NC}
|
||||
用法: ./chromium_debugging.sh <命令> [选项]
|
||||
|
||||
${GREEN}命令:${NC}
|
||||
start 启动 Chromium(带 GUI,显示在 root 桌面)
|
||||
stop 停止 Chromium
|
||||
restart 重启 Chromium
|
||||
status 查看 Chromium 状态
|
||||
debug-url 获取 WebSocket 调试地址(给 VS Code 插件用)
|
||||
log 查看 Chromium 日志(默认最近 50 行)
|
||||
log -f 实时跟踪日志
|
||||
help 显示此帮助
|
||||
|
||||
${YELLOW}环境变量:${NC}
|
||||
CHROMIUM_MANAGER_HOST 管理服务地址(默认 127.0.0.1)
|
||||
CHROMIUM_MANAGER_PORT 管理服务端口(默认 19520)
|
||||
|
||||
${CYAN}示例:${NC}
|
||||
./chromium_debugging.sh start # 启动 Chromium
|
||||
./chromium_debugging.sh status # 查看状态
|
||||
./chromium_debugging.sh debug-url # 获取调试地址
|
||||
EOF
|
||||
}
|
||||
|
||||
# ==================== API 调用 ====================
|
||||
|
||||
call_api() {
|
||||
local ENDPOINT="$1"
|
||||
local RESULT
|
||||
local HTTP_CODE
|
||||
|
||||
RESULT=$(curl -s -w "\n%{http_code}" -X GET "$BASE_URL$ENDPOINT" 2>/dev/null)
|
||||
HTTP_CODE=$(echo "$RESULT" | tail -1)
|
||||
RESULT=$(echo "$RESULT" | sed '$d')
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "{\"status\":\"error\",\"message\":\"连接管理服务失败 ($HTTP_CODE),请确认 root 上已运行 chromium-manager.sh\"}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$RESULT"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==================== 命令实现 ====================
|
||||
|
||||
cmd_start() {
|
||||
echo -e "${YELLOW}🚀 请求启动 Chromium...${NC}"
|
||||
local RESULT=$(call_api "/start")
|
||||
local STATUS=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
local PID=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('pid',''))" 2>/dev/null)
|
||||
echo -e "${GREEN}✅ Chromium 已启动 (PID: $PID)${NC}"
|
||||
echo -e " 调试地址: ${CYAN}http://$ROOT_HOST:9222${NC}"
|
||||
elif [ "$STATUS" = "error" ]; then
|
||||
local MSG=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('message','未知错误'))" 2>/dev/null)
|
||||
echo -e "${RED}❌ $MSG${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 请求失败${NC}"
|
||||
echo "$RESULT"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
echo -e "${YELLOW}🛑 请求停止 Chromium...${NC}"
|
||||
local RESULT=$(call_api "/stop")
|
||||
local STATUS=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
echo -e "${GREEN}✅ Chromium 已停止${NC}"
|
||||
else
|
||||
local MSG=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('message','未知错误'))" 2>/dev/null)
|
||||
echo -e "${YELLOW}⚠️ $MSG${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
echo -e "${YELLOW}🔄 请求重启 Chromium...${NC}"
|
||||
local RESULT=$(call_api "/restart")
|
||||
local STATUS=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
local PID=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('pid',''))" 2>/dev/null)
|
||||
echo -e "${GREEN}✅ Chromium 已重启 (PID: $PID)${NC}"
|
||||
else
|
||||
local MSG=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('message','未知错误'))" 2>/dev/null)
|
||||
echo -e "${RED}❌ $MSG${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local RESULT=$(call_api "/status")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}❌ 无法连接管理服务${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local STATUS=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" = "running" ]; then
|
||||
local PID=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('pid',''))" 2>/dev/null)
|
||||
echo -e "${GREEN}✅ Chromium 运行中${NC}"
|
||||
echo -e " PID: ${CYAN}$PID${NC}"
|
||||
echo -e " 调试端口: ${CYAN}$ROOT_HOST:9222${NC}"
|
||||
|
||||
local BROWSER=$(echo "$RESULT" | python3 -c "
|
||||
import sys,json
|
||||
data = json.load(sys.stdin)
|
||||
info = data.get('browser_info', {})
|
||||
if isinstance(info, dict):
|
||||
print(f\" 浏览器: {info.get('Browser', '未知')}\")
|
||||
ua = info.get('User-Agent', '')
|
||||
if ua:
|
||||
print(f\" 用户代理: {ua[:80]}...\")
|
||||
" 2>/dev/null)
|
||||
echo -e "$BROWSER"
|
||||
else
|
||||
echo -e "${YELLOW}⏹️ Chromium 未运行${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_debug_url() {
|
||||
local RESULT=$(call_api "/status")
|
||||
if [ $? -ne 0 ]; then return 1; fi
|
||||
|
||||
local STATUS=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
|
||||
|
||||
if [ "$STATUS" != "running" ]; then
|
||||
echo -e "${RED}❌ Chromium 未运行,请先执行 start${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 获取 WebSocket 调试地址
|
||||
local WS_URL=$(curl -s "http://$ROOT_HOST:9222/json/version" 2>/dev/null | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('webSocketDebuggerUrl', ''))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
local PAGE_WS=$(curl -s "http://$ROOT_HOST:9222/json" 2>/dev/null | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
pages = json.load(sys.stdin)
|
||||
if pages:
|
||||
print(pages[0].get('webSocketDebuggerUrl', ''))
|
||||
else:
|
||||
print('')
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$WS_URL" ]; then
|
||||
echo -e "${GREEN}🔗 调试地址:${NC}"
|
||||
echo -e " 浏览器: ${CYAN}$WS_URL${NC}"
|
||||
if [ -n "$PAGE_WS" ]; then
|
||||
echo -e " 当前页面: ${CYAN}$PAGE_WS${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 VS Code 插件配置示例:${NC}"
|
||||
echo " {"
|
||||
echo " \"type\": \"chrome\","
|
||||
echo " \"request\": \"attach\","
|
||||
echo " \"name\": \"Attach to Chromium\","
|
||||
echo " \"port\": 9222,"
|
||||
echo " \"host\": \"$ROOT_HOST\""
|
||||
echo " }"
|
||||
else
|
||||
echo -e "${RED}❌ 无法获取调试地址${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_log() {
|
||||
# 先看看管理服务能不能返回日志路径
|
||||
local LOG_PATH=$(curl -s "http://$ROOT_HOST:9222/json/version" 2>/dev/null | python3 -c "
|
||||
import sys,json
|
||||
print('ok')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$LOG_PATH" ]; then
|
||||
echo -e "${YELLOW}⚠️ 无法直接访问日志文件${NC}"
|
||||
echo " root 上的日志目录: ~/debug/logs/chromium/"
|
||||
echo " 可以在 root 上用 cat/tail 查看"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 通过 Chromium 的 API 简单确认它在运行
|
||||
echo -e "${YELLOW}📄 日志文件在 root 上: ~/debug/logs/chromium/${NC}"
|
||||
echo -e " 请在 root 终端查看,或使用 ssh root@localhost 'tail -f ~/debug/logs/chromium/chromium-*.log'"
|
||||
}
|
||||
|
||||
# ==================== 主入口 ====================
|
||||
|
||||
case "${1:-help}" in
|
||||
start)
|
||||
cmd_start
|
||||
;;
|
||||
stop)
|
||||
cmd_stop
|
||||
;;
|
||||
restart)
|
||||
cmd_restart
|
||||
;;
|
||||
status)
|
||||
cmd_status
|
||||
;;
|
||||
debug-url|debug_url|url)
|
||||
cmd_debug_url
|
||||
;;
|
||||
log)
|
||||
shift
|
||||
cmd_log "$@"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}未知命令: $1${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 最终诊断 — 检查 JS 控制台错误 + 终端渲染确认
|
||||
*/
|
||||
import { writeFileSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CDP_PORT = 9225;
|
||||
const BASE = 'http://localhost:5199';
|
||||
const API = 'http://localhost:8080/api/v1';
|
||||
const OUT = '/home/aska/Code/Cyrene/debug/logs/chromium';
|
||||
|
||||
function makeCDPHelper(ws) {
|
||||
let msgId = 1;
|
||||
const pending = new Map();
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg.result || msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = msgId++;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
pending.set(id, (r) => { clearTimeout(timer); resolve(r); });
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function main() {
|
||||
// Login
|
||||
const loginRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin123' }),
|
||||
});
|
||||
const { token, user_id: userId } = await loginRes.json();
|
||||
if (!token) throw new Error('Login failed');
|
||||
|
||||
// Use existing page
|
||||
const pages = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
const ourPage = pages.find(p => p.url === BASE || p.url === `${BASE}/`);
|
||||
if (!ourPage) throw new Error('No page found');
|
||||
|
||||
// Connect CDP
|
||||
const pw = new WebSocket(ourPage.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
pw.once('open', resolve); pw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('page WS timeout')), 5000);
|
||||
});
|
||||
const cdp = makeCDPHelper(pw);
|
||||
|
||||
await cdp('Page.enable');
|
||||
await cdp('Runtime.enable');
|
||||
await cdp('Log.enable');
|
||||
|
||||
// Collect console messages
|
||||
const consoleMessages = [];
|
||||
pw.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||
consoleMessages.push({
|
||||
type: msg.params.type,
|
||||
text: msg.params.args?.map(a => a.value ?? a.description ?? '').join(' ').slice(0, 300),
|
||||
});
|
||||
}
|
||||
if (msg.method === 'Log.entryAdded') {
|
||||
consoleMessages.push({
|
||||
type: 'log',
|
||||
text: msg.params.entry?.text?.slice(0, 300) || '',
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Inject auth, reload
|
||||
await cdp('Runtime.evaluate', { expression: `localStorage.setItem('token', ${JSON.stringify(token)}); localStorage.setItem('user_id', '${userId}');` });
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(5000);
|
||||
|
||||
// Get JS errors
|
||||
await cdp('Runtime.evaluate', {
|
||||
expression: `window.__errors = []; window.onerror = function(m,s,l,c,e) { window.__errors.push({msg:m,source:s,line:l,col:c,error:String(e)}); }; 'listening';`
|
||||
});
|
||||
|
||||
// Screenshot
|
||||
console.log('[1] Taking screenshot...');
|
||||
const ss = await cdp('Page.captureScreenshot', { format: 'png' });
|
||||
if (ss?.data) {
|
||||
writeFileSync(`${OUT}/screenshot_layout.png`, Buffer.from(ss.data, 'base64'));
|
||||
console.log(' ✅ Saved', ss.data.length, 'bytes base64');
|
||||
}
|
||||
|
||||
// Get console errors
|
||||
const jsErrors = await cdp('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `JSON.stringify(window.__errors || [])`
|
||||
});
|
||||
console.log('\n[2] JS Errors:', jsErrors?.result?.value || 'none');
|
||||
|
||||
// Console messages
|
||||
console.log('\n[3] Console messages collected:', consoleMessages.length);
|
||||
for (const m of consoleMessages) {
|
||||
console.log(` [${m.type}] ${m.text}`);
|
||||
}
|
||||
|
||||
// Check computed styles for rendering issues
|
||||
console.log('\n[4] Final rendering check...');
|
||||
const finalCheck = await cdp('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `
|
||||
JSON.stringify({
|
||||
chatInput: (() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
if (!ta) return 'NO_TEXTAREA';
|
||||
const r = ta.getBoundingClientRect();
|
||||
// Walk up to find the ChatInput wrapper
|
||||
let parent = ta.parentElement;
|
||||
for (let i = 0; i < 10 && parent; i++) {
|
||||
if (parent.className?.includes?.('border-t')) break;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
const wrapper = parent;
|
||||
return {
|
||||
textareaRect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height), w: Math.round(r.width) },
|
||||
textareaVisible: r.top < innerHeight && r.bottom > 0 && r.width > 0,
|
||||
wrapperRect: wrapper ? (() => { const wr = wrapper.getBoundingClientRect(); return { t: Math.round(wr.top), b: Math.round(wr.bottom), h: Math.round(wr.height) }; })() : null,
|
||||
wrapperBg: wrapper ? getComputedStyle(wrapper).backgroundColor : 'N/A',
|
||||
wrapperDisplay: wrapper ? getComputedStyle(wrapper).display : 'N/A',
|
||||
};
|
||||
})(),
|
||||
iotStatusBar: (() => {
|
||||
const cb = document.querySelector('.chat-background');
|
||||
if (!cb) return 'NO_CHAT_BG';
|
||||
const kids = Array.from(cb.children);
|
||||
const iotKid = kids[kids.length - 1]; // Last child of chat-background
|
||||
if (!iotKid) return 'NO_IOT_KID';
|
||||
const r = iotKid.getBoundingClientRect();
|
||||
return {
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
visible: r.top < innerHeight && r.bottom > 0 && r.height > 0,
|
||||
cls: iotKid.className?.slice?.(0, 200) || '',
|
||||
cs: { display: getComputedStyle(iotKid).display, bg: getComputedStyle(iotKid).backgroundColor },
|
||||
txt: (iotKid.textContent || '').slice(0, 100),
|
||||
};
|
||||
})(),
|
||||
overlays: (() => {
|
||||
const all = Array.from(document.querySelectorAll('*'));
|
||||
return all.filter(el => {
|
||||
const cs = getComputedStyle(el);
|
||||
return (cs.position === 'fixed' || cs.position === 'absolute') &&
|
||||
parseInt(cs.zIndex) > 0 &&
|
||||
el.clientHeight > 100;
|
||||
}).map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName,
|
||||
cls: (el.className?.slice?.(0, 150) || ''),
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), l: Math.round(r.left), r: Math.round(r.right), h: Math.round(r.height) },
|
||||
zIndex: getComputedStyle(el).zIndex,
|
||||
position: getComputedStyle(el).position,
|
||||
};
|
||||
});
|
||||
})(),
|
||||
})
|
||||
`
|
||||
});
|
||||
console.log(finalCheck?.result?.value || 'ERROR');
|
||||
|
||||
pw.close();
|
||||
console.log('\n✅ Done.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 布局诊断 v3 — 正确使用 CDP Target.createTarget + Page target
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const BASE = 'http://localhost:5199';
|
||||
const API = 'http://localhost:8080/api/v1';
|
||||
const CDP_PORT = parseInt(process.env.CDP_PORT || '9225');
|
||||
const OUT = '/home/aska/Code/Cyrene/debug/logs/chromium';
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
function makeCDPHelper(ws) {
|
||||
let msgId = 1;
|
||||
const pending = new Map();
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg.result || msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return (method, params = {}) => new Promise((resolve) => {
|
||||
const id = msgId++;
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
pending.set(id, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function main() {
|
||||
// Step 1: Login
|
||||
console.log('[1] Logging in...');
|
||||
const loginRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin123' }),
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
const token = loginData?.token;
|
||||
const userId = loginData?.user_id || 'admin';
|
||||
console.log(' userId:', userId, 'token:', token?.slice(0, 20) + '...');
|
||||
if (!token) throw new Error('Login failed');
|
||||
|
||||
// Step 2: Connect to browser CDP, create target
|
||||
console.log('[2] Getting browser WS URL...');
|
||||
const ver = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json();
|
||||
const browserWsUrl = ver.webSocketDebuggerUrl;
|
||||
console.log(' Browser WS:', browserWsUrl.slice(0, 80));
|
||||
|
||||
// Connect to browser
|
||||
const bw = new WebSocket(browserWsUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
bw.once('open', resolve);
|
||||
bw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('browser WS timeout')), 5000);
|
||||
});
|
||||
const bcdp = makeCDPHelper(bw);
|
||||
|
||||
// Create a new page target
|
||||
console.log('[3] Creating page target...');
|
||||
const targetResult = await bcdp('Target.createTarget', { url: 'about:blank', width: 1440, height: 900 });
|
||||
const targetId = targetResult?.targetId;
|
||||
console.log(' TargetId:', targetId);
|
||||
|
||||
// Get page list to find our target's WS URL
|
||||
const pages = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
const ourPage = pages.find(p => p.id === targetId || p.url === 'about:blank');
|
||||
const pageWsUrl = ourPage?.webSocketDebuggerUrl;
|
||||
console.log(' Page WS:', pageWsUrl?.slice(0, 80));
|
||||
|
||||
if (!pageWsUrl) throw new Error('Could not find page WS URL');
|
||||
|
||||
// Close browser connection, connect to page
|
||||
bw.close();
|
||||
|
||||
// Step 4: Connect to page CDP
|
||||
console.log('[4] Connecting to page target...');
|
||||
const pw = new WebSocket(pageWsUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
pw.once('open', resolve);
|
||||
pw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('page WS timeout')), 5000);
|
||||
});
|
||||
const cdp = makeCDPHelper(pw);
|
||||
|
||||
await cdp('Page.enable');
|
||||
await cdp('Runtime.enable');
|
||||
await cdp('DOM.enable');
|
||||
|
||||
// Step 5: Navigate to set origin, inject auth, reload
|
||||
console.log('[5] Setting auth...');
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(3000);
|
||||
|
||||
// Inject localStorage
|
||||
const injectResult = await cdp('Runtime.evaluate', {
|
||||
expression: `localStorage.setItem('token', ${JSON.stringify(token)}); localStorage.setItem('user_id', '${userId}'); 'injected';`
|
||||
});
|
||||
console.log(' Inject result:', JSON.stringify(injectResult).slice(0, 200));
|
||||
|
||||
// Reload to pick up auth
|
||||
console.log('[6] Reloading with auth...');
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(4000);
|
||||
|
||||
// Check if on chat page
|
||||
const checkResult = await cdp('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `JSON.stringify({ title: document.title, hasTextarea: !!document.querySelector('textarea'), bodyText: document.body?.innerText?.slice(0, 200) })`
|
||||
});
|
||||
console.log(' Page state:', checkResult?.result?.value || 'N/A');
|
||||
|
||||
// Step 7: Screenshot
|
||||
console.log('[7] Taking screenshot...');
|
||||
const ss = await cdp('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 } });
|
||||
if (ss?.data) {
|
||||
writeFileSync(`${OUT}/screenshot_layout.png`, Buffer.from(ss.data, 'base64'));
|
||||
console.log(' ✅ Saved', ss.data.length, 'chars base64');
|
||||
} else {
|
||||
console.log(' ⚠️ No screenshot data');
|
||||
}
|
||||
|
||||
// Step 8: Extract layout diagnostics
|
||||
console.log('[8] Extracting layout info...');
|
||||
const diagResult = await cdp('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `
|
||||
(() => {
|
||||
function info(el) {
|
||||
if (!el) return null;
|
||||
const s = getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName,
|
||||
cls: (el.className && typeof el.className === 'string') ? el.className.slice(0,300) : '',
|
||||
rect: { t: Math.round(r.top), l: Math.round(r.left), b: Math.round(r.bottom), r: Math.round(r.right), w: Math.round(r.width), h: Math.round(r.height) },
|
||||
display: s.display, visibility: s.visibility, opacity: s.opacity,
|
||||
overflow: s.overflow, overflowY: s.overflowY, position: s.position,
|
||||
zIndex: s.zIndex, flexShrink: s.flexShrink, flexGrow: s.flexGrow,
|
||||
clientH: el.clientHeight, scrollH: el.scrollHeight,
|
||||
offsetParent: el.offsetParent ? el.offsetParent.tagName : null,
|
||||
};
|
||||
}
|
||||
const R = {};
|
||||
R.viewport = { w: innerWidth, h: innerHeight };
|
||||
R.root = info(document.getElementById('root'));
|
||||
R.hScreen = info(document.querySelector('.h-screen'));
|
||||
R.main = info(document.querySelector('main'));
|
||||
|
||||
const m = document.querySelector('main');
|
||||
if (m && m.firstElementChild) {
|
||||
R.appWrapper = info(m.firstElementChild);
|
||||
R.appWrapperChildren = Array.from(m.firstElementChild.children).map((c,i) => ({
|
||||
idx: i,
|
||||
cls: (c.className && typeof c.className === 'string') ? c.className.slice(0,200) : '',
|
||||
...info(c),
|
||||
}));
|
||||
}
|
||||
|
||||
R.chatBackground = info(document.querySelector('.chat-background'));
|
||||
const cb = document.querySelector('.chat-background');
|
||||
if (cb) {
|
||||
R.chatBgChildren = Array.from(cb.children).map((c,i) => ({
|
||||
idx: i,
|
||||
cls: (c.className && typeof c.className === 'string') ? c.className.slice(0,200) : '',
|
||||
...info(c),
|
||||
}));
|
||||
}
|
||||
|
||||
R.textarea = info(document.querySelector('textarea'));
|
||||
|
||||
R.shrink0 = Array.from(document.querySelectorAll('.flex-shrink-0')).map(el => ({
|
||||
text: (el.textContent||'').slice(0,60),
|
||||
...info(el),
|
||||
}));
|
||||
|
||||
R.borderT = Array.from(document.querySelectorAll('.border-t')).map(el => ({
|
||||
text: (el.textContent||'').slice(0,100),
|
||||
...info(el),
|
||||
}));
|
||||
|
||||
return JSON.stringify(R);
|
||||
})()
|
||||
`
|
||||
});
|
||||
|
||||
const diagValue = diagResult?.result?.value || diagResult?.value || '{}';
|
||||
const result = typeof diagValue === 'string' ? JSON.parse(diagValue) : diagValue;
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
writeFileSync(`${OUT}/diagnostics.json`, JSON.stringify(result, null, 2));
|
||||
|
||||
pw.close();
|
||||
console.log('\n✅ Done');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 布局诊断 v4 — 针对 ChatInput + IoTStatusBar 不可见问题
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const BASE = 'http://localhost:5199';
|
||||
const API = 'http://localhost:8080/api/v1';
|
||||
const CDP_PORT = parseInt(process.env.CDP_PORT || '9225');
|
||||
const OUT = '/home/aska/Code/Cyrene/debug/logs/chromium';
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
function makeCDPHelper(ws) {
|
||||
let msgId = 1;
|
||||
const pending = new Map();
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg.result || msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = msgId++;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, 15000);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
pending.set(id, (r) => { clearTimeout(timer); resolve(r); });
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function evaluate(cdp, expression, label) {
|
||||
try {
|
||||
const r = await cdp('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression,
|
||||
});
|
||||
const val = r?.result?.value ?? r?.value;
|
||||
if (label) console.log(` [${label}]:`, typeof val === 'string' ? val.slice(0, 500) : JSON.stringify(val).slice(0, 500));
|
||||
return val;
|
||||
} catch (e) {
|
||||
console.error(` [${label}] ERROR:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Step 1: Login
|
||||
console.log('[1] Logging in...');
|
||||
const loginRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin123' }),
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
const token = loginData?.token;
|
||||
const userId = loginData?.user_id || 'admin';
|
||||
console.log(' userId:', userId, 'token:', token?.slice(0, 20) + '...');
|
||||
if (!token) throw new Error('Login failed: ' + JSON.stringify(loginData));
|
||||
|
||||
// Step 2: Connect to browser CDP, create target
|
||||
console.log('[2] Getting browser WS URL...');
|
||||
const ver = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json();
|
||||
const browserWsUrl = ver.webSocketDebuggerUrl;
|
||||
|
||||
const bw = new WebSocket(browserWsUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
bw.once('open', resolve);
|
||||
bw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('browser WS timeout')), 5000);
|
||||
});
|
||||
const bcdp = makeCDPHelper(bw);
|
||||
|
||||
// Create a new page target
|
||||
console.log('[3] Creating page target...');
|
||||
const targetResult = await bcdp('Target.createTarget', { url: 'about:blank', width: 1440, height: 900 });
|
||||
const targetId = targetResult?.targetId;
|
||||
|
||||
const pages = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
const ourPage = pages.find(p => p.id === targetId || p.url === 'about:blank');
|
||||
const pageWsUrl = ourPage?.webSocketDebuggerUrl;
|
||||
if (!pageWsUrl) throw new Error('Could not find page WS URL');
|
||||
|
||||
bw.close();
|
||||
|
||||
// Step 4: Connect to page CDP
|
||||
console.log('[4] Connecting to page target...');
|
||||
const pw = new WebSocket(pageWsUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
pw.once('open', resolve);
|
||||
pw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('page WS timeout')), 5000);
|
||||
});
|
||||
const cdp = makeCDPHelper(pw);
|
||||
|
||||
await cdp('Page.enable');
|
||||
await cdp('Runtime.enable');
|
||||
await cdp('DOM.enable');
|
||||
|
||||
// Step 5: Navigate, inject auth, reload
|
||||
console.log('[5] Navigating and setting auth...');
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(3000);
|
||||
|
||||
await cdp('Runtime.evaluate', {
|
||||
expression: `localStorage.setItem('token', ${JSON.stringify(token)}); localStorage.setItem('user_id', '${userId}'); 'injected';`
|
||||
});
|
||||
|
||||
console.log('[6] Reloading with auth...');
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(5000);
|
||||
|
||||
// Check page state
|
||||
const pageState = await evaluate(cdp,
|
||||
`JSON.stringify({ title: document.title, hasTextarea: !!document.querySelector('textarea'), bodyClasses: document.body.className, rootExists: !!document.getElementById('root') })`,
|
||||
'PageState'
|
||||
);
|
||||
console.log(' Page state:', pageState);
|
||||
|
||||
// Step 7: Screenshot
|
||||
console.log('[7] Taking screenshot...');
|
||||
const ss = await cdp('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 } });
|
||||
if (ss?.data) {
|
||||
writeFileSync(`${OUT}/screenshot_layout.png`, Buffer.from(ss.data, 'base64'));
|
||||
console.log(' ✅ Screenshot saved');
|
||||
}
|
||||
|
||||
// ============ DETAILED DIAGNOSTICS ============
|
||||
console.log('\n[8] === HEIGHT CHAIN DIAGNOSIS ===');
|
||||
|
||||
// 8a: Viewport
|
||||
const vp = await evaluate(cdp,
|
||||
`JSON.stringify({ w: innerWidth, h: innerHeight, scrollY: scrollY, scrollX: scrollX })`,
|
||||
'Viewport'
|
||||
);
|
||||
|
||||
// 8b: Height chain: #root → .h-screen → main → App wrapper → ChatContainer
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const results = [];
|
||||
const root = document.getElementById('root');
|
||||
results.push({ id: '#root', exists: !!root, clientH: root?.clientHeight, scrollH: root?.scrollHeight,
|
||||
cs: root ? getComputedStyle(root).height : 'N/A' });
|
||||
|
||||
const hScreen = document.querySelector('.h-screen');
|
||||
results.push({ id: '.h-screen', exists: !!hScreen, clientH: hScreen?.clientHeight, scrollH: hScreen?.scrollHeight,
|
||||
cs: hScreen ? getComputedStyle(hScreen).height : 'N/A', csDisplay: hScreen ? getComputedStyle(hScreen).display : 'N/A' });
|
||||
|
||||
const main = document.querySelector('main');
|
||||
results.push({ id: 'main', exists: !!main, clientH: main?.clientHeight, scrollH: main?.scrollHeight,
|
||||
cs: main ? getComputedStyle(main).height : 'N/A', csOverflow: main ? getComputedStyle(main).overflow : 'N/A' });
|
||||
|
||||
const flexColFull = document.querySelector('.flex.flex-col.h-full.overflow-hidden') ||
|
||||
document.querySelector('main > div');
|
||||
results.push({ id: 'App flex-col', exists: !!flexColFull, clientH: flexColFull?.clientHeight, scrollH: flexColFull?.scrollHeight,
|
||||
className: flexColFull?.className?.slice(0, 200) || 'N/A' });
|
||||
|
||||
const chatBg = document.querySelector('.chat-background');
|
||||
results.push({ id: '.chat-background', exists: !!chatBg, clientH: chatBg?.clientHeight, scrollH: chatBg?.scrollHeight,
|
||||
rect: chatBg ? (()=>{const r=chatBg.getBoundingClientRect(); return {t:r.top,b:r.bottom,h:r.height};})() : null });
|
||||
|
||||
const textarea = document.querySelector('textarea');
|
||||
results.push({ id: 'textarea', exists: !!textarea,
|
||||
rect: textarea ? (()=>{const r=textarea.getBoundingClientRect(); return {t:Math.round(r.top),b:Math.round(r.bottom),l:r.left,r:r.right,w:Math.round(r.width),h:Math.round(r.height)};})() : null,
|
||||
display: textarea ? getComputedStyle(textarea).display : 'N/A',
|
||||
visibility: textarea ? getComputedStyle(textarea).visibility : 'N/A',
|
||||
opacity: textarea ? getComputedStyle(textarea).opacity : 'N/A' });
|
||||
|
||||
return JSON.stringify(results, null, 2);
|
||||
})()
|
||||
`, 'HeightChain');
|
||||
|
||||
// 8c: ChatInput wrapper and IoTStatusBar wrapper
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const results = [];
|
||||
|
||||
// Find ChatInput wrapper (flex-shrink-0 after flex-1)
|
||||
const flexShrinkDivs = Array.from(document.querySelectorAll('.flex-shrink-0'));
|
||||
for (const div of flexShrinkDivs) {
|
||||
const r = div.getBoundingClientRect();
|
||||
const hasTextarea = div.querySelector('textarea');
|
||||
const hasIoT = div.querySelector('button[title*="IoT"]') || div.textContent?.includes('IoT');
|
||||
results.push({
|
||||
element: hasTextarea ? 'ChatInput wrapper' : (div.textContent?.includes('IoT') ? 'IoT wrapper' : 'other flex-shrink-0'),
|
||||
className: div.className?.slice(0, 200) || '',
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), l: Math.round(r.left), h: Math.round(r.height) },
|
||||
clientH: div.clientHeight,
|
||||
display: getComputedStyle(div).display,
|
||||
visibility: getComputedStyle(div).visibility,
|
||||
hasTextarea: !!hasTextarea,
|
||||
textContent: (div.textContent || '').slice(0, 80),
|
||||
});
|
||||
}
|
||||
|
||||
// Find border-t elements (ChatInput has border-t)
|
||||
const borderT = Array.from(document.querySelectorAll('.border-t'));
|
||||
for (const el of borderT) {
|
||||
const r = el.getBoundingClientRect();
|
||||
results.push({
|
||||
element: 'border-t element',
|
||||
className: (el.className?.slice(0, 200) || ''),
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
clientH: el.clientHeight,
|
||||
display: getComputedStyle(el).display,
|
||||
visible: r.top < innerHeight && r.bottom > 0,
|
||||
textContent: (el.textContent || '').slice(0, 100),
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(results, null, 2);
|
||||
})()
|
||||
`, 'FlexShrinkAndBorder');
|
||||
|
||||
// 8d: Direct query for ChatInput and IoTStatusBar components
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const results = {};
|
||||
|
||||
// All textareas
|
||||
const textareas = Array.from(document.querySelectorAll('textarea'));
|
||||
results.textareas = textareas.map((ta, i) => {
|
||||
const r = ta.getBoundingClientRect();
|
||||
let parent = ta.parentElement;
|
||||
const parentChain = [];
|
||||
for (let j = 0; j < 6 && parent; j++) {
|
||||
parentChain.push({
|
||||
tag: parent.tagName,
|
||||
cls: (parent.className?.slice(0, 150) || ''),
|
||||
clientH: parent.clientHeight,
|
||||
scrollH: parent.scrollHeight,
|
||||
display: getComputedStyle(parent).display,
|
||||
overflow: getComputedStyle(parent).overflow,
|
||||
rect: (() => { const pr = parent.getBoundingClientRect(); return { t: Math.round(pr.top), b: Math.round(pr.bottom), h: Math.round(pr.height) }; })(),
|
||||
});
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return {
|
||||
idx: i,
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width), h: Math.round(r.height) },
|
||||
clientH: ta.clientHeight,
|
||||
display: getComputedStyle(ta).display,
|
||||
visibility: getComputedStyle(ta).visibility,
|
||||
opacity: getComputedStyle(ta).opacity,
|
||||
isVisible: r.top < innerHeight && r.bottom > 0 && r.width > 0 && r.height > 0,
|
||||
parentChain,
|
||||
};
|
||||
});
|
||||
|
||||
// All divs with IoT-related text
|
||||
const allDivs = Array.from(document.querySelectorAll('div'));
|
||||
results.iotDivs = allDivs
|
||||
.filter(d => (d.textContent || '').includes('IoT'))
|
||||
.map(d => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return {
|
||||
text: (d.textContent || '').slice(0, 100),
|
||||
cls: (d.className?.slice(0, 200) || ''),
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
clientH: d.clientHeight,
|
||||
display: getComputedStyle(d).display,
|
||||
visibility: getComputedStyle(d).visibility,
|
||||
isVisible: r.top < innerHeight && r.bottom > 0 && r.width > 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Check if #root children are rendered
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
results.rootChildren = Array.from(root.children).map((c, i) => ({
|
||||
idx: i,
|
||||
tag: c.tagName,
|
||||
cls: (c.className?.slice(0, 200) || ''),
|
||||
childCount: c.children.length,
|
||||
clientH: c.clientHeight,
|
||||
rect: (() => { const r = c.getBoundingClientRect(); return { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) }; })(),
|
||||
}));
|
||||
}
|
||||
|
||||
return JSON.stringify(results, null, 2);
|
||||
})()
|
||||
`, 'DetailedElements');
|
||||
|
||||
// 8e: Check what's inside main element
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main) return 'NO MAIN ELEMENT FOUND!';
|
||||
|
||||
const results = {
|
||||
mainClientH: main.clientHeight,
|
||||
mainChildCount: main.children.length,
|
||||
children: Array.from(main.children).map((c, i) => ({
|
||||
idx: i,
|
||||
tag: c.tagName,
|
||||
cls: (c.className?.slice(0, 200) || ''),
|
||||
clientH: c.clientHeight,
|
||||
scrollH: c.scrollHeight,
|
||||
rect: (() => { const r = c.getBoundingClientRect(); return { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) }; })(),
|
||||
display: getComputedStyle(c).display,
|
||||
overflow: getComputedStyle(c).overflow,
|
||||
childCount: c.children.length,
|
||||
grandchildren: Array.from(c.children).map((gc, j) => ({
|
||||
idx: j,
|
||||
tag: gc.tagName,
|
||||
cls: (gc.className?.slice(0, 200) || ''),
|
||||
clientH: gc.clientHeight,
|
||||
rect: (() => { const r = gc.getBoundingClientRect(); return { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) }; })(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(results, null, 2);
|
||||
})()
|
||||
`, 'MainChildren');
|
||||
|
||||
// Write diagnostics
|
||||
console.log('\n[9] Collecting final diagnostics...');
|
||||
const finalDiag = await evaluate(cdp, `
|
||||
(() => {
|
||||
const D = {};
|
||||
D.viewport = { w: innerWidth, h: innerHeight };
|
||||
|
||||
D.chatInputVisible = (() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
if (!ta) return { reason: 'NO_TEXTAREA_FOUND' };
|
||||
const r = ta.getBoundingClientRect();
|
||||
const cs = getComputedStyle(ta);
|
||||
return {
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width), h: Math.round(r.height) },
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
isInViewport: r.top < innerHeight && r.bottom > 0,
|
||||
isBelowFold: r.top >= innerHeight,
|
||||
isAboveFold: r.bottom <= 0,
|
||||
hasSize: r.width > 0 && r.height > 0,
|
||||
};
|
||||
})();
|
||||
|
||||
D.iotStatusBarVisible = (() => {
|
||||
const iotDivs = Array.from(document.querySelectorAll('div')).filter(d => (d.textContent || '').includes('IoT 设备'));
|
||||
if (iotDivs.length === 0) return { reason: 'NO_IOT_DIV_FOUND' };
|
||||
const el = iotDivs[0];
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
isInViewport: r.top < innerHeight && r.bottom > 0,
|
||||
isBelowFold: r.top >= innerHeight,
|
||||
};
|
||||
})();
|
||||
|
||||
// Check all parent heights
|
||||
D.parentHeights = [];
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
let el = root;
|
||||
let depth = 0;
|
||||
while (el && depth < 10) {
|
||||
D.parentHeights.push({
|
||||
depth,
|
||||
tag: el.tagName,
|
||||
cls: (el.className?.slice(0, 150) || ''),
|
||||
clientH: el.clientHeight,
|
||||
scrollH: el.scrollHeight,
|
||||
csHeight: getComputedStyle(el).height,
|
||||
csOverflow: getComputedStyle(el).overflow,
|
||||
csDisplay: getComputedStyle(el).display,
|
||||
});
|
||||
el = el.children[0];
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check what's in the main content area
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
D.mainInfo = {
|
||||
clientH: main.clientHeight,
|
||||
scrollH: main.scrollHeight,
|
||||
csHeight: getComputedStyle(main).height,
|
||||
csOverflow: getComputedStyle(main).overflow,
|
||||
childCount: main.children.length,
|
||||
childTags: Array.from(main.children).map(c => ({ tag: c.tagName, cls: (c.className?.slice(0, 100) || ''), clientH: c.clientHeight })),
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(D, null, 2);
|
||||
})()
|
||||
`, 'FinalDiagnosis');
|
||||
|
||||
writeFileSync(`${OUT}/diagnostics.json`, JSON.stringify(JSON.parse(finalDiag), null, 2));
|
||||
console.log('\n✅ Diagnostics saved to diagnostics.json');
|
||||
|
||||
pw.close();
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 精准诊断 — 定位 IoTStatusBar 和 ChatInput 在 DOM 中的确切位置
|
||||
*/
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CDP_PORT = parseInt(process.env.CDP_PORT || '9225');
|
||||
const BASE = 'http://localhost:5199';
|
||||
const API = 'http://localhost:8080/api/v1';
|
||||
|
||||
function makeCDPHelper(ws) {
|
||||
let msgId = 1;
|
||||
const pending = new Map();
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg.result || msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = msgId++;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 15000);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
pending.set(id, (r) => { clearTimeout(timer); resolve(r); });
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function evaluate(cdp, expression, label) {
|
||||
try {
|
||||
const r = await cdp('Runtime.evaluate', { returnByValue: true, expression });
|
||||
const val = r?.result?.value ?? r?.value;
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log(typeof val === 'string' ? val : JSON.stringify(val, null, 2));
|
||||
return val;
|
||||
} catch (e) {
|
||||
console.error(`[${label}] ERROR:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Login
|
||||
const loginRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin123' }),
|
||||
});
|
||||
const { token, user_id: userId } = await loginRes.json();
|
||||
if (!token) throw new Error('Login failed');
|
||||
|
||||
// Connect to existing page or create new one
|
||||
let pages = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
console.log('Pages:', pages.map(p => ({ id: p.id?.slice(0, 20), url: p.url?.slice(0, 60) })));
|
||||
|
||||
// Prefer existing localhost page, or navigate a page to localhost
|
||||
let ourPage = pages.find(p => p.url === BASE || p.url === `${BASE}/`);
|
||||
let needNavigate = false;
|
||||
|
||||
if (!ourPage) {
|
||||
// Create new target
|
||||
const ver = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json();
|
||||
const bw = new WebSocket(ver.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
bw.once('open', resolve);
|
||||
bw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('browser WS timeout')), 5000);
|
||||
});
|
||||
const bcdp = makeCDPHelper(bw);
|
||||
await bcdp('Target.createTarget', { url: 'about:blank', width: 1440, height: 900 });
|
||||
bw.close();
|
||||
await sleep(1000);
|
||||
pages = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
|
||||
ourPage = pages.find(p => p.url === 'about:blank');
|
||||
needNavigate = true;
|
||||
}
|
||||
|
||||
if (!ourPage) throw new Error('No page found');
|
||||
console.log('Using page:', { id: ourPage.id?.slice(0, 20), url: ourPage.url });
|
||||
|
||||
// Connect to page
|
||||
const pw = new WebSocket(ourPage.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
pw.once('open', resolve);
|
||||
pw.once('error', reject);
|
||||
setTimeout(() => reject(new Error('page WS timeout')), 5000);
|
||||
});
|
||||
const cdp = makeCDPHelper(pw);
|
||||
await cdp('Page.enable');
|
||||
await cdp('Runtime.enable');
|
||||
|
||||
// Navigate, inject auth, reload
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(3000);
|
||||
await cdp('Runtime.evaluate', { expression: `localStorage.setItem('token', ${JSON.stringify(token)}); localStorage.setItem('user_id', '${userId}');` });
|
||||
await cdp('Page.navigate', { url: BASE });
|
||||
await sleep(5000);
|
||||
|
||||
// === DIAGNOSIS 1: Main element's full DOM structure ===
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main) return 'NO_MAIN';
|
||||
|
||||
function describe(el, maxDepth) {
|
||||
if (!el || maxDepth <= 0) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const txt = (el.textContent || '').replace(/\\s+/g, ' ').slice(0, 80);
|
||||
return {
|
||||
tag: el.tagName,
|
||||
cls: (el.className?.slice?.(0, 200) || ''),
|
||||
id: el.id || '',
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width), h: Math.round(r.height) },
|
||||
cs: { display: cs.display, overflow: cs.overflow, height: cs.height, flexShrink: cs.flexShrink, flexGrow: cs.flexGrow },
|
||||
clientH: el.clientHeight, scrollH: el.scrollHeight,
|
||||
txt,
|
||||
kids: maxDepth > 1 ? Array.from(el.children).map(c => describe(c, maxDepth - 1)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return describe(main, 5);
|
||||
})()
|
||||
`, 'MainDOMTree');
|
||||
|
||||
// === DIAGNOSIS 2: Find ChatContainer (.chat-background) and its children ===
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const cb = document.querySelector('.chat-background');
|
||||
if (!cb) return 'NO_CHAT_BACKGROUND';
|
||||
const r = cb.getBoundingClientRect();
|
||||
const cs = getComputedStyle(cb);
|
||||
|
||||
const kids = Array.from(cb.children).map((c, i) => {
|
||||
const cr = c.getBoundingClientRect();
|
||||
const ccs = getComputedStyle(c);
|
||||
return {
|
||||
idx: i,
|
||||
tag: c.tagName,
|
||||
cls: (c.className?.slice?.(0, 200) || ''),
|
||||
rect: { t: Math.round(cr.top), b: Math.round(cr.bottom), h: Math.round(cr.height) },
|
||||
cs: { display: ccs.display, flexShrink: ccs.flexShrink, flexGrow: ccs.flexGrow, height: ccs.height },
|
||||
clientH: c.clientHeight,
|
||||
txt: (c.textContent || '').replace(/\\s+/g, ' ').slice(0, 100),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
chatBgRect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
chatBgCS: { height: cs.height, overflow: cs.overflow, display: cs.display },
|
||||
chatBgClientH: cb.clientHeight,
|
||||
children: kids,
|
||||
};
|
||||
})()
|
||||
`, 'ChatContainerChildren');
|
||||
|
||||
// === DIAGNOSIS 3: Check if IoTStatusBar is rendered at all ===
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
// Search for all elements that might be the IoTStatusBar
|
||||
const results = [];
|
||||
|
||||
// Strategy 1: Find div with "IoT" text that is a direct child of .chat-background
|
||||
const cb = document.querySelector('.chat-background');
|
||||
if (cb) {
|
||||
const iotInChatBg = Array.from(cb.querySelectorAll('*')).filter(el =>
|
||||
el.textContent?.includes('IoT') && el.children.length === 0
|
||||
);
|
||||
results.push({
|
||||
strategy: 'IoT_leaf_in_chatBg',
|
||||
count: iotInChatBg.length,
|
||||
elements: iotInChatBg.slice(0, 3).map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName,
|
||||
txt: (el.textContent || '').slice(0, 80),
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Strategy 2: Find .border-t elements inside .chat-background
|
||||
if (cb) {
|
||||
const borderTInCb = Array.from(cb.querySelectorAll('.border-t'));
|
||||
results.push({
|
||||
strategy: 'borderT_in_chatBg',
|
||||
count: borderTInCb.length,
|
||||
elements: borderTInCb.map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
let parentCls = '';
|
||||
let p = el.parentElement;
|
||||
for (let i = 0; i < 3 && p; i++) {
|
||||
parentCls += (p.className?.slice?.(0, 100) || '') + ' | ';
|
||||
p = p.parentElement;
|
||||
}
|
||||
return {
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
txt: (el.textContent || '').replace(/\\s+/g, ' ').slice(0, 100),
|
||||
parentChain: parentCls,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Strategy 3: Find all flex-shrink-0 inside .chat-background
|
||||
if (cb) {
|
||||
const shrinkInCb = Array.from(cb.querySelectorAll('.flex-shrink-0'));
|
||||
results.push({
|
||||
strategy: 'flexShrink0_in_chatBg',
|
||||
count: shrinkInCb.length,
|
||||
elements: shrinkInCb.map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
cls: (el.className?.slice?.(0, 200) || ''),
|
||||
txt: (el.textContent || '').replace(/\\s+/g, ' ').slice(0, 100),
|
||||
display: getComputedStyle(el).display,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`, 'IoTStatusBarSearch');
|
||||
|
||||
// === DIAGNOSIS 4: Get complete rect for every child of the App wrapper ===
|
||||
await evaluate(cdp, `
|
||||
(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main?.firstElementChild) return 'NO_MAIN_CHILD';
|
||||
const appWrapper = main.firstElementChild;
|
||||
return {
|
||||
appWrapperCls: appWrapper.className?.slice?.(0, 200),
|
||||
appWrapperRect: (() => { const r = appWrapper.getBoundingClientRect(); return { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) }; })(),
|
||||
children: Array.from(appWrapper.children).map((c, i) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
const cs = getComputedStyle(c);
|
||||
return {
|
||||
idx: i,
|
||||
cls: (c.className?.slice?.(0, 200) || ''),
|
||||
rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },
|
||||
cs: { display: cs.display, flexGrow: cs.flexGrow, flexShrink: cs.flexShrink, height: cs.height, overflow: cs.overflow },
|
||||
clientH: c.clientHeight,
|
||||
childCount: c.children.length,
|
||||
firstChildCls: c.firstElementChild ? (c.firstElementChild.className?.slice?.(0, 150) || '') : '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
})()
|
||||
`, 'AppWrapperChildren');
|
||||
|
||||
pw.close();
|
||||
console.log('\n✅ Done.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('Fatal:', err); process.exit(1); });
|
||||
@@ -0,0 +1 @@
|
||||
31823
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"viewport": {
|
||||
"w": 1440,
|
||||
"h": 813
|
||||
},
|
||||
"chatInputVisible": {
|
||||
"rect": {
|
||||
"t": 763,
|
||||
"b": 801,
|
||||
"l": 577,
|
||||
"r": 1140,
|
||||
"w": 563,
|
||||
"h": 38
|
||||
},
|
||||
"display": "block",
|
||||
"visibility": "visible",
|
||||
"opacity": "1",
|
||||
"isInViewport": true,
|
||||
"isBelowFold": false,
|
||||
"isAboveFold": false,
|
||||
"hasSize": true
|
||||
},
|
||||
"iotStatusBarVisible": {
|
||||
"rect": {
|
||||
"t": 0,
|
||||
"b": 813,
|
||||
"h": 813
|
||||
},
|
||||
"display": "block",
|
||||
"visibility": "visible",
|
||||
"opacity": "1",
|
||||
"isInViewport": true,
|
||||
"isBelowFold": false
|
||||
},
|
||||
"parentHeights": [
|
||||
{
|
||||
"depth": 0,
|
||||
"tag": "DIV",
|
||||
"cls": "",
|
||||
"clientH": 813,
|
||||
"scrollH": 813,
|
||||
"csHeight": "813px",
|
||||
"csOverflow": "hidden",
|
||||
"csDisplay": "block"
|
||||
},
|
||||
{
|
||||
"depth": 1,
|
||||
"tag": "DIV",
|
||||
"cls": "flex h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]",
|
||||
"clientH": 813,
|
||||
"scrollH": 813,
|
||||
"csHeight": "813px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "flex"
|
||||
},
|
||||
{
|
||||
"depth": 2,
|
||||
"tag": "DIV",
|
||||
"cls": "\n fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-300 ease-in-out\n -translate-x-full lg:t",
|
||||
"clientH": 813,
|
||||
"scrollH": 813,
|
||||
"csHeight": "813px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "block"
|
||||
},
|
||||
{
|
||||
"depth": 3,
|
||||
"tag": "ASIDE",
|
||||
"cls": "h-full bg-white/70 dark:bg-gray-900/70 backdrop-blur-md border-r border-pink-100 dark:border-pink-900 flex flex-col",
|
||||
"clientH": 813,
|
||||
"scrollH": 813,
|
||||
"csHeight": "813px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "flex"
|
||||
},
|
||||
{
|
||||
"depth": 4,
|
||||
"tag": "DIV",
|
||||
"cls": "p-4 border-b border-pink-100 dark:border-pink-900",
|
||||
"clientH": 72,
|
||||
"scrollH": 72,
|
||||
"csHeight": "73px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "block"
|
||||
},
|
||||
{
|
||||
"depth": 5,
|
||||
"tag": "BUTTON",
|
||||
"cls": "w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-pink-400 hover:bg-pink-500 text-white rounded-xl text-sm font-medium transition-colors",
|
||||
"clientH": 40,
|
||||
"scrollH": 40,
|
||||
"csHeight": "40px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "flex"
|
||||
},
|
||||
{
|
||||
"depth": 6,
|
||||
"tag": "SPAN",
|
||||
"cls": "",
|
||||
"clientH": 20,
|
||||
"scrollH": 20,
|
||||
"csHeight": "20px",
|
||||
"csOverflow": "visible",
|
||||
"csDisplay": "block"
|
||||
}
|
||||
],
|
||||
"mainInfo": {
|
||||
"clientH": 756,
|
||||
"scrollH": 756,
|
||||
"csHeight": "756px",
|
||||
"csOverflow": "hidden",
|
||||
"childCount": 1,
|
||||
"childTags": [
|
||||
{
|
||||
"tag": "DIV",
|
||||
"cls": "flex flex-col h-full overflow-hidden",
|
||||
"clientH": 756
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@@ -1,49 +0,0 @@
|
||||
|
||||
**项目开发文档管理规范 (修订版)**
|
||||
|
||||
**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 可启动各前后端服务,请牢记这个流程喵!
|
||||
|
||||
**5. 数据库连接**
|
||||
|
||||
- 开发阶段的数据库连接请使用 `scripts/tunnel.sh` 脚本建立到数据库服务器的连接。
|
||||
|
||||
**6. 版本提交规范**
|
||||
|
||||
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
|
||||
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
|
||||
|
||||
**7. 测试脚本临时管理**
|
||||
|
||||
- 在测试长脚本或复杂命令时,可以在项目根目录临时创建 `test` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
|
||||
- **注意:** 用完记得及时删除喵~
|
||||
@@ -25,7 +25,7 @@ export const SERVICES = {
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
IOT_DEBUG_SERVICE_URL: process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
@@ -88,7 +88,7 @@ export const SERVICES = {
|
||||
env: {
|
||||
PORT: '8092',
|
||||
DB_URL: process.env.DB_URL || 'postgres://cyrene:change_me@localhost:5432/cyrene_ai?sslmode=disable',
|
||||
IOT_SERVICE_URL: process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8092/api/v1/health',
|
||||
port: 8092,
|
||||
|
||||
@@ -489,7 +489,7 @@ app.delete('/api/logs/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// ---- IoT 设备管理 (代理到 iot-debug-service) ----
|
||||
const IOT_SERVICE_URL = process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083';
|
||||
const IOT_SERVICE_URL = process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083';
|
||||
|
||||
/** 通用 IoT 代理:转发请求到 iot-debug-service */
|
||||
async function proxyToIoT(path, opts = {}) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
refreshToken,
|
||||
setToken,
|
||||
getToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
} from './client';
|
||||
|
||||
+200
-55
@@ -1,9 +1,154 @@
|
||||
// HTTP 客户端封装
|
||||
// HTTP 客户端封装 — 带 Token 自动刷新拦截器
|
||||
|
||||
import type { AuthResponse } from '@/types/session';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
/** localStorage key 前缀 */
|
||||
const LS_TOKEN_KEY = 'token';
|
||||
const LS_REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
const LS_USER_ID_KEY = 'user_id';
|
||||
|
||||
// ========== Token 刷新队列(避免多个并发请求同时刷新 token) ==========
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
/**
|
||||
* 等待中的请求队列。当 token 刷新完成后,用新 token 重放这些请求。
|
||||
*/
|
||||
type QueuedRequest = {
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
let refreshQueue: QueuedRequest[] = [];
|
||||
|
||||
function onRefreshDone(newToken: string | null) {
|
||||
for (const q of refreshQueue) {
|
||||
q.resolve(newToken);
|
||||
}
|
||||
refreshQueue = [];
|
||||
}
|
||||
|
||||
function onRefreshFailed(err: unknown) {
|
||||
for (const q of refreshQueue) {
|
||||
q.reject(err);
|
||||
}
|
||||
refreshQueue = [];
|
||||
}
|
||||
|
||||
// ========== 存储辅助函数 ==========
|
||||
|
||||
/** 存储认证令牌 */
|
||||
export function setToken(token: string) {
|
||||
try {
|
||||
localStorage.setItem(LS_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取认证令牌 */
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LS_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取刷新令牌 */
|
||||
export function getRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LS_REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 存储 tokens */
|
||||
export function setTokens(accessToken: string, refreshTokenValue?: string) {
|
||||
setToken(accessToken);
|
||||
if (refreshTokenValue) {
|
||||
try {
|
||||
localStorage.setItem(LS_REFRESH_TOKEN_KEY, refreshTokenValue);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除认证令牌 */
|
||||
export function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(LS_TOKEN_KEY);
|
||||
localStorage.removeItem(LS_REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(LS_USER_ID_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 检查是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// ========== Token 刷新逻辑 ==========
|
||||
|
||||
/**
|
||||
* 尝试刷新 token。如果已经有正在进行的刷新请求,等待它完成。
|
||||
*/
|
||||
async function tryRefreshToken(): Promise<string | null> {
|
||||
const refreshTokenValue = getRefreshToken();
|
||||
if (!refreshTokenValue) {
|
||||
// 没有 refresh token,无法刷新
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已经有刷新正在进行中,加入队列等待
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshQueue.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${refreshTokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshTokenValue }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
if (data.token) {
|
||||
setTokens(data.token, data.refresh_token);
|
||||
return data.token;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// 刷新失败
|
||||
clearToken();
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const newToken = await refreshPromise;
|
||||
onRefreshDone(newToken);
|
||||
return newToken;
|
||||
} catch (err) {
|
||||
onRefreshFailed(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求选项 */
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
@@ -20,70 +165,70 @@ interface ApiResponse<T = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送API请求
|
||||
* 发送 API 请求,内置 401 自动刷新拦截
|
||||
*/
|
||||
async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
|
||||
const { method = 'GET', body, auth = true } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const makeRequest = async (tokenOverride?: string): Promise<ApiResponse<T>> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = tokenOverride || (auth ? getToken() : null);
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
// 401 且当前请求需要认证、且不是 refresh 接口本身、且尚未重试
|
||||
if (response.status === 401 && auth && endpoint !== '/auth/refresh' && !tokenOverride) {
|
||||
const newToken = await tryRefreshToken();
|
||||
if (newToken) {
|
||||
// 用新 token 重试
|
||||
return makeRequest(newToken);
|
||||
}
|
||||
// 刷新失败,清除状态,跳转登录页
|
||||
clearToken();
|
||||
// 触发页面重新加载以显示登录页
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return {
|
||||
error: '认证已过期,请重新登录',
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as T, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { data: data as T, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
return makeRequest();
|
||||
}
|
||||
|
||||
/** 存储认证令牌 */
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
/** 获取认证令牌 */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/** 清除认证令牌 */
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
|
||||
/** 检查是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// ========== 认证API ==========
|
||||
// ========== 认证 API ==========
|
||||
|
||||
export async function login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/login', {
|
||||
@@ -92,8 +237,8 @@ export async function login(username: string, password: string): Promise<ApiResp
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
@@ -105,8 +250,8 @@ export async function register(username: string, password: string, email: string
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
@@ -114,12 +259,12 @@ export async function register(username: string, password: string, email: string
|
||||
export async function refreshToken(): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/refresh', { method: 'POST' });
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ========== 会话API ==========
|
||||
// ========== 会话 API ==========
|
||||
|
||||
export async function createSession(title?: string) {
|
||||
return request('/sessions', { method: 'POST', body: { title } });
|
||||
@@ -141,7 +286,7 @@ export async function fetchSessionMessages(id: string) {
|
||||
return request(`/sessions/${id}/messages`);
|
||||
}
|
||||
|
||||
// ========== 记忆API ==========
|
||||
// ========== 记忆 API ==========
|
||||
|
||||
export async function searchMemory(query: string) {
|
||||
return request(`/memory/search?q=${encodeURIComponent(query)}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { MessageList } from './MessageList';
|
||||
import { IoTStatusBar } from './IoTStatusBar';
|
||||
|
||||
@@ -43,7 +44,18 @@ export function ChatContainer() {
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<MessageList messages={messages} isTyping={isTyping} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
hasMoreMessages={useChatStore((s) => s.hasMoreMessages)}
|
||||
isLoadingHistory={useChatStore((s) => s.isLoadingHistory)}
|
||||
onLoadMore={() => {
|
||||
const sessionId = useSessionStore.getState().currentSessionId;
|
||||
if (sessionId) {
|
||||
useSessionStore.getState().loadMoreMessagesFromServer(sessionId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IoT 状态栏(底部) */}
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||
import type { MessageAttachment, MultiMessageItem, StreamSegment } from '@/types/chat';
|
||||
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
role: 'user' | 'assistant' | 'system' | 'action';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
multiMessages?: MultiMessageItem[];
|
||||
streamSegments?: StreamSegment[];
|
||||
msgType?: MessageDisplayType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,8 +140,24 @@ function AIMessageActions({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming, attachments, multiMessages, streamSegments }: MessageBubbleProps) {
|
||||
export function MessageBubble({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
isStreaming,
|
||||
attachments,
|
||||
multiMessages,
|
||||
streamSegments,
|
||||
msgType,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const isAction = role === 'action' || msgType === 'action';
|
||||
|
||||
// 动作消息使用独立的渲染方式
|
||||
if (isAction) {
|
||||
return <ActionMessageBubble content={content} timestamp={timestamp} />;
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -263,6 +280,27 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
|
||||
);
|
||||
}
|
||||
|
||||
/** 动作消息气泡 — 灰色/斜体/居中,视觉上与聊天消息区分 */
|
||||
function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) {
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 py-1 animate-fadeIn">
|
||||
<div className="max-w-[70%] text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-relaxed whitespace-pre-wrap break-words">
|
||||
<span className="select-none text-gray-300 dark:text-gray-600">~ </span>
|
||||
{content}
|
||||
<span className="select-none text-gray-300 dark:text-gray-600"> ~</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-300 dark:text-gray-600 mt-0.5">{time}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 图片缩略图组件 */
|
||||
function ImageThumbnail({
|
||||
attachment,
|
||||
|
||||
@@ -6,17 +6,29 @@ import type { Message } from '@/types/chat';
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
hasMoreMessages?: boolean;
|
||||
isLoadingHistory?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
export function MessageList({
|
||||
messages,
|
||||
isTyping,
|
||||
hasMoreMessages = false,
|
||||
isLoadingHistory = false,
|
||||
onLoadMore,
|
||||
}: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
// 自动滚动到底部(仅在消息追加时,不在加载历史时)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
if (!isLoadingHistory) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, isTyping, isLoadingHistory]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (messages.length === 0 && !isLoadingHistory) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
@@ -27,7 +39,33 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900"
|
||||
>
|
||||
{/* 加载更多历史消息按钮 */}
|
||||
{hasMoreMessages && (
|
||||
<div className="flex justify-center py-3">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingHistory}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 disabled:text-gray-300 disabled:cursor-not-allowed px-4 py-1.5 rounded-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-pink-200 dark:hover:border-pink-800 transition-all"
|
||||
>
|
||||
{isLoadingHistory ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</span>
|
||||
) : (
|
||||
'加载更早的消息'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
@@ -36,8 +74,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
attachments={msg.attachments}
|
||||
multiMessages={(msg as any).multiMessages}
|
||||
streamSegments={(msg as any).streamSegments}
|
||||
msgType={msg.msgType}
|
||||
/>
|
||||
))}
|
||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore, checkAndMigrateStore } from '@/store/authStore';
|
||||
import { refreshToken, getToken } from '@/api/client';
|
||||
|
||||
/** 兼容旧代码的 Hook 导出 — 现在基于共享 Zustand store */
|
||||
export function useAuth() {
|
||||
return useAuthStore();
|
||||
const store = useAuthStore();
|
||||
|
||||
// 应用启动时检查 localStorage 版本并尝试自动刷新 token
|
||||
useEffect(() => {
|
||||
// 检查 localStorage 数据版本兼容性
|
||||
checkAndMigrateStore();
|
||||
|
||||
// 如果已登录,尝试验证 token 有效性
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
// 尝试刷新 token 来验证其有效性
|
||||
refreshToken()
|
||||
.then((resp) => {
|
||||
if (resp.error || !resp.data?.token) {
|
||||
// token 无效,清除状态
|
||||
console.log('[useAuth] token 刷新失败,清除认证状态');
|
||||
store.clearAuth();
|
||||
}
|
||||
// 刷新成功,token 已由 refreshToken 自动存储
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络错误等,不强制清除(可能是离线状态)
|
||||
console.warn('[useAuth] token 验证网络错误,保留当前状态');
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅在挂载时运行一次
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,42 @@ import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useNotificationStore } from '@/store/notificationStore';
|
||||
import { getToken } from '@/api/client';
|
||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
import type { Message, WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
|
||||
const WS_BASE_URL =
|
||||
import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws/chat';
|
||||
|
||||
// ========== 指数退避重连配置 ==========
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1000; // 初始延迟 1 秒
|
||||
const MAX_RECONNECT_DELAY_MS = 30000; // 最大延迟 30 秒
|
||||
const MAX_RECONNECT_ATTEMPTS = 10; // 最大重连次数
|
||||
const RECONNECT_MULTIPLIER = 2; // 每次翻倍
|
||||
|
||||
/**
|
||||
* 计算带 jitter 的指数退避延迟
|
||||
* delay = min(initial * multiplier^attempt, maxDelay)
|
||||
* jitter = delay * random(0.5, 1.0),避免惊群效应
|
||||
*/
|
||||
function getBackoffDelay(attempt: number): number {
|
||||
const exponentialDelay = INITIAL_RECONNECT_DELAY_MS * Math.pow(RECONNECT_MULTIPLIER, attempt);
|
||||
const cappedDelay = Math.min(exponentialDelay, MAX_RECONNECT_DELAY_MS);
|
||||
// 添加 jitter:在 [delay/2, delay] 范围内随机
|
||||
const jitter = cappedDelay * (0.5 + Math.random() * 0.5);
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
let wsInstanceCounter = 0;
|
||||
|
||||
export function useWebSocket() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [maxReconnectAttempts] = useState(MAX_RECONNECT_ATTEMPTS);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
const activeSessionRef = useRef<string | null>(null);
|
||||
const reconnectCountRef = useRef(0);
|
||||
const instanceIdRef = useRef(++wsInstanceCounter);
|
||||
|
||||
// 订阅 sessionStore 中的 currentSessionId 变化
|
||||
@@ -29,6 +52,21 @@ export function useWebSocket() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否超过最大重连次数
|
||||
if (reconnectCountRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.error(`[WS#${instanceId}] 已达到最大重连次数 (${MAX_RECONNECT_ATTEMPTS}),停止重连`);
|
||||
setReconnectAttempts(reconnectCountRef.current);
|
||||
// 通知用户连接失败
|
||||
useChatStore.getState().setTyping(false);
|
||||
useChatStore.getState().addMessage({
|
||||
id: 'err_max_reconnect_' + Date.now(),
|
||||
role: 'system',
|
||||
content: `⚠️ WebSocket 连接失败,已尝试重连 ${MAX_RECONNECT_ATTEMPTS} 次,请刷新页面后重试`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭旧连接
|
||||
if (wsRef.current) {
|
||||
console.log(`[WS#${instanceId}] 关闭旧连接`);
|
||||
@@ -42,12 +80,15 @@ export function useWebSocket() {
|
||||
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
|
||||
: `${WS_BASE_URL}?token=${token}`;
|
||||
|
||||
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}`);
|
||||
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}, reconnectAttempt=${reconnectCountRef.current}`);
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
shouldReconnectRef.current = true;
|
||||
// 连接成功后重置退避计数器
|
||||
reconnectCountRef.current = 0;
|
||||
setReconnectAttempts(0);
|
||||
console.log(`[WS#${instanceId}] 已连接, session_id:`, sessionID);
|
||||
|
||||
// 连接后发送会话恢复消息,恢复后端上下文
|
||||
@@ -67,11 +108,17 @@ export function useWebSocket() {
|
||||
setIsConnected(false);
|
||||
console.log(`[WS#${instanceId}] 已断开`);
|
||||
if (shouldReconnectRef.current) {
|
||||
console.log(`[WS#${instanceId}] 3秒后重连...`);
|
||||
const currentAttempt = reconnectCountRef.current;
|
||||
const delay = getBackoffDelay(currentAttempt);
|
||||
console.log(`[WS#${instanceId}] ${delay}ms 后重连 (第 ${currentAttempt + 1}/${MAX_RECONNECT_ATTEMPTS} 次)...`);
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
reconnectTimerRef.current = setTimeout(() => connect(), 3000);
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectCountRef.current += 1;
|
||||
setReconnectAttempts(reconnectCountRef.current);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,6 +142,10 @@ export function useWebSocket() {
|
||||
useEffect(() => {
|
||||
activeSessionRef.current = currentSessionId;
|
||||
|
||||
// 重置重连计数(切换会话时是新连接)
|
||||
reconnectCountRef.current = 0;
|
||||
setReconnectAttempts(0);
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
@@ -137,7 +188,7 @@ export function useWebSocket() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isConnected, sendMessage };
|
||||
return { isConnected, sendMessage, reconnectAttempts, maxReconnectAttempts };
|
||||
}
|
||||
|
||||
function handleServerMessage(msg: WSServerMessage) {
|
||||
@@ -200,7 +251,7 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
);
|
||||
break;
|
||||
}
|
||||
const msgsWithIds = msg.messages.map((m: any, i: number) => ({
|
||||
const msgsWithIds: Message[] = msg.messages.map((m, i) => ({
|
||||
...m,
|
||||
id: m.id || `hist_${i}_${Date.now()}`,
|
||||
}));
|
||||
@@ -210,6 +261,52 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'review':
|
||||
// 审查子会话消息 — 后端返回带类型的 review_messages 列表
|
||||
if (msg.review_messages && msg.review_messages.length > 0) {
|
||||
// 逐条显示审查消息,action 类型使用动作消息样式,chat 类型使用普通聊天样式
|
||||
for (const rm of msg.review_messages) {
|
||||
addMessage({
|
||||
id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: rm.type === 'action' ? 'action' : 'assistant',
|
||||
content: rm.content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: false,
|
||||
msgType: rm.type === 'action' ? 'action' : 'chat',
|
||||
});
|
||||
}
|
||||
}
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'multi_message':
|
||||
case 'stream_segments':
|
||||
// 多段消息 / 流式片段 — 已通过 stream_chunk 处理,这里作为兜底
|
||||
if (msg.multi_messages && msg.multi_messages.length > 0) {
|
||||
for (const item of msg.multi_messages) {
|
||||
addMessage({
|
||||
id: `multi_${Date.now()}_${item.index}`,
|
||||
role: 'assistant',
|
||||
content: item.content,
|
||||
timestamp: msg.timestamp || Date.now(),
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (msg.stream_segments && msg.stream_segments.length > 0) {
|
||||
for (const seg of msg.stream_segments) {
|
||||
addMessage({
|
||||
id: `seg_${Date.now()}_${seg.index}`,
|
||||
role: 'assistant',
|
||||
content: seg.text,
|
||||
timestamp: msg.timestamp || Date.now(),
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'device_update':
|
||||
if (msg.devices && msg.devices.length > 0) {
|
||||
chatState.setIoTDevices(msg.devices);
|
||||
|
||||
@@ -69,6 +69,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 动作消息淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* ===== 流式渲染动画 ===== */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
|
||||
@@ -3,24 +3,58 @@
|
||||
* 用于跨组件共享登录/退出状态
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
|
||||
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken, getRefreshToken, setTokens } from '@/api/client';
|
||||
|
||||
/** localStorage key 前缀 */
|
||||
const LS_VERSION_KEY = 'cyrene_store_version';
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
/** 所有 cyrene_ 前缀的 localStorage keys,logout 时全部清除 */
|
||||
const CYRENE_KEYS = [
|
||||
'token',
|
||||
'refresh_token',
|
||||
'user_id',
|
||||
'user_nickname',
|
||||
'cyrene_store_version',
|
||||
];
|
||||
|
||||
function clearAllCyreneData(): void {
|
||||
for (const key of CYRENE_KEYS) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
isLoggedIn: boolean;
|
||||
userId: string | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
register: (username: string, password: string, email: string, nickname: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
setTokens: (accessToken: string, refreshToken?: string) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
isLoggedIn: isAuthenticated(),
|
||||
userId: localStorage.getItem('user_id'),
|
||||
token: getToken(),
|
||||
refreshToken: getRefreshToken(),
|
||||
loading: false,
|
||||
|
||||
setTokens: (accessToken: string, refreshTokenValue?: string) => {
|
||||
setTokens(accessToken, refreshTokenValue);
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
token: accessToken,
|
||||
refreshToken: refreshTokenValue || null,
|
||||
});
|
||||
},
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
@@ -33,6 +67,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
refreshToken: resp.data?.refresh_token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
@@ -58,6 +93,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
refreshToken: resp.data?.refresh_token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
@@ -69,16 +105,50 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
||||
|
||||
logout: () => {
|
||||
clearToken();
|
||||
localStorage.removeItem('user_nickname');
|
||||
clearAllCyreneData();
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
clearToken();
|
||||
clearAllCyreneData();
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 在应用启动时检查 localStorage 数据版本
|
||||
* 如果版本不兼容,清除所有数据
|
||||
*/
|
||||
export function checkAndMigrateStore(): void {
|
||||
try {
|
||||
const storedVersion = localStorage.getItem(LS_VERSION_KEY);
|
||||
if (storedVersion) {
|
||||
const version = parseInt(storedVersion, 10);
|
||||
if (version !== CURRENT_VERSION) {
|
||||
console.log(`[store] localStorage 版本不兼容 (${version} → ${CURRENT_VERSION}),清除旧数据`);
|
||||
clearAllCyreneData();
|
||||
}
|
||||
}
|
||||
// 写入当前版本
|
||||
localStorage.setItem(LS_VERSION_KEY, String(CURRENT_VERSION));
|
||||
} catch {
|
||||
// 忽略存储错误
|
||||
}
|
||||
}
|
||||
|
||||
/** 兼容旧代码的 Hook 导出 */
|
||||
export function useAuth() {
|
||||
return useAuthStore();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Message } from '@/types/chat';
|
||||
import type { Message, MessageDisplayType } from '@/types/chat';
|
||||
import type { IoTDevice, BackgroundThinkingStatus } from '@/types/chat';
|
||||
|
||||
interface ChatStore {
|
||||
@@ -16,6 +16,11 @@ interface ChatStore {
|
||||
iotDevices: IoTDevice[];
|
||||
iotDevicesLastUpdated: number | null;
|
||||
|
||||
// 历史消息分页
|
||||
hasMoreMessages: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
historyPage: number;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
appendToLastMessage: (content: string) => void;
|
||||
finishStreaming: () => void;
|
||||
@@ -26,6 +31,12 @@ interface ChatStore {
|
||||
setContinuousMode: (enabled: boolean) => void;
|
||||
setBackgroundThinkingStatus: (status: BackgroundThinkingStatus) => void;
|
||||
setIoTDevices: (devices: IoTDevice[]) => void;
|
||||
|
||||
// 历史消息分页
|
||||
setHasMoreMessages: (hasMore: boolean) => void;
|
||||
setIsLoadingHistory: (loading: boolean) => void;
|
||||
setHistoryPage: (page: number) => void;
|
||||
prependMessages: (messages: Message[]) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
@@ -35,6 +46,9 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
backgroundThinkingStatus: 'idle',
|
||||
iotDevices: [],
|
||||
iotDevicesLastUpdated: null,
|
||||
hasMoreMessages: false,
|
||||
isLoadingHistory: false,
|
||||
historyPage: 1,
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
@@ -77,7 +91,7 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
|
||||
setTyping: (typing) => set({ isTyping: typing }),
|
||||
|
||||
clearMessages: () => set({ messages: [], isTyping: false }),
|
||||
clearMessages: () => set({ messages: [], isTyping: false, hasMoreMessages: false, historyPage: 1 }),
|
||||
|
||||
setContinuousMode: (enabled) => set({ continuousMode: enabled }),
|
||||
|
||||
@@ -85,4 +99,31 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
|
||||
setIoTDevices: (devices) =>
|
||||
set({ iotDevices: devices, iotDevicesLastUpdated: Date.now() }),
|
||||
|
||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||
setIsLoadingHistory: (loading) => set({ isLoadingHistory: loading }),
|
||||
setHistoryPage: (page) => set({ historyPage: page }),
|
||||
|
||||
prependMessages: (olderMessages) =>
|
||||
set((state) => ({
|
||||
messages: [...olderMessages, ...state.messages],
|
||||
})),
|
||||
}));
|
||||
|
||||
// 辅助函数:根据 role 和 msgType 创建 Message 对象
|
||||
export function createMessage(
|
||||
id: string,
|
||||
role: 'user' | 'assistant' | 'system' | 'action',
|
||||
content: string,
|
||||
timestamp: number,
|
||||
opts?: { isStreaming?: boolean; msgType?: MessageDisplayType }
|
||||
): Message {
|
||||
return {
|
||||
id,
|
||||
role: role === 'action' ? 'action' : role,
|
||||
content,
|
||||
timestamp,
|
||||
isStreaming: opts?.isStreaming ?? false,
|
||||
msgType: opts?.msgType ?? (role === 'action' ? 'action' : 'chat'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export function isAdminUser(userId: string | null): boolean {
|
||||
return userId === 'admin';
|
||||
}
|
||||
|
||||
/** 每页加载的消息数量 */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface SessionStore {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
@@ -47,6 +50,7 @@ interface SessionStore {
|
||||
// 服务端持久化操作
|
||||
loadSessionsFromServer: (userId: string) => Promise<void>;
|
||||
loadMessagesFromServer: (sessionId: string) => Promise<void>;
|
||||
loadMoreMessagesFromServer: (sessionId: string) => Promise<void>;
|
||||
clearMainSessionMessages: (sessionId: string) => Promise<boolean>;
|
||||
deleteSessionAndRefresh: (id: string, userId: string) => Promise<void>;
|
||||
deleteAllSessionsAndReset: (userId: string) => Promise<void>;
|
||||
@@ -82,11 +86,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
},
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setMessages: (messages) => {
|
||||
// 仅在当前版本号未过期时设置消息
|
||||
set((state) => {
|
||||
// 使用 state 快照做防御性检查:_loadVersion 在 set 回调中是最新的
|
||||
return { messages, loading: false };
|
||||
});
|
||||
set({ messages, loading: false });
|
||||
useChatStore.getState().setMessages(messages);
|
||||
},
|
||||
clearMessages: () => {
|
||||
@@ -110,22 +110,24 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
},
|
||||
|
||||
/**
|
||||
* 从服务端加载指定会话的消息历史
|
||||
* 从服务端加载指定会话的消息历史 (首次加载,第1页)
|
||||
* 使用 _loadVersion 防止竞态条件:响应返回时如果版本号已变(用户切换到其他会话)则丢弃结果
|
||||
*/
|
||||
loadMessagesFromServer: async (sessionId: string) => {
|
||||
// 记录请求发起时的版本号
|
||||
const versionAtStart = get()._loadVersion;
|
||||
const chatStore = useChatStore.getState();
|
||||
chatStore.setIsLoadingHistory(true);
|
||||
chatStore.setHistoryPage(1);
|
||||
set({ loading: true });
|
||||
|
||||
try {
|
||||
const resp = await fetchMessages(sessionId);
|
||||
// 竞态条件检查:响应返回时版本号应未变,且当前会话仍为请求的会话
|
||||
const resp = await fetchMessages(sessionId, PAGE_SIZE);
|
||||
// 竞态条件检查
|
||||
const currentState = get();
|
||||
if (
|
||||
currentState._loadVersion !== versionAtStart ||
|
||||
currentState.currentSessionId !== sessionId
|
||||
) {
|
||||
// 用户已切换到其他会话,丢弃此过期响应
|
||||
console.log(
|
||||
'[sessionStore] 丢弃过期的 loadMessagesFromServer 响应:',
|
||||
`sessionId=${sessionId}`,
|
||||
@@ -134,18 +136,24 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMessages = resp.messages || [];
|
||||
const msgs: Message[] = rawMessages.map((m: any, i: number) => ({
|
||||
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: typeof m.created_at === 'number' ? m.created_at : Date.now(),
|
||||
isStreaming: false,
|
||||
}));
|
||||
const msgs: Message[] = rawMessages.map((m, i: number) => {
|
||||
const raw = m as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: raw.id ? String(raw.id) : `hist_${i}_${Date.now()}`,
|
||||
role: (raw.role as Message['role']) || 'assistant',
|
||||
content: typeof raw.content === 'string' ? raw.content : '',
|
||||
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
|
||||
isStreaming: false as const,
|
||||
};
|
||||
});
|
||||
|
||||
set({ messages: msgs, loading: false });
|
||||
useChatStore.getState().setMessages(msgs);
|
||||
chatStore.setMessages(msgs);
|
||||
// 如果返回消息数量等于 PAGE_SIZE,说明可能还有更多
|
||||
chatStore.setHasMoreMessages(rawMessages.length >= PAGE_SIZE);
|
||||
} catch {
|
||||
// 同样检查版本号,避免错误响应的空数组覆盖新会话的消息
|
||||
const currentState = get();
|
||||
if (
|
||||
currentState._loadVersion !== versionAtStart ||
|
||||
@@ -154,7 +162,72 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
set({ messages: [], loading: false });
|
||||
useChatStore.getState().clearMessages();
|
||||
chatStore.clearMessages();
|
||||
} finally {
|
||||
useChatStore.getState().setIsLoadingHistory(false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载更早的历史消息 (分页加载)
|
||||
*/
|
||||
loadMoreMessagesFromServer: async (sessionId: string) => {
|
||||
const versionAtStart = get()._loadVersion;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
if (chatStore.isLoadingHistory || !chatStore.hasMoreMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatStore.setIsLoadingHistory(true);
|
||||
const nextPage = chatStore.historyPage + 1;
|
||||
|
||||
try {
|
||||
const resp = await fetchMessages(sessionId, PAGE_SIZE);
|
||||
// 竞态条件检查
|
||||
const currentState = get();
|
||||
if (
|
||||
currentState._loadVersion !== versionAtStart ||
|
||||
currentState.currentSessionId !== sessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMessages = resp.messages || [];
|
||||
// 服务端返回的是最新的消息,我们需要取比当前消息更旧的部分
|
||||
// 由于后端当前不支持 offset/pagination,这里采用简单策略:
|
||||
// 如果返回的条数与当前消息数不同,说明有新消息,重新加载
|
||||
const currentMsgCount = chatStore.messages.length;
|
||||
|
||||
if (rawMessages.length > currentMsgCount) {
|
||||
// 有新消息,取更早的
|
||||
const olderMessages: Message[] = rawMessages
|
||||
.slice(0, rawMessages.length - currentMsgCount)
|
||||
.map((m, i: number) => {
|
||||
const raw = m as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: raw.id ? String(raw.id) : `hist_old_${i}_${Date.now()}`,
|
||||
role: (raw.role as Message['role']) || 'assistant',
|
||||
content: typeof raw.content === 'string' ? raw.content : '',
|
||||
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
|
||||
isStreaming: false as const,
|
||||
};
|
||||
});
|
||||
|
||||
if (olderMessages.length > 0) {
|
||||
chatStore.prependMessages(olderMessages);
|
||||
chatStore.setHistoryPage(nextPage);
|
||||
}
|
||||
chatStore.setHasMoreMessages(olderMessages.length >= PAGE_SIZE);
|
||||
} else {
|
||||
chatStore.setHasMoreMessages(false);
|
||||
}
|
||||
|
||||
set({ loading: false });
|
||||
} catch (err) {
|
||||
console.error('[sessionStore] 加载更多消息失败:', err);
|
||||
} finally {
|
||||
useChatStore.getState().setIsLoadingHistory(false);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 聊天相关类型定义
|
||||
|
||||
/** 消息角色 */
|
||||
export type MessageRole = 'user' | 'assistant' | 'system';
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
|
||||
|
||||
/** 消息显示类型 (区分聊天消息与动作消息) */
|
||||
export type MessageDisplayType = 'chat' | 'action' | 'system';
|
||||
|
||||
/** 对话模式 */
|
||||
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
|
||||
@@ -29,6 +32,12 @@ export interface MessageAttachment {
|
||||
description?: string; // AI 对图片的描述
|
||||
}
|
||||
|
||||
/** 审查消息 (后端审查子会话输出的带类型消息) */
|
||||
export interface ReviewMessage {
|
||||
type: 'action' | 'chat';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 单条消息 */
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -39,6 +48,8 @@ export interface Message {
|
||||
attachments?: MessageAttachment[];
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
/** 消息显示类型: 区分聊天消息与动作消息 */
|
||||
msgType?: MessageDisplayType;
|
||||
}
|
||||
|
||||
/** IoT 设备类型定义 */
|
||||
@@ -111,7 +122,7 @@ export interface AppNotification extends NotificationData {
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments';
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review';
|
||||
message_id?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
@@ -125,6 +136,7 @@ export interface WSServerMessage {
|
||||
messages?: Message[];
|
||||
multi_messages?: MultiMessageItem[];
|
||||
stream_segments?: StreamSegment[];
|
||||
review_messages?: ReviewMessage[];
|
||||
devices?: IoTDevice[];
|
||||
thinking_status?: BackgroundThinkingStatus;
|
||||
notification?: NotificationData;
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface SessionListResponse {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
/** 会话消息列表响应 */
|
||||
/** 会话消息列表响应 (API 返回的原始数据,字段名如 created_at 与服务端一致) */
|
||||
export interface SessionMessagesResponse {
|
||||
messages: import('@/types/chat').Message[];
|
||||
messages: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** 单个会话响应 */
|
||||
@@ -41,6 +41,7 @@ export interface SessionResponse {
|
||||
export interface AuthResponse {
|
||||
user_id: string;
|
||||
token: string;
|
||||
refresh_token?: string;
|
||||
expires: number;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user