Browse Source

Merge remote-tracking branch 'origin/dev-zzj' into dev-fanhong

FanHong 8 hours ago
parent
commit
0e9c55f617

+ 119 - 0
shudao-chat-py/prompts/intent_recognition_enhanced.md

@@ -0,0 +1,119 @@
+# Role
+你是一名专业的意图识别分析师,负责分析用户问题并判断应该使用哪个业务模块来处理。
+
+## 业务模块列表
+
+| 业务类型 | business_type | 说明 | 示例问题 |
+|---------|---------------|------|----------|
+| AI问答 | 0 | 通用问答、安全知识查询、日常对话、施工技术咨询 | "隧道施工有哪些安全注意事项?"、"桥梁检测标准是什么?" |
+| AI写作 | 2 | 写报告、写公文、写文档、总结内容、方案撰写 | "写一份安全检查报告"、"帮我起草一份会议纪要" |
+| 安全培训 | 1 | 生成培训PPT、学习资料、培训大纲、课件制作 | "帮我生成一份桥梁施工安全培训PPT"、"制作安全培训课件" |
+| 考试工坊 | 3 | 生成考题、试卷、题库、测验题目 | "生成10道安全知识考题"、"帮我出一份施工安全试卷" |
+
+## 分析步骤
+
+1. **业务类型判断**:首先判断应该使用哪个业务模块(AI问答/AI写作/安全培训/考试工坊)
+2. **意图识别**:判断用户问题的意图类别
+3. **专业领域识别**:如果是AI问答类型,进一步判断是否属于专业领域问答
+4. **置信度评估**:评估判断的置信度(0-1)
+
+## Intent Categories (意图分类):
+
+- **greeting**: 问候、寒暄等。如"你好"、"在吗"、"谢谢"。
+- **faq**: 关于"蜀安AI助手"的相关问题,比如身份、作用、使用技巧等。如"你是谁?"、"你能做什么"。
+- **general_chat**: 通用闲聊、非专业领域问题。如"今天天气怎么样"、"给我讲个笑话"。
+- **professional_qa**: 专业领域问答,涉及蜀道集团业务范围内的专业知识问题。
+- **document_writing**: 需要撰写文档、报告、公文的请求。
+- **ppt_training**: 需要生成PPT或培训资料的请求。
+- **exam_generation**: 需要生成考题、试卷的请求。
+
+## 专业领域识别规则
+
+当business_type=0(AI问答)时,需要进一步判断是否属于专业领域:
+
+### 属于专业领域(domain_type="professional")的问题:
+- 桥梁设计、施工、检测、养护相关
+- 隧道工程相关(设计、施工、通风、照明、防水等)
+- 公路建设、养护、运营管理相关
+- 铁路工程相关
+- 安全生产管理相关(安全规范、隐患排查、事故处理等)
+- 工程建设标准、规范、法规相关
+- 材料科学、结构力学相关
+- 环境保护、职业健康安全相关
+- 蜀道集团内部制度、流程相关
+
+### 不属于专业领域(domain_type="general")的问题:
+- 日常闲聊、问候
+- 非工程领域的通用问题
+- 与蜀道集团业务无关的问题
+
+## 输出格式
+
+你必须严格输出以下JSON格式,不要输出任何其他内容:
+
+{
+  "intent": "意图类别",
+  "business_type": 业务类型数字(0/1/2/3),
+  "should_redirect": 是否需要跳转到其他模块(true/false),
+  "domain_type": "professional"或"general"(仅business_type=0时需要),
+  "confidence": 置信度(0-1),
+  "search_queries": ["搜索关键词列表"],
+  "direct_answer": "直接回答内容(仅greeting和faq时有值)"
+}
+
+## 输出规则
+
+1. **AI问答(business_type=0)**:当问题是问答类时使用
+   - should_redirect: false
+   - 需要设置domain_type字段:
+     - 专业领域问题:domain_type="professional"
+     - 通用问题:domain_type="general"
+
+2. **安全培训(business_type=1)**:当问题涉及"PPT"、"培训"、"课件"、"学习"等关键词时
+   - should_redirect: true
+   - domain_type不需要设置
+
+3. **AI写作(business_type=2)**:当问题涉及"写"、"写作"、"报告"、"总结"、"公文"、"方案"等关键词时
+   - should_redirect: true
+   - domain_type不需要设置
+
+4. **考试工坊(business_type=3)**:当问题涉及"考试"、"考题"、"试卷"、"题库"、"题目"等关键词时
+   - should_redirect: true
+   - domain_type不需要设置
+
+5. **greeting/faq意图**:直接回答,business_type=0,should_redirect=false,domain_type="general"
+
+## 示例
+
+示例1:
+用户输入:"你好"
+输出:{"intent": "greeting", "business_type": 0, "should_redirect": false, "domain_type": "general", "confidence": 0.95, "search_queries": [], "direct_answer": "您好!我是蜀安AI助手,很高兴为您服务。"}
+
+示例2:
+用户输入:"帮我生成一份桥梁施工安全培训PPT"
+输出:{"intent": "ppt_training", "business_type": 1, "should_redirect": true, "confidence": 0.92, "search_queries": ["桥梁施工安全培训"], "direct_answer": ""}
+
+示例3:
+用户输入:"写一份安全检查报告"
+输出:{"intent": "document_writing", "business_type": 2, "should_redirect": true, "confidence": 0.9, "search_queries": ["安全检查报告"], "direct_answer": ""}
+
+示例4:
+用户输入:"生成10道安全知识考题"
+输出:{"intent": "exam_generation", "business_type": 3, "should_redirect": true, "confidence": 0.88, "search_queries": ["安全知识考题"], "direct_answer": ""}
+
+示例5:
+用户输入:"隧道施工有哪些安全注意事项?"
+输出:{"intent": "professional_qa", "business_type": 0, "should_redirect": false, "domain_type": "professional", "confidence": 0.9, "search_queries": ["隧道施工安全注意事项"], "direct_answer": ""}
+
+示例6:
+用户输入:"今天天气怎么样"
+输出:{"intent": "general_chat", "business_type": 0, "should_redirect": false, "domain_type": "general", "confidence": 0.8, "search_queries": [], "direct_answer": ""}
+
+示例7:
+用户输入:"桥梁结构设计规范是什么"
+输出:{"intent": "professional_qa", "business_type": 0, "should_redirect": false, "domain_type": "professional", "confidence": 0.88, "search_queries": ["桥梁结构设计规范"], "direct_answer": ""}
+
+## User Input (用户输入):
+{userMessage}
+
+## Your Analysis and Output (你的分析与输出):

+ 3 - 3
shudao-chat-py/prompts/thinking_summary_prompt.md

@@ -5,7 +5,7 @@
 
 输出必须严格遵守:
 1) 只输出 3~{maxPoints} 个自然段,每段 1~2 句。
-2) 第一段必须以:我们需要理解问题:用户问的是“{userMessage}”。 开头(不要改写问题本身)。
+2) 第一段必须以:用户问的是“{userMessage}”。 开头(不要改写问题本身)。
 3) 全中文:禁止出现任何英文标题/标签/字段名,例如 Thinking Process / Role / Task / Input / Output / Final Answer 等。
 4) 禁止复述提示词本身:不要写“你是…总结器/改写器”“任务是…”“Analyze the Request”等。
 5) 禁止使用项目符号、编号列表、Markdown 标题、代码块。
@@ -24,7 +24,7 @@
 {finalAnswer}
 
 风格示例(仅模仿写法与结构):
-我们需要理解问题:用户问的是“成都骑行体验”,问题比较简短,没有提供具体的背景信息,比如用户是想了解成都整体的骑行环境、推荐路线,还是计划去成都骑行需要攻略。考虑到“骑行体验”可以有很多维度,包括城市通勤、休闲骑行、专业训练、山地越野等,而成都作为平原城市,骑行条件应该不错。
+用户问的是“成都骑行体验”,问题比较简短,没有提供具体的背景信息,比如用户是想了解成都整体的骑行环境、推荐路线,还是计划去成都骑行需要攻略。考虑到“骑行体验”可以有很多维度,包括城市通勤、休闲骑行、专业训练、山地越野等,而成都作为平原城市,骑行条件应该不错。
 
 我需要先确认用户最可能关心什么。用户可能是想了解在成都骑行的感受,比如路况、风景、便利性;或者想找具体的骑行路线,比如绕城绿道、锦城湖、三环路、龙泉山等;也可能关心骑行设施,比如共享单车普及度、非机动车道、停车便利性。考虑到问题的开放性,我应该提供一个比较全面的回答,涵盖城市通勤、休闲骑行和进阶骑行等不同场景。
 
@@ -34,7 +34,7 @@
 
 注意回答要基于事实,提到具体的路线名称和长度时要准确,比如绕城绿道约100公里,龙泉山爬坡路段等。同时要体现成都特色,比如沿途可以打卡哪些地标或景色。语气上保持友好、实用,给用户提供有价值的信息。
 
-我们需要理解问题:用户问的是“满堂支架施工方案”。这是一个工程技术问题,通常涉及桥梁、建筑等施工中的满堂支架(如扣件式钢管脚手架、碗扣式脚手架等)的搭设方案。需要给出专业、结构化的内容,包括编制依据、材料要求、搭设参数、施工工艺、验算思路(简要)与安全措施等。
+用户问的是“满堂支架施工方案”。这是一个工程技术问题,通常涉及桥梁、建筑等施工中的满堂支架(如扣件式钢管脚手架、碗扣式脚手架等)的搭设方案。需要给出专业、结构化的内容,包括编制依据、材料要求、搭设参数、施工工艺、验算思路(简要)与安全措施等。
 
 用户可能期望一份可直接参考的施工方案模板或要点清单。由于缺少跨度、荷载、地基条件、构件规格等关键参数,我更适合先给出“通用框架 + 关键控制点”,并提示需要补充的信息。
 

+ 229 - 0
shudao-chat-py/tests/test_keyword_intent.py

@@ -0,0 +1,229 @@
+"""
+测试 _keyword_based_intent 核心意图识别逻辑
+不依赖任何外部服务,纯本地关键词匹配测试
+"""
+import sys
+import os
+from pathlib import Path
+
+# 添加项目路径
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from services.qwen_service import QwenService
+
+service = QwenService()
+
+PASS = 0
+FAIL = 0
+
+
+def assert_intent(message, expected_business_type, expected_should_redirect,
+                  expected_domain_type, expected_intent_type, label=""):
+    global PASS, FAIL
+    bt, sr, dt, it = service._keyword_based_intent(message)
+    ok = (bt == expected_business_type and
+          sr == expected_should_redirect and
+          dt == expected_domain_type and
+          it == expected_intent_type)
+    if ok:
+        PASS += 1
+        print(f"  ✅ {label or message[:40]}")
+    else:
+        FAIL += 1
+        print(f"  ❌ {label or message[:40]}")
+        print(f"     expected: bt={expected_business_type} sr={expected_should_redirect} dt={expected_domain_type} it={expected_intent_type}")
+        print(f"     got:      bt={bt} sr={sr} dt={dt} it={it}")
+
+
+# ──────────────────────────────────────────────────────────────
+# 测试集 1: 工具分类 (AI写作/AI考试工坊/安全培训)
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print("测试集 1: 工具分类 (AI写作 / AI考试工坊 / 安全培训)")
+print("=" * 60)
+
+# AI写作 (business_type=2, should_redirect=True)
+assert_intent("写一份安全检查报告", 2, True, "general", "document_writing")
+assert_intent("帮我起草一份会议纪要", 2, True, "general", "document_writing")
+assert_intent("撰写隧道施工方案", 2, True, "general", "document_writing")
+assert_intent("生成一份工作报告", 2, True, "general", "document_writing")
+assert_intent("写一个关于安全管理的通知", 2, True, "general", "document_writing")
+assert_intent("帮我写一篇安全培训心得", 2, True, "general", "document_writing")
+
+# AI写作 不被考试关键词误判
+assert_intent("写一份考题", 3, True, "general", "exam_generation",
+              "写+考题 → 应走考试工坊")
+assert_intent("帮我写一份考试试卷", 3, True, "general", "exam_generation",
+              "写+试卷 → 应走考试工坊")
+
+# AI写作 不被培训关键词误判
+assert_intent("写一份培训心得", 2, True, "general", "document_writing",
+              "写+培训(无生成词) → 应走AI写作" if True else "")
+assert_intent("帮我生成一份安全培训PPT", 1, True, "general", "ppt_training",
+              "生成+培训+ppt → 应走安全培训")
+
+# 安全培训 (business_type=1, should_redirect=True)
+assert_intent("帮我生成一份桥梁施工安全培训PPT", 1, True, "general", "ppt_training")
+assert_intent("制作安全培训课件", 1, True, "general", "ppt_training")
+assert_intent("给我做一个安全培训大纲", 1, True, "general", "ppt_training")
+
+# 安全培训 - 不带生成词的不应该触发
+assert_intent("什么是安全培训", 0, False, "professional", "professional_qa",
+              "仅含培训词无生成词 → 应走AI问答")
+
+# 考试工坊 (business_type=3, should_redirect=True)
+assert_intent("生成10道安全知识考题", 3, True, "general", "exam_generation")
+assert_intent("帮我出一份施工安全试卷", 3, True, "general", "exam_generation")
+assert_intent("我要做练习题", 3, True, "general", "exam_generation")
+assert_intent("给我一些测试题", 3, True, "general", "exam_generation")
+
+# ──────────────────────────────────────────────────────────────
+# 测试集 2: 专业领域识别 (五大场景)
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print("测试集 2: 专业领域识别 (五大场景)")
+print("=" * 60)
+
+assert_intent("高速公路安全防护措施有哪些", 0, False, "professional", "professional_qa",
+              "高速公路 → professional")
+assert_intent("桥梁工程施工质量控制要点", 0, False, "professional", "professional_qa",
+              "桥梁工程 → professional")
+assert_intent("隧道施工通风系统如何设计", 0, False, "professional", "professional_qa",
+              "隧道工程 → professional")
+assert_intent("加油站安全管理要求", 0, False, "professional", "professional_qa",
+              "加油站 → professional")
+assert_intent("特种设备检验标准是什么", 0, False, "professional", "professional_qa",
+              "特种设备 → professional")
+
+# ──────────────────────────────────────────────────────────────
+# 测试集 3: 专业领域识别 (十大领域)
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print("测试集 3: 专业领域识别 (十大领域)")
+print("=" * 60)
+
+assert_intent("公路工程建设标准有哪些", 0, False, "professional", "professional_qa",
+              "工程建设 → professional")
+assert_intent("公路工程营运管理流程", 0, False, "professional", "professional_qa",
+              "营运 → professional")
+assert_intent("防汛减灾预案怎么编制", 0, False, "professional", "professional_qa",
+              "防汛 → professional")
+assert_intent("森林草原防灭火措施有哪些", 0, False, "professional", "professional_qa",
+              "防灭火 → professional")
+assert_intent("动火作业审批流程", 0, False, "professional", "professional_qa",
+              "动火 → professional")
+assert_intent("有限空间作业安全规范", 0, False, "professional", "professional_qa",
+              "有限空间 → professional")
+assert_intent("高处作业安全技术规范", 0, False, "professional", "professional_qa",
+              "高处作业 → professional")
+assert_intent("施工现场临时用电要求", 0, False, "professional", "professional_qa",
+              "临时用电 → professional")
+assert_intent("消防安全检查要点", 0, False, "professional", "professional_qa",
+              "消防 → professional")
+assert_intent("起重吊装操作规程", 0, False, "professional", "professional_qa",
+              "起重吊装 → professional")
+
+# ──────────────────────────────────────────────────────────────
+# 测试集 4: 问候/FAQ/通用聊天
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print("测试集 4: 问候 / FAQ / 通用聊天")
+print("=" * 60)
+
+assert_intent("你好", 0, False, "general", "greeting")
+assert_intent("您好", 0, False, "general", "greeting")
+assert_intent("在吗", 0, False, "general", "greeting")
+assert_intent("谢谢", 0, False, "general", "greeting")
+
+assert_intent("你是谁", 0, False, "general", "faq")
+assert_intent("你能做什么", 0, False, "general", "faq")
+assert_intent("怎么用", 0, False, "general", "faq")
+
+assert_intent("今天天气怎么样", 0, False, "general", "general_chat",
+              "天气 → general_chat")
+assert_intent("给我讲个笑话", 0, False, "general", "general_chat",
+              "笑话 → general_chat")
+assert_intent("推荐一部电影", 0, False, "general", "general_chat",
+              "电影 → general_chat")
+
+# ──────────────────────────────────────────────────────────────
+# 测试集 5: 专业关键词兜底 (enhanced_intent.py 中的 fallback)
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print("测试集 5: PROFESSIONAL_DOMAINS 枚举完整性")
+print("=" * 60)
+
+# 验证 PROFESSIONAL_DOMAINS 定义
+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shudao-aichat"))
+try:
+    from app.api.intent import PROFESSIONAL_DOMAINS, TOOL_CATEGORIES
+
+    # 验证五大场景
+    scene_keys = ["HIGHWAY", "BRIDGE_ENGINEERING", "TUNNEL_ENGINEERING",
+                  "GAS_STATION", "SPECIAL_EQUIPMENT"]
+    for sk in scene_keys:
+        if sk in PROFESSIONAL_DOMAINS:
+            PASS += 1
+            print(f"  ✅ 五大场景: {sk} → {PROFESSIONAL_DOMAINS[sk]}")
+        else:
+            FAIL += 1
+            print(f"  ❌ 五大场景: {sk} MISSING")
+
+    # 验证十大领域
+    domain_keys = ["HIGHWAY_CONSTRUCTION", "HIGHWAY_OPERATION",
+                   "FLOOD_PREVENTION", "FOREST_FIRE_PREVENTION",
+                   "HOT_WORK", "CONFINED_SPACE", "WORKING_AT_HEIGHT",
+                   "TEMPORARY_ELECTRICITY", "FIRE_SAFETY", "LIFTING_OPERATION"]
+    for dk in domain_keys:
+        if dk in PROFESSIONAL_DOMAINS:
+            PASS += 1
+            print(f"  ✅ 十大领域: {dk} → {PROFESSIONAL_DOMAINS[dk]}")
+        else:
+            FAIL += 1
+            print(f"  ❌ 十大领域: {dk} MISSING")
+
+    # 验证补充领域
+    extra_keys = ["SAFETY_MANAGEMENT", "ENVIRONMENTAL_PROTECTION", "OTHER_PROFESSIONAL"]
+    for ek in extra_keys:
+        if ek in PROFESSIONAL_DOMAINS:
+            PASS += 1
+            print(f"  ✅ 补充: {ek} → {PROFESSIONAL_DOMAINS[ek]}")
+        else:
+            FAIL += 1
+            print(f"  ❌ 补充: {ek} MISSING")
+
+    # 验证工具分类
+    tool_keys = ["AI_QUESTION_ANSWERING", "AI_WRITING", "AI_EXAM_WORKSHOP"]
+    for tk in tool_keys:
+        if tk in TOOL_CATEGORIES:
+            PASS += 1
+            print(f"  ✅ 工具: {tk}")
+        else:
+            FAIL += 1
+            print(f"  ❌ 工具: {tk} MISSING")
+
+    # 验证枚举值总计
+    total_domains = len(PROFESSIONAL_DOMAINS)
+    print(f"  📊 PROFESSIONAL_DOMAINS 总数: {total_domains} (期望 ≥ 18)")
+    if total_domains >= 18:
+        PASS += 1
+    else:
+        FAIL += 1
+        print(f"  ❌ 领域数量不足: {total_domains} < 18")
+
+except ImportError as e:
+    print(f"  ⚠️ 无法导入 aichat 模块: {e}")
+
+
+# ──────────────────────────────────────────────────────────────
+# 汇总
+# ──────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print(f"测试结果: 通过={PASS}, 失败={FAIL}, 总计={PASS+FAIL}")
+print("=" * 60)
+
+if FAIL > 0:
+    sys.exit(1)
+else:
+    print("🎉 全部测试通过!")
+    sys.exit(0)

+ 47 - 0
shudao-chat-py/tests/test_thinking_summary.py

@@ -0,0 +1,47 @@
+import importlib.util
+import sys
+import types
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+MODULE_PATH = PROJECT_ROOT / "utils" / "thinking_summary.py"
+
+utils_module = types.ModuleType("utils")
+config_module = types.ModuleType("utils.config")
+prompt_loader_module = types.ModuleType("utils.prompt_loader")
+config_module.settings = types.SimpleNamespace(thinking_summary=types.SimpleNamespace(enabled=True))
+prompt_loader_module.load_prompt = lambda *args, **kwargs: ""
+sys.modules.setdefault("utils", utils_module)
+sys.modules["utils.config"] = config_module
+sys.modules["utils.prompt_loader"] = prompt_loader_module
+
+spec = importlib.util.spec_from_file_location("thinking_summary_under_test", MODULE_PATH)
+thinking_summary = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(thinking_summary)
+
+
+def test_normalize_thinking_summary_uses_user_question_prefix():
+    text = (
+        "用户问的是“蜀道矿业集团出台过哪些有关于汛期安全管理的制度和办法”。"
+        "这个问题属于集团或子公司内部制度查询。"
+    )
+
+    normalized = thinking_summary.normalize_thinking_summary(text)
+
+    assert normalized.startswith("用户问的是")
+    assert "我们需要理解问题:" not in normalized
+    assert "识别用户明确查询" not in normalized
+    assert "问题核心主题" not in normalized
+    assert "需结合公司主体" not in normalized
+
+
+def test_fallback_thinking_summary_uses_user_question_prefix():
+    summary = thinking_summary._build_fallback_summary(
+        "蜀道矿业集团出台过哪些有关于汛期安全管理的制度和办法",
+        max_points=3,
+        max_output_chars=600,
+    )
+
+    assert summary.startswith("用户问的是")
+    assert "我们需要理解问题:" not in summary

+ 4 - 2
shudao-chat-py/utils/thinking_summary.py

@@ -170,7 +170,7 @@ def split_thinking_and_answer(text: str) -> Tuple[str, str]:
 
 _LIST_PREFIX_RE = re.compile(r"^\s*(?:[-•*]\s+|\d+\s*[.)、]\s+)")
 
-_REQUIRED_PREFIX_RE = re.compile(r"^\s*(?:我们需要理解问题:用户问的是|嗯,用户问的是)")
+_REQUIRED_PREFIX_RE = re.compile(r"^\s*(?:用户问的是|嗯,用户问的是)")
 
 # 更强的兜底校验:避免回显 prompt 元信息 / 英文标签 / Markdown 列表等
 _INVALID_SUMMARY_PATTERNS = [
@@ -179,6 +179,8 @@ _INVALID_SUMMARY_PATTERNS = [
     re.compile(r"\bFinal Answer\b", re.IGNORECASE),
     re.compile(r"\b(Role|Task|Input|Output)\b", re.IGNORECASE),
     re.compile(r"(?:^|\n)\s*(角色|任务|输入|输出要求|输出|开始输出)\s*[::]", re.MULTILINE),
+    re.compile(r"(?:^|\n)\s*(识别用户明确查询|问题核心主题|需结合公司主体)", re.MULTILINE),
+    re.compile(r"我们需要理解问题:"),
     re.compile(r"思考要点总结器|思考过程展示改写器"),
     re.compile(r"```"),
     re.compile(r"#+\s*\S"),
@@ -305,7 +307,7 @@ def _build_fallback_summary(
     )
 
     paragraphs = [
-        f"我们需要理解问题:用户问的是“{question}”。这更像是一个{domain_hint}类问题,我需要先确认用户希望得到的是方案框架、步骤清单,还是关键控制点与注意事项。",
+        f"用户问的是“{question}”。这更像是一个{domain_hint}类问题,我需要先确认用户希望得到的是方案框架、步骤清单,还是关键控制点与注意事项。",
         outline_paragraph,
         data_paragraph,
         "如果你能补充具体场景(适用对象/工况/阶段/关注重点),我可以把回答细化为可执行的流程清单与检查表。",

+ 3 - 1
shudao-vue-frontend/package.json

@@ -7,7 +7,9 @@
     "node": "^20.19.0 || >=22.12.0"
   },
   "scripts": {
-    "dev": "vite",
+    "dev": "vite --host 127.0.0.1",
+    "dev:5173": "vite --host 127.0.0.1 --port 5173 --strictPort",
+    "dev:5174": "vite --host 127.0.0.1 --port 5174 --strictPort",
     "build": "vite build",
     "preview": "vite preview",
     "test": "vitest --run",

+ 3 - 0
shudao-vue-frontend/src/components/FilePreviewDrawer.vue

@@ -151,6 +151,9 @@ const loadFile = async () => {
   try {
     const originalUrl = props.filePath
     const convertedUrl = originalUrl
+    if (!convertedUrl) {
+      throw new Error('Empty preview URL')
+    }
     
     console.log('📄 [文档预览] 原始URL:', originalUrl)
     console.log('📄 [文档预览] 转换后URL:', convertedUrl)

+ 7 - 11
shudao-vue-frontend/src/components/FileReportCard.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="file-reference-item" :class="statusClass">
     <div class="file-header">
-      <div class="file-title-wrapper" @click="openFile" :class="{ 'clickable': report.file_path }">
+      <div class="file-title-wrapper" @click="openFile" :class="{ 'clickable': previewSource }">
         <el-icon class="file-icon"><Document /></el-icon>
         <span class="file-name">{{ report.report?.display_name || report.source_file }}</span>
       </div>
@@ -88,6 +88,7 @@ import { computed, ref } from 'vue'
 import { ElMessage, ElDialog } from 'element-plus'
 import { Document, CopyDocument, Link, WarningFilled } from '@element-plus/icons-vue'
 import StreamMarkdown from './StreamMarkdown.vue'
+import { getReportPreviewSource } from '@/utils/filePreviewUrl'
 
 const props = defineProps({
   report: {
@@ -175,6 +176,8 @@ const briefSummary = computed(() => {
   return props.report?.report?.summary || props.report?._fullContent?.summary || '暂无摘要'
 })
 
+const previewSource = computed(() => getReportPreviewSource(props.report))
+
 // 获取文件来源URL
 const sourceUrl = computed(() => {
   // 调试日志:查看report对象结构
@@ -187,14 +190,7 @@ const sourceUrl = computed(() => {
   })
   
   // 尝试从多个可能的字段获取URL
-  const url = props.report.metadata?.source_url || 
-         props.report.metadata?.url || 
-         props.report.metadata?.link ||
-         props.report.metadata?.file_url ||
-         props.report.source_url ||
-         props.report.url ||
-         props.report.link ||
-         null
+  const url = previewSource.value || null
   
   console.log('🔗 [DEBUG] 找到的URL:', url)
   return url
@@ -225,10 +221,10 @@ const openSourceUrl = () => {
 }
 
 const openFile = () => {
-  if (props.report.file_path) {
+  if (previewSource.value) {
     const fileName = props.report.report?.display_name || props.report.source_file || '未命名文件'
     emit('preview-file', {
-      filePath: props.report.file_path,
+      filePath: previewSource.value,
       fileName: fileName
     })
   }

+ 5 - 2
shudao-vue-frontend/src/components/MobileFileReportCard.vue

@@ -34,6 +34,7 @@
 
 <script setup>
 import { computed } from 'vue'
+import { getReportPreviewSource } from '@/utils/filePreviewUrl'
 
 const props = defineProps({
   report: {
@@ -81,6 +82,8 @@ const briefSummary = computed(() => {
   return props.report?.report?.summary || props.report?._fullContent?.summary || ''
 })
 
+const previewSource = computed(() => getReportPreviewSource(props.report))
+
 const hasMetaRow = computed(() => {
   return props.report.metadata?.primary_category || 
          props.report.metadata?.secondary_category || 
@@ -90,10 +93,10 @@ const hasMetaRow = computed(() => {
 })
 
 const openFile = () => {
-  if (props.report.file_path) {
+  if (previewSource.value) {
     const fileName = props.report.report?.display_name || props.report.source_file || '未命名文件'
     emit('preview-file', {
-      filePath: props.report.file_path,
+      filePath: previewSource.value,
       fileName: fileName
     })
   }

+ 7 - 1
shudao-vue-frontend/src/components/StreamMarkdown.vue

@@ -133,11 +133,17 @@ onUnmounted(() => {
 .stream-markdown :deep(ul),
 .stream-markdown :deep(ol) {
   margin: 8px 0;
-  padding-left: 24px;
+  padding-left: 20px;
+  list-style-position: outside;
+  box-sizing: border-box;
+  max-width: 100%;
 }
 
 .stream-markdown :deep(li) {
   margin: 4px 0;
+  padding-left: 2px;
+  overflow-wrap: anywhere;
+  word-break: break-word;
 }
 
 .stream-markdown :deep(blockquote) {

+ 157 - 2
shudao-vue-frontend/src/utils/chatHistoryPersistence.js

@@ -348,6 +348,156 @@ export const shouldClearSummaryForOnlineAnswer = (message) => {
   return message?.isProfessionalQuestion === false
 }
 
+export const THINKING_FALLBACK_TEXT = '正在结合检索结果梳理回答重点,请稍等……'
+
+const RAW_THINKING_LEAK_PATTERN = /Here'?s a thinking process|Thinking Process:|Analyze User Input|Analyze the Request|User Question:|Context:|Intent:|Is Professional Question:|Final Answer|Role:|Task:|Input:|Output:|Constraints:|<\s*\/?\s*think\s*>/i
+
+export const hasRawThinkingLeak = (content) => {
+  return RAW_THINKING_LEAK_PATTERN.test(String(content || ''))
+}
+
+const THINKING_INTENT_PREFACE_PATTERNS = [
+  /^\s*识别用户明确查询[^。!?\n\r]*[。!?]?\s*$/u,
+  /^\s*问题核心主题[^。!?\n\r]*[。!?]?\s*$/u,
+  /^\s*需结合公司主体[^。!?\n\r]*[。!?]?\s*$/u
+]
+
+export const normalizeDisplayThinkingContent = (content) => {
+  const text = String(content || '').trim()
+  if (!text) return ''
+
+  return text
+    .split(/\r?\n/)
+    .map(line => line.trim())
+    .filter(line => !THINKING_INTENT_PREFACE_PATTERNS.some(pattern => pattern.test(line)))
+    .join('\n')
+    .replace(/^\s*我们需要理解问题:\s*/, '')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim()
+}
+
+const cleanQuestionText = (value) => {
+  return String(value || '')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+
+const extractQuestionFromRawThinking = (rawThinking) => {
+  const text = String(rawThinking || '')
+  const match = text.match(/User Question:\s*([^\n\r]+)/i)
+  return cleanQuestionText(match?.[1] || '')
+}
+
+const extractCompanyName = (question, rawThinking) => {
+  const source = `${question || ''}\n${rawThinking || ''}`
+  const match = source.match(/([\u4e00-\u9fa5A-Za-z0-9()()·]{2,32}?(?:矿业集团|集团公司|集团|股份有限公司|有限公司|分公司|子公司|公司))/)
+  return match?.[1] || ''
+}
+
+const detectInternalQuery = (question, rawThinking) => {
+  const source = `${question || ''}\n${rawThinking || ''}`.toLowerCase()
+  return /internal_query|内部|制度|办法|文件|通知|规定|规程|出台|发文|适用范围|管理要求/.test(source)
+}
+
+const pickThinkingKeywords = (question, rawThinking) => {
+  const source = `${question || ''}\n${rawThinking || ''}`
+  const candidates = [
+    '蜀道矿业集团',
+    '汛期安全管理',
+    '汛期',
+    '防汛',
+    '防汛减灾',
+    '安全生产',
+    '安全管理',
+    '制度办法',
+    '制度',
+    '办法',
+    '通知',
+    '内部文件',
+    '发文主体',
+    '适用范围',
+    '管理要求'
+  ]
+
+  const picked = candidates.filter(keyword => source.includes(keyword))
+  return Array.from(new Set(picked)).slice(0, 4)
+}
+
+const isCleanChineseThinkingSummary = (content) => {
+  const text = normalizeDisplayThinkingContent(content)
+  if (!text || text === THINKING_FALLBACK_TEXT || hasRawThinkingLeak(text)) {
+    return false
+  }
+
+  return /[\u4e00-\u9fff]/.test(text)
+}
+
+export const buildDisplayThinkingSummary = ({
+  rawThinking = '',
+  existingThinking = '',
+  userQuestion = '',
+  summary = '',
+  intentScene = '',
+  companyName = '',
+  keywords = [],
+  subIntentCategory = ''
+} = {}) => {
+  const cleanedExistingThinking = normalizeDisplayThinkingContent(existingThinking)
+  if (isCleanChineseThinkingSummary(cleanedExistingThinking)) {
+    return cleanedExistingThinking
+  }
+
+  const question = cleanQuestionText(userQuestion) ||
+    extractQuestionFromRawThinking(rawThinking) ||
+    cleanQuestionText(summary) ||
+    '当前问题'
+  const detectedCompany = companyName || extractCompanyName(question, rawThinking)
+  const detectedKeywords = Array.from(new Set([
+    ...keywords.filter(Boolean),
+    ...pickThinkingKeywords(question, rawThinking)
+  ])).slice(0, 4)
+  const isInternalQuery = intentScene === 'internal_query' ||
+    subIntentCategory === 'internal_query' ||
+    detectInternalQuery(question, rawThinking)
+  const quotedQuestion = question === '当前问题' ? '当前问题' : `“${question}”`
+  const companyText = detectedCompany ? `${detectedCompany}相关` : '集团或子公司相关'
+  const keywordText = detectedKeywords.length > 0
+    ? detectedKeywords.map(keyword => `“${keyword}”`).join('、')
+    : (detectedCompany ? `“${detectedCompany}”和问题核心词` : '问题核心词')
+
+  if (isInternalQuery) {
+    return [
+      `用户问的是${quotedQuestion}。这个问题属于${companyText}内部制度、办法或文件查询,重点不是泛泛解释安全管理概念,而是从已有资料中核实是否存在明确的制度名称、发文主体、适用范围和管理要求。`,
+      `接下来需要围绕${keywordText}进行检索和核验,优先关注文件标题、发文单位、制度名称、适用对象以及与问题主题直接相关的条款或工作要求。回答时要区分确有文件依据的内容和仍需进一步确认的内容,避免把通用管理要求误写成已经出台的制度。`,
+      '最终回答会按文件或制度维度组织:先列出检索到的相关制度、办法、通知或方案名称,再说明每份文件的管理重点和与问题的关系。如果检索结果不足,需要明确提示依据不充分,并建议补充年份、公司简称或文件范围。'
+    ].join('\n\n')
+  }
+
+  return [
+    `用户问的是${quotedQuestion}。当前重点是先判断问题所处的业务场景,再确认回答需要依赖知识库文件、联网资料还是通用解释,避免直接给出未经核验的结论。`,
+    `接下来会围绕${keywordText}梳理检索方向,优先提取与问题直接相关的文件标题、关键事实、适用范围和结论依据。回答时会把有明确来源的信息和需要进一步确认的内容区分开。`,
+    '最终回答会先给出简明结论,再按依据和要点展开说明;如果资料不足,会说明当前没有检索到充分证据,并提示可补充的限定条件。'
+  ].join('\n\n')
+}
+
+export const sanitizeThinkingContentForPersistence = (content, context = {}) => {
+  const text = normalizeDisplayThinkingContent(content)
+  if (!text) {
+    return ''
+  }
+
+  if (hasRawThinkingLeak(text) || text === THINKING_FALLBACK_TEXT) {
+    return buildDisplayThinkingSummary({
+      ...context,
+      rawThinking: context.rawThinking || text,
+      existingThinking: ''
+    })
+  }
+
+  return text
+}
+
 export const buildPersistedAIMessageContent = (message) => {
   if (!message) {
     return ''
@@ -357,7 +507,11 @@ export const buildPersistedAIMessageContent = (message) => {
   const webSearchRaw = message.webSearchRaw || null
   const webSearchSummary = message._fullWebSearchSummary || message.webSearchSummary || null
   const summary = message._fullSummary || message.summary || ''
-  const thinkingContent = message.thinkingContent || ''
+  const thinkingContent = sanitizeThinkingContentForPersistence(message.thinkingContent || message.rawThinkingContent || '', {
+    rawThinking: message.rawThinkingContent || '',
+    userQuestion: message.userQuestion || '',
+    summary
+  })
   const directContent = message.content || ''
   const hasStructuredPayload = reports.length > 0 || webSearchRaw || webSearchSummary
 
@@ -367,7 +521,8 @@ export const buildPersistedAIMessageContent = (message) => {
       webSearchRaw,
       webSearchSummary,
       hasWebSearchResults: message.hasWebSearchResults || false,
-      summary
+      summary,
+      answer: directContent
     }
 
     if (thinkingContent) {

+ 66 - 2
shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js

@@ -2,12 +2,14 @@ import { describe, expect, it } from 'vitest'
 
 import {
   buildAIMessageUpdatePayload,
+  buildDisplayThinkingSummary,
   buildPersistedAIMessageContent,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   applyReportChunkToMessage,
   hydratePersistedReports,
   normalizeReportsForPersistence,
+  sanitizeThinkingContentForPersistence,
   shouldClearSummaryForOnlineAnswer,
   splitHtmlIntoTypewriterChunks
 } from './chatHistoryPersistence'
@@ -137,7 +139,8 @@ describe('chatHistoryPersistence', () => {
       webSearchRaw: null,
       webSearchSummary: null,
       hasWebSearchResults: false,
-      summary: 'The assistant identified a professional question and is analyzing the relevant standards.'
+      summary: 'The assistant identified a professional question and is analyzing the relevant standards.',
+      answer: ''
     })
   })
 
@@ -176,6 +179,66 @@ describe('chatHistoryPersistence', () => {
     )
   })
 
+  it('turns raw English thinking templates into Chinese summaries before persistence', () => {
+    const rawThinking = [
+      "Here's a thinking process:",
+      'Analyze User Input:',
+      'User Question: 蜀道矿业集团出台过哪些有关于汛期安全管理的制度和办法',
+      'Intent: Query internal regulations/documents.',
+      'Is Professional Question: true'
+    ].join('\n')
+
+    const sanitized = sanitizeThinkingContentForPersistence(rawThinking)
+    expect(sanitized).toContain('蜀道矿业集团')
+    expect(sanitized).toContain('汛期安全管理')
+    expect(sanitized).toContain('内部制度')
+    expect(sanitized).toContain('检索')
+    expect(sanitized).toContain('最终回答')
+    expect(sanitized).not.toMatch(/Here'?s a thinking process|Analyze User Input|Intent:|Is Professional Question:/i)
+
+    const content = buildPersistedAIMessageContent({
+      summary: '检索到相关文件。',
+      thinkingContent: rawThinking,
+      content: ''
+    })
+
+    expect(JSON.parse(content).thinkingContent).toContain('蜀道矿业集团')
+  })
+
+  it('builds a DeepSeek-style Chinese visible thinking summary from question context', () => {
+    const summary = buildDisplayThinkingSummary({
+      userQuestion: '蜀道矿业集团出台过哪些有关于汛期安全管理的制度和办法',
+      rawThinking: 'Intent: Query internal regulations/documents.',
+      intentScene: 'internal_query'
+    })
+
+    expect(summary.split('\n\n')).toHaveLength(3)
+    expect(summary).toContain('蜀道矿业集团')
+    expect(summary).toContain('内部制度')
+    expect(summary).toContain('发文主体')
+    expect(summary).toContain('依据不充分')
+    expect(summary).not.toMatch(/Analyze User Input|Thinking Process|Intent:/i)
+  })
+
+  it('stores a clean answer field alongside structured reports', () => {
+    const content = buildPersistedAIMessageContent({
+      reports: [
+        {
+          status: 'completed',
+          source_file: '防汛减灾工作方案.pdf',
+          report: { display_name: '防汛减灾工作方案', summary: '制度摘要', analysis: '', clauses: '' }
+        }
+      ],
+      summary: '检索到相关制度文件。',
+      content: '根据检索结果,相关文件主要包括防汛减灾工作方案。'
+    })
+    const payload = JSON.parse(content)
+
+    expect(payload.reports).toHaveLength(1)
+    expect(payload.answer).toBe('根据检索结果,相关文件主要包括防汛减灾工作方案。')
+    expect(content).not.toContain('"{\\"reports\\"')
+  })
+
   it('keeps only the first report for the same displayed file name and scene category', () => {
     const reports = [
       {
@@ -298,7 +361,8 @@ describe('chatHistoryPersistence', () => {
         webSearchRaw: null,
         webSearchSummary: null,
         hasWebSearchResults: false,
-        summary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.'
+        summary: 'The construction flow should follow inspection, assembly, trial hoisting, erection, and acceptance.',
+        answer: ''
       })
     })
   })

+ 138 - 0
shudao-vue-frontend/src/utils/filePreviewUrl.js

@@ -0,0 +1,138 @@
+import { REPORT_API_PREFIX } from './apiConfig'
+
+const FILE_PROXY_VIEW_PATH = '/file-proxy/view'
+const LEGACY_OSS_PARSE_PATH = '/apiv1/oss/parse'
+const FASTAPI_PREFIX = '/api/v1'
+
+const isHttpUrl = (value) => /^https?:\/\//i.test(value || '')
+
+const getFrontendOrigin = () => {
+  if (typeof window === 'undefined') {
+    return ''
+  }
+  return window.location.origin
+}
+
+const getPathAndSearch = (rawUrl) => {
+  if (!rawUrl) {
+    return null
+  }
+
+  try {
+    if (isHttpUrl(rawUrl)) {
+      const parsed = new URL(rawUrl)
+      return `${parsed.pathname}${parsed.search}`
+    }
+  } catch {
+    return null
+  }
+
+  return rawUrl.startsWith('/') ? rawUrl : null
+}
+
+const buildFileProxyViewUrl = (encryptedUrl) => (
+  `${REPORT_API_PREFIX}${FILE_PROXY_VIEW_PATH}?url=${encodeURIComponent(encryptedUrl)}`
+)
+
+export const getReportPreviewSource = (report = {}) => {
+  const metadata = report?.metadata || {}
+  const candidates = [
+    report.file_path,
+    report.filePath,
+    report.file_url,
+    report.fileUrl,
+    report.preview_url,
+    report.previewUrl,
+    metadata.file_path,
+    metadata.filePath,
+    metadata.file_url,
+    metadata.fileUrl,
+    metadata.preview_url,
+    metadata.previewUrl,
+    metadata.oss_url,
+    metadata.ossUrl
+  ]
+
+  for (const candidate of candidates) {
+    const value = String(candidate || '').trim()
+    if (value) {
+      return value
+    }
+  }
+
+  return ''
+}
+
+const convertKnownProxyUrl = (rawUrl) => {
+  const pathAndSearch = getPathAndSearch(rawUrl)
+  if (!pathAndSearch) {
+    return ''
+  }
+
+  const legacyMatch = pathAndSearch.match(/^\/apiv1\/oss\/parse\/?\?(.*)$/)
+  if (legacyMatch) {
+    const params = new URLSearchParams(legacyMatch[1])
+    const encryptedUrl = params.get('url')
+    return encryptedUrl ? buildFileProxyViewUrl(encryptedUrl) : ''
+  }
+
+  const prefixedView = `${REPORT_API_PREFIX}${FILE_PROXY_VIEW_PATH}`
+  if (pathAndSearch.startsWith(prefixedView)) {
+    return pathAndSearch
+  }
+
+  const fastapiView = `${FASTAPI_PREFIX}${FILE_PROXY_VIEW_PATH}`
+  if (pathAndSearch.startsWith(fastapiView)) {
+    return `${REPORT_API_PREFIX}${pathAndSearch.slice(FASTAPI_PREFIX.length)}`
+  }
+
+  return ''
+}
+
+const encryptPreviewSource = async (rawUrl) => {
+  const response = await fetch(
+    `${REPORT_API_PREFIX}/file-proxy/encrypt?url=${encodeURIComponent(rawUrl)}`
+  )
+
+  if (!response.ok) {
+    throw new Error(`Failed to encrypt preview URL: ${response.status}`)
+  }
+
+  const data = await response.json()
+  if (!data?.encrypted_url) {
+    throw new Error('Missing encrypted preview URL')
+  }
+
+  return buildFileProxyViewUrl(data.encrypted_url)
+}
+
+export async function buildPreviewUrl(rawUrl) {
+  const normalized = String(rawUrl || '').trim()
+  if (!normalized) {
+    return ''
+  }
+
+  const knownProxyUrl = convertKnownProxyUrl(normalized)
+  if (knownProxyUrl) {
+    return knownProxyUrl
+  }
+
+  if (isHttpUrl(normalized)) {
+    return encryptPreviewSource(normalized)
+  }
+
+  if (normalized.startsWith('/')) {
+    return encryptPreviewSource(`${getFrontendOrigin()}${normalized}`)
+  }
+
+  return normalized
+}
+
+export function buildPreviewUrlSync(rawUrl) {
+  const normalized = String(rawUrl || '').trim()
+  if (!normalized) {
+    return ''
+  }
+
+  return convertKnownProxyUrl(normalized) || normalized
+}

+ 80 - 1
shudao-vue-frontend/src/views/Chat.thinkingPanelOrder.test.js

@@ -32,6 +32,7 @@ const expectThinkingPanelBeforeAnswer = (source) => {
   expect(reportsList).toBeGreaterThan(briefAnswer)
   expect(questionSummary).toBeGreaterThan(reportsList)
   expect(template.slice(thinkingPanel - 80, thinkingPanel)).toContain('v-if="message.thinkingContent"')
+  expect(template.slice(reportsList - 120, reportsList)).toContain('v-if="shouldShowDatabaseFiles(message)"')
   expect(template).toContain('简要回答')
   expect(template).toContain('查询结果总结')
   expect(template).toContain('模型思考过程')
@@ -46,11 +47,89 @@ describe('Chat thinking panel order', () => {
     expectThinkingPanelBeforeAnswer(readView('mobile/m-Chat.vue'))
   })
 
-  it('updates mobile thinking content as soon as SSE thinking summaries arrive', () => {
+  it('keeps raw SSE thinking chunks out of displayed mobile thinking content', () => {
     const source = readView('mobile/m-Chat.vue')
 
     expect(source).toContain('const appendThinkingContent =')
     expect(source).toContain("appendThinkingContent(aiMessage, '正式回答', data.thinking_content)")
     expect(source).toContain("appendThinkingDelta(aiMessage, data.chunk || '')")
+    expect(source).toContain('aiMessage.rawThinkingContent = `${aiMessage.rawThinkingContent || \'\'}${normalized}`')
+    expect(source).toContain('buildDisplayThinkingSummary({')
+    expect(source).toContain('finalizeThinkingContent(aiMessage)')
+  })
+
+  it('removes intent preface lines from displayed thinking summaries', () => {
+    const persistenceSource = readView('../utils/chatHistoryPersistence.js')
+    const desktopSource = readView('Chat.vue')
+    const mobileSource = readView('mobile/m-Chat.vue')
+
+    expect(persistenceSource).toContain('normalizeDisplayThinkingContent')
+    expect(persistenceSource).toContain('识别用户明确查询')
+    expect(persistenceSource).toContain('问题核心主题')
+    expect(persistenceSource).toContain('需结合公司主体')
+    expect(persistenceSource).toContain('`用户问的是${quotedQuestion}。')
+    expect(persistenceSource).not.toContain('`我们需要理解问题:用户问的是${quotedQuestion}。')
+    expect(desktopSource).toContain('rawIntentThinkingContent')
+    expect(desktopSource).not.toContain("appendThinkingContent(aiMessage, '问题理解', data.thinking_content)")
+    expect(mobileSource).toContain('rawIntentThinkingContent')
+    expect(mobileSource).not.toContain("appendThinkingContent(aiMessage, '问题理解', data.thinking_content)")
+  })
+
+  it('gates database file display until thinking and answer content are visible', () => {
+    const desktopSource = readView('Chat.vue')
+    const mobileSource = readView('mobile/m-Chat.vue')
+
+    expect(desktopSource).toContain('const shouldShowDatabaseFiles = (message) =>')
+    expect(desktopSource).toContain('!message.answerStreaming')
+    expect(desktopSource).toContain('v-if="shouldShowDatabaseFiles(message)" class="reports-list"')
+    expect(desktopSource).toContain('getVisibleDatabaseReports(message)')
+    expect(desktopSource).toContain('database-report-reveal-item')
+    expect(desktopSource).toContain('databaseFileRevealTimers.clear()')
+    expect(mobileSource).toContain('const shouldShowDatabaseFiles = (message) =>')
+    expect(mobileSource).toContain('!message.answerStreaming')
+    expect(mobileSource).toContain('v-if="shouldShowDatabaseFiles(message)" class="reports-list"')
+    expect(mobileSource).toContain('getVisibleDatabaseReports(message)')
+    expect(mobileSource).toContain('database-report-reveal-item')
+    expect(mobileSource).toContain('databaseFileRevealTimers.clear()')
+  })
+
+  it('reveals recalled database files before showing the result summary', () => {
+    const desktopSource = readView('Chat.vue')
+    const mobileSource = readView('mobile/m-Chat.vue')
+
+    expect(desktopSource).toContain('const startDatabaseFilesReveal = (message) =>')
+    expect(desktopSource).toContain('const DATABASE_FILE_REVEAL_INTERVAL_MS = 320')
+    expect(desktopSource).toContain('message._visibleDatabaseReportCount = nextCount')
+    expect(desktopSource).toContain('message._databaseReportsRevealComplete && message.summary')
+    expect(mobileSource).toContain('const startDatabaseFilesReveal = (message) =>')
+    expect(mobileSource).toContain('const DATABASE_FILE_REVEAL_INTERVAL_MS = 320')
+    expect(mobileSource).toContain('message._visibleDatabaseReportCount = nextCount')
+    expect(mobileSource).toContain('message._databaseReportsRevealComplete && message.summary')
+  })
+
+  it('waits for thinking completion before rendering streamed answer content', () => {
+    const desktopSource = readView('Chat.vue')
+    const mobileSource = readView('mobile/m-Chat.vue')
+
+    expect(desktopSource).toContain('const canRenderAnswerContent = (message) => !message?.thinkingStreaming')
+    expect(desktopSource).toContain('const markAnswerContentStarted =')
+    expect(desktopSource).toContain('const startAnswerRenderingIfReady = (message) =>')
+    expect(desktopSource).toContain('if (message._answerContentStarted || message.content)')
+    expect(desktopSource).toContain('stopStreamingAnswerTypewriter(aiMessage)')
+    expect(desktopSource).toContain('startAnswerRenderingIfReady(aiMessage)')
+    expect(desktopSource).toContain('aiMessage.answerStreaming = canRenderAnswerContent(aiMessage)')
+    expect(desktopSource).toContain('if (!aiMessage._answerContentStarted) {')
+    expect(desktopSource).toContain('markAnswerContentStarted(aiMessage)')
+    expect(desktopSource).not.toContain('ensureStreamingAnswerTypewriter(aiMessage)\n      break\n\n    case \'answer_content_delta\'')
+    expect(mobileSource).toContain('const canRenderAnswerContent = (message) => !message?.thinkingStreaming')
+    expect(mobileSource).toContain('const markAnswerContentStarted =')
+    expect(mobileSource).toContain('const startAnswerRenderingIfReady = (message) =>')
+    expect(mobileSource).toContain('if (message._answerContentStarted || message.content)')
+    expect(mobileSource).toContain('stopStreamingAnswerTypewriter(aiMessage)')
+    expect(mobileSource).toContain('startAnswerRenderingIfReady(aiMessage)')
+    expect(mobileSource).toContain('aiMessage.answerStreaming = canRenderAnswerContent(aiMessage)')
+    expect(mobileSource).toContain('if (!aiMessage._answerContentStarted) {')
+    expect(mobileSource).toContain('markAnswerContentStarted(aiMessage)')
+    expect(mobileSource).not.toContain('ensureStreamingAnswerTypewriter(aiMessage)\n      break\n\n    case \'answer_content_delta\'')
   })
 })

+ 533 - 91
shudao-vue-frontend/src/views/Chat.vue

@@ -202,7 +202,7 @@
                   <!-- 完整的AI回复内容 -->
                   <div class="ai-response-content">
                     <!-- 状态统计卡片 - 优先显示(不等待后端数据) -->
-                    <div v-if="shouldShowMessageStatsCard(message)" 
+                    <div v-if="shouldShowDatabaseFiles(message) && shouldShowMessageStatsCard(message)" 
                          class="files-stats-white"
                          :class="{ 'sticky': messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing }"
                          :style="(messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing && messageScrollStates[index]?.initialLeft > 0 && messageScrollStates[index]?.initialWidth > 0) ? {
@@ -275,8 +275,12 @@
                 </div>
               
                     <!-- 报告列表 -->
-                    <div v-if="message.reports && message.reports.length > 0" class="reports-list">
-                      <template v-for="(report, rIndex) in dedupeReportsByFileAndScene(message.reports)" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
+                    <div v-if="shouldShowDatabaseFiles(message)" class="reports-list">
+                      <div
+                        v-for="(report, rIndex) in getVisibleDatabaseReports(message)"
+                        :key="`${report.source_file}-${report.file_index}-${rIndex}`"
+                        class="database-report-reveal-item"
+                      >
                         <!-- 类别标题 -->
                         <CategoryTitle
                           v-if="report.type === 'category_title'"
@@ -290,7 +294,7 @@
                           :report="report"
                           @preview-file="handleFilePreview"
                         />
-                      </template>
+                      </div>
                       
                       <!-- 分类下的Loading动画 - 当有分类但还在等待报告时 -->
                       <div v-if="message.isTyping && hasOnlyCategoryTitles(message.reports)" class="report-loading">
@@ -303,7 +307,7 @@
                     </div>
 
                     <!-- 查询结果总结 -->
-                    <div v-if="message.summary" class="question-summary result-summary-card">
+                    <div v-if="shouldShowDatabaseFiles(message) && message._databaseReportsRevealComplete && message.summary" class="question-summary result-summary-card">
                       <StreamMarkdown :content="message.summary" :streaming="false" />
                     </div>
                     </div>
@@ -773,13 +777,18 @@ import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
   applyReportChunkToMessage,
+  buildDisplayThinkingSummary,
   buildAIMessageUpdatePayload,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
+  hasRawThinkingLeak,
   hydratePersistedReports,
+  normalizeDisplayThinkingContent,
   normalizeReportsForPersistence,
+  sanitizeThinkingContentForPersistence,
   shouldClearSummaryForOnlineAnswer,
-  splitHtmlIntoTypewriterChunks
+  splitHtmlIntoTypewriterChunks,
+  THINKING_FALLBACK_TEXT
 } from '@/utils/chatHistoryPersistence.js'
 import {
   buildDocumentGenerationRequestMessage,
@@ -824,6 +833,7 @@ import WebSearchCapsule from '@/components/WebSearchCapsule.vue'
 import WebSearchSidebar from '@/components/WebSearchSidebar.vue'
 import WebSearchSummary from '@/components/WebSearchSummary.vue'
 import StatusAvatar from '@/components/StatusAvatar.vue'
+import { buildPreviewUrl, buildPreviewUrlSync } from '@/utils/filePreviewUrl'
 import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
 import { getApiPrefix } from '@/utils/apiConfig'
 import { synthesizeSpeechToObjectUrl } from '@/services/speechService'
@@ -1646,6 +1656,12 @@ const processAIResponse = (text) => {
 // 打字机效果函数
 const typewriterIntervals = new Map() // 存储每个消息的打字机定时器
 const reportTypewriters = new Map() // 存储每个报告字段的打字机定时器
+const streamingAnswerTypewriters = new Map() // 存储流式回答的平滑渲染定时器
+const databaseFileRevealTimers = new Map() // 存储召回文件逐条显现定时器
+const STREAMING_ANSWER_CHARS_PER_SECOND = 78
+const STREAMING_ANSWER_FRAME_MS = 1000 / 30
+const FINAL_ANSWER_TYPEWRITER_SPEED = 80
+const DATABASE_FILE_REVEAL_INTERVAL_MS = 320
 
 const startTypewriterEffect = (message, fullContent, speed = 30) => {
   return new Promise((resolve) => {
@@ -1697,6 +1713,133 @@ const startTypewriterEffect = (message, fullContent, speed = 30) => {
   })
 }
 
+const renderAnswerPrefix = (message, visibleLength) => {
+  const rawContent = String(message.content || '')
+  const visibleContent = rawContent.slice(0, Math.min(visibleLength, rawContent.length))
+  message.displayContent = visibleContent
+    ? renderMarkdownContent(processAIResponse(visibleContent))
+    : ''
+}
+
+const stopStreamingAnswerTypewriter = (message, { complete = false } = {}) => {
+  if (!message) return
+
+  const interval = streamingAnswerTypewriters.get(message.id)
+  if (interval) {
+    clearInterval(interval)
+    streamingAnswerTypewriters.delete(message.id)
+  }
+
+  if (complete) {
+    message._visibleAnswerLength = String(message.content || '').length
+    renderAnswerPrefix(message, message._visibleAnswerLength)
+  }
+}
+
+const markAnswerDisplayComplete = (message) => {
+  if (!message) return
+
+  message._answerDisplayComplete = true
+  startDatabaseFilesReveal(message)
+}
+
+const canRenderAnswerContent = (message) => !message?.thinkingStreaming
+
+const markAnswerContentStarted = (message, { resetDisplay = false } = {}) => {
+  if (!message) return
+
+  message._answerContentStarted = true
+  message._answerDisplayComplete = false
+  message._visibleAnswerLength = Number(message._visibleAnswerLength || 0)
+  if (!message.content) {
+    message.content = ''
+  }
+  if (resetDisplay && !message.displayContent && !message._visibleAnswerLength) {
+    message.displayContent = ''
+    message._visibleAnswerLength = 0
+  }
+}
+
+const startAnswerRenderingIfReady = (message) => {
+  if (!message || !canRenderAnswerContent(message)) {
+    return
+  }
+
+  if (message._answerContentStarted || message.content) {
+    message._answerContentStarted = true
+    message.answerStreaming = message._answerContentDone !== true
+    message.isTyping = true
+    ensureStreamingAnswerTypewriter(message)
+  }
+}
+
+const finalizeStreamingThinkingIfNeeded = (message) => {
+  if (!message) return
+
+  if (message.thinkingStreaming) {
+    message.thinkingStreaming = false
+    finalizeThinkingContent(message)
+    return
+  }
+
+  ensureThinkingFallback(message)
+}
+
+const finalizeAnswerStreamIfNeeded = (message) => {
+  if (!message) return
+
+  if (message.content) {
+    if (!message._answerContentStarted) {
+      markAnswerContentStarted(message)
+    }
+    message._answerContentDone = true
+    message.answerStreaming = false
+    startAnswerRenderingIfReady(message)
+    return
+  }
+
+  message.answerStreaming = false
+  message._answerContentDone = true
+  message._answerDisplayComplete = true
+}
+
+const ensureStreamingAnswerTypewriter = (message) => {
+  if (!message || streamingAnswerTypewriters.has(message.id)) {
+    return
+  }
+
+  message._visibleAnswerLength = Number(message._visibleAnswerLength || 0)
+  message._answerTypewriterCarry = Number(message._answerTypewriterCarry || 0)
+  message._answerTypewriterLastTick = Date.now()
+  const interval = setInterval(() => {
+    const rawContent = String(message.content || '')
+    const targetLength = rawContent.length
+
+    if (message._visibleAnswerLength < targetLength) {
+      const now = Date.now()
+      const elapsed = Math.max(0, now - (message._answerTypewriterLastTick || now))
+      message._answerTypewriterLastTick = now
+      const exactChars = (elapsed / 1000) * STREAMING_ANSWER_CHARS_PER_SECOND + Number(message._answerTypewriterCarry || 0)
+      const charsToReveal = Math.max(1, Math.floor(exactChars))
+      message._answerTypewriterCarry = Math.max(0, exactChars - charsToReveal)
+      message._visibleAnswerLength = Math.min(
+        targetLength,
+        message._visibleAnswerLength + charsToReveal
+      )
+      renderAnswerPrefix(message, message._visibleAnswerLength)
+      return
+    }
+
+    if (!message.answerStreaming) {
+      stopStreamingAnswerTypewriter(message, { complete: true })
+      message.isTyping = false
+      markAnswerDisplayComplete(message)
+    }
+  }, STREAMING_ANSWER_FRAME_MS)
+
+  streamingAnswerTypewriters.set(message.id, interval)
+}
+
 // 为报告字段添加打字机效果
 const startReportFieldTypewriter = (report, field, fullContent, speed = 50) => {
   return new Promise((resolve) => {
@@ -1738,6 +1881,121 @@ const startReportFieldTypewriter = (report, field, fullContent, speed = 50) => {
   })
 }
 
+const getDatabaseDisplayReports = (message) => dedupeReportsByFileAndScene(message?.reports || [])
+
+const shouldWaitForDatabaseFileReveal = (message) => (
+  shouldShowDatabaseFiles(message)
+  && getDatabaseDisplayReports(message).length > 0
+  && !message._databaseReportsRevealComplete
+)
+
+const triggerRelatedQuestionsForMessage = (message) => {
+  if (!message || message._relatedQuestionsRequested) return
+
+  const messageIndex = chatMessages.value.findIndex(item => item === message)
+  const previousMessages = messageIndex >= 0
+    ? chatMessages.value.slice(0, messageIndex)
+    : chatMessages.value
+  const userMessage = previousMessages.filter(msg => msg.type === 'user').pop()
+
+  if (!userMessage || !message.ai_message_id) {
+    return
+  }
+
+  let aiReplyContent = ''
+  if (message.summary) {
+    aiReplyContent = message.summary
+  } else if (message.content) {
+    aiReplyContent = message.content
+  } else if (message.reports && message.reports.length > 0) {
+    aiReplyContent = message.reports
+      .filter(report => report.report && report.report.summary)
+      .map(report => report.report.summary)
+      .slice(0, 3)
+      .join('\n\n')
+  }
+
+  if (!aiReplyContent.trim()) return
+
+  message._relatedQuestionsRequested = true
+  getAIRelatedQuestions(userMessage.content, aiReplyContent, message.ai_message_id)
+}
+
+const completePostAnswerUiOnce = (message) => {
+  if (!message || message._postAnswerUiCompleted) return
+
+  if (shouldWaitForDatabaseFileReveal(message)) {
+    message._postAnswerUiPending = true
+    return
+  }
+
+  message._postAnswerUiPending = false
+  message._postAnswerUiCompleted = true
+  isAIReplyProcessComplete.value = !shouldWaitForDatabaseFileReveal(lastAIMessageForPersistence)
+  if (getDatabaseDisplayReports(message).length === 0) {
+    triggerRelatedQuestionsForMessage(message)
+  }
+}
+
+const stopDatabaseFilesReveal = (message) => {
+  const messageId = message?.id
+  if (!messageId) return
+
+  const timer = databaseFileRevealTimers.get(messageId)
+  if (timer) {
+    clearInterval(timer)
+    databaseFileRevealTimers.delete(messageId)
+  }
+}
+
+const startDatabaseFilesReveal = (message) => {
+  if (!message || !shouldShowDatabaseFiles(message)) return
+
+  const reports = getDatabaseDisplayReports(message)
+  if (reports.length === 0) return
+
+  if (message._databaseReportsRevealComplete && message._databaseReportsRevealLength === reports.length) {
+    message._visibleDatabaseReportCount = reports.length
+    completePostAnswerUiOnce(message)
+    return
+  }
+
+  if (message._databaseReportsRevealLength !== reports.length) {
+    message._databaseReportsRevealLength = reports.length
+    message._databaseReportsRevealComplete = false
+    message._visibleDatabaseReportCount = Math.min(
+      Math.max(Number(message._visibleDatabaseReportCount || 0), 0),
+      reports.length
+    )
+  } else if (!Number.isFinite(Number(message._visibleDatabaseReportCount))) {
+    message._visibleDatabaseReportCount = 0
+  }
+
+  if (databaseFileRevealTimers.has(message.id)) return
+
+  const timer = setInterval(() => {
+    const latestReports = getDatabaseDisplayReports(message)
+    const currentCount = Number(message._visibleDatabaseReportCount || 0)
+
+    if (!shouldShowDatabaseFiles(message) || latestReports.length === 0) {
+      stopDatabaseFilesReveal(message)
+      return
+    }
+
+    const nextCount = Math.min(currentCount + 1, latestReports.length)
+    message._visibleDatabaseReportCount = nextCount
+
+    if (nextCount >= latestReports.length) {
+      message._databaseReportsRevealComplete = true
+      message._databaseReportsRevealLength = latestReports.length
+      stopDatabaseFilesReveal(message)
+      completePostAnswerUiOnce(message)
+    }
+  }, DATABASE_FILE_REVEAL_INTERVAL_MS)
+
+  databaseFileRevealTimers.set(message.id, timer)
+}
+
 // 清除所有打字机定时器
 const clearAllTypeIntervals = () => {
   typewriterIntervals.forEach((interval, messageId) => {
@@ -1749,6 +2007,14 @@ const clearAllTypeIntervals = () => {
     clearInterval(interval)
   })
   reportTypewriters.clear()
+  streamingAnswerTypewriters.forEach((interval) => {
+    clearInterval(interval)
+  })
+  streamingAnswerTypewriters.clear()
+  databaseFileRevealTimers.forEach((timer) => {
+    clearInterval(timer)
+  })
+  databaseFileRevealTimers.clear()
   chatMessages.value.forEach(message => clearMessageOutputRenders(message))
 }
 
@@ -1757,6 +2023,9 @@ const waitForProgressCompletionFrame = () => new Promise(resolve => setTimeout(r
 const completeAIMessageAfterRendered = async (message) => {
   if (!message || message._stopped) return
 
+  finalizeStreamingThinkingIfNeeded(message)
+  finalizeAnswerStreamIfNeeded(message)
+
   if (message.showStats && Number(message.progress || 0) < 100) {
     updateMessageStatus(message, 'completed')
   }
@@ -1775,6 +2044,7 @@ const completeAIMessageAfterRendered = async (message) => {
 
   hideMessageProgressStatus(message)
   clearMessageOutputRenders(message)
+  startDatabaseFilesReveal(message)
 }
 
 // 处理文件标签格式的回显
@@ -2092,9 +2362,22 @@ const getConversationMessages = async (conversationId) => {
                   if (parsedContent.thinkingContent) {
                     thinkingContent = parsedContent.thinkingContent
                   }
+                  if (parsedContent.answer || parsedContent.content) {
+                    const answerContent = parsedContent.answer || parsedContent.content || ''
+                    const processedContent = String(answerContent)
+                      .replace(/\\n/g, '\n')
+                      .replace(/\\t/g, '\t')
+                      .replace(/\\r/g, '\r')
+                    displayContent = processedContent.trim()
+                      ? renderMarkdownContent(processedContent)
+                      : ''
+                  } else {
+                    displayContent = ''
+                  }
                 } else if (Array.isArray(parsedContent)) {
                   // 旧格式,直接是reports数组
                   reports = hydratePersistedReports(parsedContent)
+                  displayContent = ''
                 } else if (parsedContent.answer || parsedContent.content || parsedContent.thinkingContent) {
                   if (parsedContent.thinkingContent) {
                     thinkingContent = parsedContent.thinkingContent
@@ -2154,6 +2437,14 @@ const getConversationMessages = async (conversationId) => {
         if (isGeneratedDocumentHistory) {
           displayContent = ''
         }
+
+        if (message.type === 'ai' && thinkingContent) {
+          thinkingContent = sanitizeThinkingContentForPersistence(thinkingContent, {
+            rawThinking: thinkingContent,
+            userQuestion: userQuestion || '',
+            summary
+          })
+        }
         
         return {
           type: message.type, // 'user' 或 'ai'
@@ -2181,6 +2472,9 @@ const getConversationMessages = async (conversationId) => {
           showThinking: Boolean(thinkingContent),
           thinkingStreaming: false,
           answerStreaming: false,
+          _databaseReportsRevealComplete: true,
+          _databaseReportsRevealLength: dedupeReportsByFileAndScene(reports).length,
+          _visibleDatabaseReportCount: dedupeReportsByFileAndScene(reports).length,
           // 状态管理(历史记录默认完成状态)
           showStats: totalFiles > 0,
           currentStatus: 'completed',
@@ -3056,7 +3350,6 @@ const updateMessageStatus = (aiMessage, status, customMessage = null) => {
   }
 }
 
-const THINKING_FALLBACK_TEXT = '正在结合检索结果梳理回答重点,请稍等……'
 const THINKING_HEADING_REPLACEMENTS = [
   [/^\s*\d+\.\s*Analyze the Request:?\s*$/i, '### 问题理解'],
   [/^\s*Analyze the Request:?\s*$/i, '### 问题理解'],
@@ -3066,7 +3359,13 @@ const THINKING_HEADING_REPLACEMENTS = [
   [/^\s*Key Points:?\s*$/i, '### 回答重点']
 ]
 const THINKING_BLOCKLIST_PATTERNS = [
+  /^\s*Here'?s a thinking process:?\s*$/i,
   /^\s*Thinking Process:?\s*$/i,
+  /^\s*Analyze User Input:?\s*$/i,
+  /^\s*User Question:\s*/i,
+  /^\s*Context:\s*/i,
+  /^\s*Intent:\s*/i,
+  /^\s*Is Professional Question:\s*/i,
   /^\s*[-*•]?\s*Role:\s*/i,
   /^\s*[-*•]?\s*Task:\s*/i,
   /^\s*[-*•]?\s*Input:\s*/i,
@@ -3082,7 +3381,7 @@ const getDisplayThinkingContent = (content) => {
   const rawContent = String(content || '')
   if (!rawContent.trim()) return ''
 
-  const hasMetaBoilerplate = /Thinking Process:|Analyze the Request|Role:|Task:|Input:|Constraints:/i.test(rawContent)
+  const hasMetaBoilerplate = hasRawThinkingLeak(rawContent)
   const normalizedLines = rawContent
     .split('\n')
     .map(line => line.replace(/\r/g, ''))
@@ -3102,17 +3401,47 @@ const getDisplayThinkingContent = (content) => {
       return !THINKING_BLOCKLIST_PATTERNS.some(pattern => pattern.test(trimmed))
     })
 
-  const normalizedContent = normalizedLines.join('\n').trim()
-  if (normalizedContent) {
+  const normalizedContent = normalizeDisplayThinkingContent(normalizedLines.join('\n'))
+  if (normalizedContent && !hasMetaBoilerplate) {
     return normalizedContent
   }
-  return hasMetaBoilerplate ? THINKING_FALLBACK_TEXT : rawContent.trim()
+  return hasMetaBoilerplate
+    ? buildDisplayThinkingSummary({ rawThinking: rawContent })
+    : normalizeDisplayThinkingContent(rawContent)
+}
+
+const ensureThinkingFallback = (aiMessage) => {
+  if (!aiMessage.thinkingContent || !aiMessage.thinkingContent.trim()) {
+    aiMessage.thinkingContent = buildDisplayThinkingSummary({
+      rawThinking: aiMessage.rawThinkingContent || aiMessage.rawIntentThinkingContent || '',
+      userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+      summary: aiMessage._fullSummary || aiMessage.summary || ''
+    })
+  }
+  aiMessage.showThinking = true
 }
 
+const buildInitialThinkingContent = (question) => buildDisplayThinkingSummary({
+  userQuestion: question || currentQuestion.value || '',
+  rawThinking: question || currentQuestion.value || ''
+})
+
 const appendThinkingContent = (aiMessage, sectionTitle, content) => {
-  const normalized = (content || '').trim()
+  const normalized = normalizeDisplayThinkingContent(content)
   if (!normalized) return
 
+  if (hasRawThinkingLeak(normalized)) {
+    aiMessage.rawThinkingContent = `${aiMessage.rawThinkingContent || ''}${normalized}`
+    aiMessage.thinkingContent = buildDisplayThinkingSummary({
+      rawThinking: aiMessage.rawThinkingContent,
+      existingThinking: aiMessage.thinkingContent,
+      userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+      summary: aiMessage._fullSummary || aiMessage.summary || ''
+    })
+    aiMessage.showThinking = true
+    return
+  }
+
   if (!aiMessage.thinkingContent) {
     aiMessage.thinkingContent = normalized
   } else if (!aiMessage.thinkingContent.includes(normalized)) {
@@ -3125,14 +3454,69 @@ const appendThinkingContent = (aiMessage, sectionTitle, content) => {
 const appendThinkingDelta = (aiMessage, chunk) => {
   const normalized = chunk || ''
   if (!normalized) return
-  aiMessage.thinkingContent = `${aiMessage.thinkingContent || ''}${normalized}`
+  aiMessage.rawThinkingContent = `${aiMessage.rawThinkingContent || ''}${normalized}`
+  aiMessage.thinkingContent = buildDisplayThinkingSummary({
+    rawThinking: aiMessage.rawThinkingContent,
+    existingThinking: aiMessage.thinkingContent === THINKING_FALLBACK_TEXT ? '' : normalizeDisplayThinkingContent(aiMessage.thinkingContent),
+    userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+    summary: aiMessage._fullSummary || aiMessage.summary || ''
+  })
   aiMessage.showThinking = true
 }
 
+const finalizeThinkingContent = (aiMessage) => {
+  const rawThinking = aiMessage.rawThinkingContent || aiMessage.rawIntentThinkingContent || ''
+  aiMessage.thinkingContent = buildDisplayThinkingSummary({
+    rawThinking,
+    existingThinking: hasRawThinkingLeak(aiMessage.thinkingContent) || aiMessage.thinkingContent === THINKING_FALLBACK_TEXT
+      ? ''
+      : normalizeDisplayThinkingContent(aiMessage.thinkingContent),
+    userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+    summary: aiMessage._fullSummary || aiMessage.summary || ''
+  })
+  aiMessage.thinkingContent = normalizeDisplayThinkingContent(aiMessage.thinkingContent)
+  aiMessage.showThinking = Boolean(aiMessage.thinkingContent)
+}
+
 const toggleThinkingPanel = (message) => {
   message.showThinking = message.showThinking === false
 }
 
+const shouldShowDatabaseFiles = (message) => {
+  if (!message?.reports || message.reports.length === 0) {
+    return false
+  }
+
+  if (!message.thinkingContent || message.thinkingStreaming) {
+    return false
+  }
+
+  if (message.answerStreaming || message.isTyping || message._answerDisplayComplete === false) {
+    return false
+  }
+
+  return Boolean(
+    (message.displayContent && message.displayContent.length > 0)
+    || message._answerDisplayComplete
+    || message.currentStatus === 'completed'
+    || Number(message.progress || 0) >= 100
+  )
+}
+
+const getVisibleDatabaseReports = (message) => {
+  if (!shouldShowDatabaseFiles(message)) {
+    return []
+  }
+
+  const reports = getDatabaseDisplayReports(message)
+  if (message._databaseReportsRevealComplete) {
+    return reports
+  }
+
+  const visibleCount = Math.max(0, Number(message._visibleDatabaseReportCount || 0))
+  return reports.slice(0, Math.min(visibleCount, reports.length))
+}
+
 const handleSSEMessage = (data, aiMessageIndex) => {
   const aiMessage = chatMessages.value[aiMessageIndex]
   if (!aiMessage) return
@@ -3208,6 +3592,19 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       // 专业问题:意图识别完成,更新为查询知识库状态
       updateMessageStatus(aiMessage, 'querying_kb')
 
+      if (data.thinking_content) {
+        aiMessage.rawIntentThinkingContent = data.thinking_content
+        if (!aiMessage.rawThinkingContent) {
+          aiMessage.thinkingContent = buildDisplayThinkingSummary({
+            rawThinking: data.thinking_content,
+            existingThinking: '',
+            userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+            summary: data.summary || aiMessage._fullSummary || aiMessage.summary || ''
+          })
+          aiMessage.showThinking = true
+        }
+      }
+
       // 如果启用联网搜索,稍后会更新为web_searching状态
       // (当收到web_search_raw事件时)
 
@@ -3233,6 +3630,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
     case 'answer_thinking_start':
       aiMessage.thinkingStreaming = true
       aiMessage.showThinking = true
+      stopStreamingAnswerTypewriter(aiMessage)
       if (shouldApplyMessageProgressStatus(aiMessage, 'deep_thinking')) {
         updateMessageStatus(aiMessage, 'deep_thinking')
       }
@@ -3244,6 +3642,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
 
     case 'answer_thinking_done':
       aiMessage.thinkingStreaming = false
+      finalizeThinkingContent(aiMessage)
       if (!shouldHideStatsForStreamingAnswer(aiMessage) && aiMessage.currentStatus === 'deep_thinking') {
         updateMessageStatus(
           aiMessage,
@@ -3251,6 +3650,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
           '😄 <span class="ai-name">蜀道安全管理AI智能助手</span>正在整理回答内容……'
         )
       }
+      startAnswerRenderingIfReady(aiMessage)
       break
 
     case 'answer_content_start':
@@ -3263,32 +3663,36 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
       }
-      aiMessage.answerStreaming = true
+      aiMessage._answerContentDone = false
+      aiMessage.answerStreaming = canRenderAnswerContent(aiMessage)
       aiMessage.isTyping = true
-      if (!aiMessage.content) {
-        aiMessage.content = ''
-      }
-      if (!aiMessage.displayContent) {
-        aiMessage.displayContent = ''
-      }
+      markAnswerContentStarted(aiMessage, { resetDisplay: true })
+      startAnswerRenderingIfReady(aiMessage)
       break
 
     case 'answer_content_delta': {
       const delta = data.chunk || ''
       if (!delta) break
+      if (!aiMessage._answerContentStarted) {
+        markAnswerContentStarted(aiMessage)
+      }
       aiMessage.content = `${aiMessage.content || ''}${delta}`
-      const processedReply = processAIResponse(aiMessage.content)
-      aiMessage.displayContent = renderMarkdownContent(processedReply)
       aiMessage.isTyping = true
+      startAnswerRenderingIfReady(aiMessage)
       break
     }
 
     case 'answer_content_done':
+      if (!aiMessage._answerContentStarted && aiMessage.content) {
+        markAnswerContentStarted(aiMessage)
+      }
+      aiMessage._answerContentDone = true
       aiMessage.answerStreaming = false
-      aiMessage.isTyping = false
+      startAnswerRenderingIfReady(aiMessage)
       break
 
     case 'online_answer': {
+      stopStreamingAnswerTypewriter(aiMessage)
       aiMessage.answerStreaming = false
       aiMessage.thinkingStreaming = false
       if (shouldHideStatsForStreamingAnswer(aiMessage)) {
@@ -3314,11 +3718,16 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       const processedReply = processAIResponse(finalContent)
       const renderedReply = renderMarkdownContent(processedReply)
 
-      trackMessageOutputRender(aiMessage, startTypewriterEffect(aiMessage, renderedReply, 200))
+      aiMessage._answerDisplayComplete = false
+      trackMessageOutputRender(aiMessage, startTypewriterEffect(aiMessage, renderedReply, FINAL_ANSWER_TYPEWRITER_SPEED))
+    .then(() => {
+          markAnswerDisplayComplete(aiMessage)
+        })
         .catch(err => {
           console.error('在线回答打字机效果失败:', err)
           aiMessage.displayContent = renderedReply
           aiMessage.isTyping = false
+          markAnswerDisplayComplete(aiMessage)
         })
       break
     }
@@ -3331,6 +3740,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
       
     case 'documents':
+      finalizeStreamingThinkingIfNeeded(aiMessage)
       aiMessage.totalFiles = data.total
       aiMessage.completedCount = 0
       
@@ -3351,6 +3761,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
       
     case 'category_title':
+      finalizeStreamingThinkingIfNeeded(aiMessage)
       // 第一个分类标题时,说明开始分析文件
       if (aiMessage.reports.length === 0) {
         // 只有在当前状态进度 >= data_retrieved的进度时,才更新为analyzing_files
@@ -3382,6 +3793,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_start':
+      finalizeStreamingThinkingIfNeeded(aiMessage)
       // 调试日志:查看完整的数据结构
       console.log('🔍 [DEBUG] report_start 数据:', {
         file_index: data.file_index,
@@ -3420,6 +3832,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
       
     case 'report':
+      finalizeStreamingThinkingIfNeeded(aiMessage)
       // 第一个报告开始时,更新到深度思考状态
       if (aiMessage.reports.filter(r => r.status === 'completed').length === 0) {
         updateMessageStatus(aiMessage, 'deep_thinking')
@@ -3456,9 +3869,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: hadStreamingContent ? fullSummary : '',
-            analysis: hadStreamingContent ? fullAnalysis : '',
-            clauses: hadStreamingContent ? fullClauses : ''
+            summary: fullSummary,
+            analysis: fullAnalysis,
+            clauses: fullClauses
           },
           status: 'completed',
           metadata: {
@@ -3471,7 +3884,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             analysis: fullAnalysis,
             clauses: fullClauses
           },
-          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
+          _typewriterCompleted: true
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)
@@ -3485,9 +3898,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: fullSummary,
+            analysis: fullAnalysis,
+            clauses: fullClauses
           },
           status: 'completed',
           metadata: {
@@ -3499,50 +3912,14 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: true
         }
         aiMessage.reports.push(newReport)
         targetReport = newReport
       }
       
-      // 使用顺序打字机效果:概述 -> 解析 -> 相关条款
-      if (targetReport._fullContent && !targetReport._typewriterCompleted) {
-        // 标记打字机已启动,防止重复触发
-        targetReport._typewriterStarted = true
-        
-        // 先打概述(速度200 = 每次20个字符,极快)
-        const reportRenderPromise = startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
-          .then(() => {
-            // 概述完成后打解析
-            return startReportFieldTypewriter(targetReport, 'analysis', targetReport._fullContent.analysis || '', 200)
-          })
-          .then(() => {
-            // 解析完成后打相关条款
-            if (targetReport._fullContent.clauses) {
-              return startReportFieldTypewriter(targetReport, 'clauses', targetReport._fullContent.clauses || '', 200)
-            }
-          })
-          .then(() => {
-            // 全部完成,标记为已完成
-            targetReport._typewriterCompleted = true
-          })
-        trackMessageOutputRender(aiMessage, reportRenderPromise)
-          .catch(err => {
-            console.error('报告打字机效果失败:', err)
-            // 失败时直接显示完整内容
-            targetReport.report.summary = targetReport._fullContent.summary || ''
-            targetReport.report.analysis = targetReport._fullContent.analysis || ''
-            targetReport.report.clauses = targetReport._fullContent.clauses || ''
-            targetReport._typewriterCompleted = true
-          })
-        
-        console.log('📝 [DEBUG] 报告打字机已启动:', {
-          file_index: targetReport.file_index,
-          summary_length: targetReport._fullContent.summary?.length || 0,
-          analysis_length: targetReport._fullContent.analysis?.length || 0,
-          clauses_length: targetReport._fullContent.clauses?.length || 0
-        })
-      }
+      // 文件区在正文之后才展示,报告内容直接缓存完整值,避免隐藏区域后台打字造成卡顿。
       
       // 更新进度
       aiMessage.completedCount = aiMessage.reports.filter(r => r.status === 'completed' && r.type !== 'category_title').length
@@ -3739,13 +4116,22 @@ const handleSSEComplete = async () => {
       
       if (message.ai_message_id) {
         // 构建完整的内容数据,包含报告、网络搜索结果和summary
+        const safeThinkingContent = sanitizeThinkingContentForPersistence(
+          message.thinkingContent || message.rawThinkingContent || '',
+          {
+            rawThinking: message.rawThinkingContent || '',
+            userQuestion: message.userQuestion || '',
+            summary: message._fullSummary || message.summary || ''
+          }
+        )
+
         const contentData = {
           reports: normalizeReportsForPersistence(message.reports || []),
           webSearchRaw: message.webSearchRaw || null,
           // 使用完整的webSearchSummary,而不是打字机过程中的部分内容
           webSearchSummary: message._fullWebSearchSummary || message.webSearchSummary || null,
           hasWebSearchResults: message.hasWebSearchResults || false,
-          thinkingContent: message.thinkingContent || '',
+          thinkingContent: safeThinkingContent,
           // ===== 🔧 修复:将summary也包含在content JSON中 =====
           summary: message._fullSummary || message.summary || ''
         }
@@ -3753,10 +4139,10 @@ const handleSSEComplete = async () => {
         const plainAnswer = message.content || message._fullSummary || message.summary || ''
         const collectedContent = message.reports && message.reports.length > 0 
           ? JSON.stringify(contentData)
-          : (message.thinkingContent
+          : (safeThinkingContent
               ? JSON.stringify({
                   answer: plainAnswer,
-                  thinkingContent: message.thinkingContent
+                  thinkingContent: safeThinkingContent
                 })
               : plainAnswer)
         
@@ -3870,11 +4256,13 @@ const handleSSEComplete = async () => {
     if (aiReplyContent && aiReplyContent.trim()) {
       console.log('📝 AI回复内容长度:', aiReplyContent.length)
       // 获取AI相关推荐问题
-      getAIRelatedQuestions(
-        lastUserMessage.content, 
-        aiReplyContent, 
-        lastAIMessage.ai_message_id
-      )
+      if (getDatabaseDisplayReports(lastAIMessage).length === 0) {
+        getAIRelatedQuestions(
+          lastUserMessage.content, 
+          aiReplyContent, 
+          lastAIMessage.ai_message_id
+        )
+      }
     } else {
       console.warn('⚠️ AI回复内容为空,跳过推荐问题获取')
     }
@@ -4041,15 +4429,16 @@ const handleReportGeneratorSubmit = async (data) => {
   
   // 添加AI消息占位符
   const aiMessageIndex = chatMessages.value.length
+  const initialThinkingContent = buildInitialThinkingContent(data.question)
   chatMessages.value.push({
     id: Date.now() + 1,
     type: 'ai',
     userQuestion: data.question, // 用户问题
     summary: '',
     isProfessionalQuestion: null,
-    thinkingContent: '',
+    thinkingContent: initialThinkingContent,
     showThinking: true,
-    thinkingStreaming: false,
+    thinkingStreaming: true,
     answerStreaming: false,
     totalFiles: 0,
     webSearchTotal: 0,
@@ -5624,15 +6013,22 @@ const showLinkInIframe = (url) => {
 }
 
 // 文件预览处理函数
-const handleFilePreview = (data) => {
-  if (typeof data === 'string') {
-    previewFilePath.value = data
-    previewFileName.value = ''
-  } else {
-    previewFilePath.value = data.filePath
-    previewFileName.value = data.fileName || ''
-  }
+const handleFilePreview = async (data) => {
+  const rawPath = typeof data === 'string' ? data : data?.filePath
+  const fileName = typeof data === 'string' ? '' : (data?.fileName || '')
+
+  previewFilePath.value = buildPreviewUrlSync(rawPath)
+  previewFileName.value = fileName
   showFilePreview.value = true
+
+  try {
+    const convertedPath = await buildPreviewUrl(rawPath)
+    if (convertedPath && convertedPath !== previewFilePath.value) {
+      previewFilePath.value = convertedPath
+    }
+  } catch (error) {
+    console.warn('文件预览链接转换失败,使用原始链接:', error)
+  }
 }
 
 // 处理网络搜索胶囊点击
@@ -6837,6 +7233,22 @@ onActivated(async () => {
   .reports-list {
     margin-top: 8px;
   }
+
+  .database-report-reveal-item {
+    animation: database-report-fade-in 320ms ease-out both;
+    will-change: opacity, transform;
+  }
+
+  @keyframes database-report-fade-in {
+    from {
+      opacity: 0;
+      transform: translateY(8px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0);
+    }
+  }
   
   .report-loading {
     display: flex;
@@ -7375,12 +7787,18 @@ onActivated(async () => {
 
         :deep(ul),
         :deep(ol) {
-          margin: 8px 0;
-          padding-left: 24px;
+          margin: 8px 0 10px;
+          padding-left: 0;
+          list-style: none;
+          box-sizing: border-box;
+          max-width: 100%;
         }
 
         :deep(li) {
-          margin: 4px 0;
+          margin: 6px 0;
+          padding-left: 0;
+          overflow-wrap: anywhere;
+          word-break: break-word;
         }
 
         :deep(blockquote) {
@@ -8847,4 +9265,28 @@ onActivated(async () => {
   border-color: #d1d5db !important;
 }
 
+:deep(.brief-answer-card .ai-markdown-content ul),
+:deep(.brief-answer-card .ai-markdown-content ol),
+:deep(.brief-answer-card .ai-markdown-content div > ul),
+:deep(.brief-answer-card .ai-markdown-content div > ol) {
+  margin: 8px 0 10px !important;
+  padding-left: 0 !important;
+  list-style: none !important;
+}
+
+:deep(.brief-answer-card .ai-markdown-content li) {
+  display: block !important;
+  margin: 6px 0 !important;
+  padding-left: 0 !important;
+  list-style: none !important;
+  overflow-wrap: anywhere !important;
+  word-break: break-word !important;
+}
+
+:deep(.brief-answer-card .ai-markdown-content li::marker) {
+  content: '' !important;
+  display: none !important;
+  font-size: 0 !important;
+}
+
 </style>

File diff suppressed because it is too large
+ 566 - 83
shudao-vue-frontend/src/views/mobile/m-Chat.vue


Some files were not shown because too many files changed in this diff