Преглед изворни кода

Merge remote-tracking branch 'origin/server_test' into dev

# Conflicts:
#	shudao-chat-py/routers/chat.py   resolved by origin/server_test(远端) version
FanHong пре 3 недеља
родитељ
комит
4b6ef6a81c
41 измењених фајлова са 2637 додато и 455 уклоњено
  1. 4 0
      .gitignore
  2. 3 2
      shudao-chat-py/models/points.py
  3. 11 1
      shudao-chat-py/models/report.py
  4. 378 222
      shudao-chat-py/routers/chat.py
  5. 94 5
      shudao-chat-py/routers/hazard.py
  6. 2 9
      shudao-chat-py/routers/points.py
  7. 33 2
      shudao-chat-py/routers/report_compat.py
  8. 14 8
      shudao-chat-py/routers/scene.py
  9. 129 37
      shudao-chat-py/routers/total.py
  10. 47 1
      shudao-chat-py/services/aichat_proxy.py
  11. 154 0
      shudao-chat-py/tests/test_chat_ai_writing_route.py
  12. 4 2
      shudao-chat-py/utils/config.py
  13. 11 1
      shudao-vue-frontend/src/components/QuestionInput.vue
  14. 3 0
      shudao-vue-frontend/src/request/apis.js
  15. 58 0
      shudao-vue-frontend/src/utils/aiWritingContent.js
  16. 31 0
      shudao-vue-frontend/src/utils/aiWritingContent.test.js
  17. 29 0
      shudao-vue-frontend/src/utils/aiWritingRequest.js
  18. 69 0
      shudao-vue-frontend/src/utils/aiWritingRequest.test.js
  19. 19 0
      shudao-vue-frontend/src/utils/attachmentContext.js
  20. 28 0
      shudao-vue-frontend/src/utils/attachmentContext.test.js
  21. 10 0
      shudao-vue-frontend/src/utils/attachmentFile.js
  22. 18 0
      shudao-vue-frontend/src/utils/attachmentFile.test.js
  23. 103 1
      shudao-vue-frontend/src/utils/chatHistoryPersistence.js
  24. 140 0
      shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js
  25. 32 0
      shudao-vue-frontend/src/utils/chatInputKeydown.js
  26. 75 0
      shudao-vue-frontend/src/utils/chatInputKeydown.test.js
  27. 41 0
      shudao-vue-frontend/src/utils/generatedDocumentCard.js
  28. 29 0
      shudao-vue-frontend/src/utils/generatedDocumentCard.test.js
  29. 37 0
      shudao-vue-frontend/src/utils/resizableSidebar.js
  30. 57 0
      shudao-vue-frontend/src/utils/resizableSidebar.test.js
  31. 6 0
      shudao-vue-frontend/src/utils/safetyTrainingNavigation.js
  32. 12 0
      shudao-vue-frontend/src/utils/safetyTrainingNavigation.test.js
  33. 15 0
      shudao-vue-frontend/src/utils/safetyTrainingRouteLoading.js
  34. 25 0
      shudao-vue-frontend/src/utils/safetyTrainingRouteLoading.test.js
  35. 20 0
      shudao-vue-frontend/src/views/Chat.pointsBalance.test.js
  36. 51 0
      shudao-vue-frontend/src/views/Chat.thinkingPanelOrder.test.js
  37. 14 0
      shudao-vue-frontend/src/views/Chat.userMessageWhitespace.test.js
  38. 535 119
      shudao-vue-frontend/src/views/Chat.vue
  39. 30 2
      shudao-vue-frontend/src/views/HazardDetection.vue
  40. 127 24
      shudao-vue-frontend/src/views/SafetyHazard.vue
  41. 139 19
      shudao-vue-frontend/src/views/mobile/m-Chat.vue

+ 4 - 0
.gitignore

@@ -54,3 +54,7 @@ shudao-go-backend/views/index.html
 .npm-cache
 .npm-cache
 
 
 shudao-vue-frontend/.playwright-cli/
 shudao-vue-frontend/.playwright-cli/
+
+# Local regression tests not required for runtime deployment
+shudao-vue-frontend/src/views/Chat.feedbackPoints.test.js
+shudao-chat-py/tests/test_like_feedback_points.py

+ 3 - 2
shudao-chat-py/models/points.py

@@ -1,4 +1,5 @@
-from sqlalchemy import Column, Integer, String, BigInteger, Text, Index
+from sqlalchemy import Column, Integer, String, BigInteger, Text, TIMESTAMP, Index
+from sqlalchemy.sql import func
 from database import Base
 from database import Base
 
 
 
 
@@ -12,4 +13,4 @@ class PointsConsumptionLog(Base):
     file_url = Column(Text)                                    # 允许为空
     file_url = Column(Text)                                    # 允许为空
     points_consumed = Column(Integer, nullable=False, default=10)  # 默认10积分
     points_consumed = Column(Integer, nullable=False, default=10)  # 默认10积分
     balance_after = Column(Integer, nullable=False)            # 消费后余额,不允许为空
     balance_after = Column(Integer, nullable=False)            # 消费后余额,不允许为空
-    created_at = Column(Integer, default=0)                    # Unix时间戳
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp())  # 数据库timestamp类型

+ 11 - 1
shudao-chat-py/models/report.py

@@ -2,7 +2,16 @@
 报告相关数据模型
 报告相关数据模型
 """
 """
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
-from typing import Optional
+from typing import List, Optional
+
+
+class UploadedDocumentContext(BaseModel):
+    """用户上传文档上下文"""
+    file_name: str = Field(..., description="上传文件名")
+    file_type: str = Field(default="", description="文件类型")
+    content: str = Field(..., description="后端提取的纯文本内容")
+    attachment_id: Optional[str] = Field(default=None, description="附件解析ID")
+    char_count: Optional[int] = Field(default=None, description="提取文本字符数")
 
 
 
 
 class ReportCompleteFlowRequest(BaseModel):
 class ReportCompleteFlowRequest(BaseModel):
@@ -14,6 +23,7 @@ class ReportCompleteFlowRequest(BaseModel):
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     is_network_search_enabled: bool = Field(default=False, description="是否启用联网搜索")
     is_network_search_enabled: bool = Field(default=False, description="是否启用联网搜索")
     enable_online_model: bool = Field(default=False, description="是否启用在线模型")
     enable_online_model: bool = Field(default=False, description="是否启用在线模型")
+    uploaded_documents: List[UploadedDocumentContext] = Field(default_factory=list, description="用户上传文档上下文")
 
 
 
 
 class UpdateAIMessageRequest(BaseModel):
 class UpdateAIMessageRequest(BaseModel):

+ 378 - 222
shudao-chat-py/routers/chat.py

@@ -9,6 +9,7 @@ from models.total import RecommendQuestion
 from utils.config import settings
 from utils.config import settings
 from utils.logger import logger
 from utils.logger import logger
 from services.qwen_service import qwen_service
 from services.qwen_service import qwen_service
+from services.deepseek_service import deepseek_service
 from utils.prompt_loader import load_prompt
 from utils.prompt_loader import load_prompt
 from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
 from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
 import time
 import time
@@ -171,124 +172,6 @@ def _finalize_related_questions(questions: list, content: str, limit: int = 3) -
     return cleaned_questions[:limit]
     return cleaned_questions[:limit]
 
 
 
 
-def _extract_json_object_candidates(text: str) -> list[str]:
-    text = text or ""
-    objects = []
-    start = -1
-    depth = 0
-    in_string = False
-    string_quote = '"'
-    escaped = False
-
-    for index, ch in enumerate(text):
-        if escaped:
-            escaped = False
-            continue
-
-        if in_string:
-            if ch == "\\":
-                escaped = True
-                continue
-            if ch == string_quote:
-                in_string = False
-            continue
-
-        if ch in ('"', "'"):
-            in_string = True
-            string_quote = ch
-            continue
-
-        if ch == "{":
-            if depth == 0:
-                start = index
-            depth += 1
-            continue
-
-        if ch == "}" and depth > 0:
-            depth -= 1
-            if depth == 0 and start >= 0:
-                objects.append(text[start:index + 1])
-                start = -1
-
-    return objects
-
-
-def _extract_first_json_object(text: str) -> str:
-    candidates = _extract_json_object_candidates(text)
-    if not candidates:
-        raise ValueError("未找到完整的JSON对象")
-    return candidates[0]
-
-
-def _looks_like_exam_payload(payload) -> bool:
-    if not isinstance(payload, dict):
-        return False
-    questions_root = payload.get("questions") if isinstance(
-        payload.get("questions"), dict) else {}
-    return any([
-        payload.get("singleChoice"),
-        payload.get("single_choice"),
-        payload.get("judge"),
-        payload.get("multiple"),
-        payload.get("multiple_choice"),
-        payload.get("multipleChoice"),
-        payload.get("short"),
-        payload.get("short_answer"),
-        payload.get("shortAnswer"),
-        payload.get("单选题"),
-        payload.get("判断题"),
-        payload.get("多选题"),
-        payload.get("简答题"),
-        questions_root.get("single_choice"),
-        questions_root.get("singleChoice"),
-        questions_root.get("judge"),
-        questions_root.get("multiple"),
-        questions_root.get("multiple_choice"),
-        questions_root.get("multipleChoice"),
-        questions_root.get("short"),
-        questions_root.get("short_answer"),
-        questions_root.get("shortAnswer"),
-        questions_root.get("单选题"),
-        questions_root.get("判断题"),
-        questions_root.get("多选题"),
-        questions_root.get("简答题"),
-    ])
-
-
-def _extract_exam_json_content(response_text: str) -> str:
-    raw_thinking, raw_answer = split_thinking_and_answer(response_text or "")
-    answer_text = (raw_answer or response_text or "").strip()
-    candidates = _extract_json_object_candidates(answer_text)
-    if not candidates:
-        raise ValueError("未找到完整的JSON对象")
-
-    normalized_candidates = [
-        candidate
-        .replace("“", '"')
-        .replace("”", '"')
-        .replace("‘", "'")
-        .replace("’", "'")
-        .replace(",", ",")
-        for candidate in candidates
-    ]
-
-    parsed_candidates = []
-    for candidate in normalized_candidates:
-        try:
-            parsed_candidates.append((candidate, json.loads(candidate)))
-        except Exception:
-            continue
-
-    if not parsed_candidates:
-        return max(normalized_candidates, key=len)
-
-    for candidate, parsed in parsed_candidates:
-        if _looks_like_exam_payload(parsed):
-            return candidate
-
-    return max((candidate for candidate, _ in parsed_candidates), key=len)
-
-
 def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
 def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
     latest_message = (
     latest_message = (
         db.query(AIMessage)
         db.query(AIMessage)
@@ -370,6 +253,310 @@ async def _rag_search(message: str, top_k: int = 5) -> str:
     return ""
     return ""
 
 
 
 
+SAFETY_TRAINING_PLAN_SYSTEM_PROMPT = """
+你是安全培训需求整理助手。请把用户的自然语言输入整理成安全培训PPT大纲生成任务。
+
+规则:
+1. 只输出一个 JSON 对象,不要输出 Markdown、解释或额外文字。
+2. 即使用户说“通知”“材料”“文档”,也必须理解为安全培训模块中的 PPT 大纲需求,不要切换到其他文档生成任务。
+3. 如果字段缺失,请根据安全培训场景合理补全,但不要编造具体制度编号、人员姓名或不存在的事实。
+4. template 字段用于选择大纲模板,默认填“标准安全培训PPT大纲”。
+5. content_focus 至少给出 3 个要点。
+
+JSON 字段:
+{
+  "topic": "培训主题",
+  "template": "模板名称",
+  "content_focus": ["内容要点1", "内容要点2", "内容要点3"],
+  "audience": "参训对象",
+  "time": "培训时间",
+  "location": "培训地点",
+  "goal": "培训目标",
+  "notes": "其他要求",
+  "normalized_request": "归一化后的安全培训PPT大纲生成需求"
+}
+"""
+
+
+def _extract_tag_value(message: str, tag: str) -> str:
+    match = re.search(fr"<{tag}>(.*?)</{tag}>", message or "", re.DOTALL)
+    return match.group(1).strip() if match else ""
+
+
+def _strip_document_tags(message: str) -> str:
+    text = message or ""
+    for tag in ("word", "filename", "filesize"):
+        text = re.sub(fr"<{tag}>.*?</{tag}>", " ", text, flags=re.DOTALL)
+    return re.sub(r"\s+", " ", text).strip()
+
+
+def _extract_safety_training_request_payload(message: str) -> dict:
+    return {
+        "document_content": _extract_tag_value(message, "word"),
+        "filename": _extract_tag_value(message, "filename"),
+        "filesize": _extract_tag_value(message, "filesize"),
+        "request": _strip_document_tags(message),
+    }
+
+
+def _clean_safety_training_topic(message: str) -> str:
+    request_text = _extract_safety_training_request_payload(message)["request"]
+    first_clause = re.split(r"[,。;;,\n]", request_text, maxsplit=1)[0].strip()
+    topic = first_clause or request_text or "安全培训"
+    for token in ("请", "帮我", "帮忙", "生成", "制作", "输出", "一份", "一个", "一下", "PPT大纲", "ppt大纲", "大纲", "通知", "文档", "材料"):
+        topic = topic.replace(token, "")
+    topic = re.sub(r"\s+", "", topic).strip(" ::,,。;;")
+    if not topic:
+        topic = "安全培训"
+    if "培训" not in topic:
+        topic = f"{topic}安全培训"
+    return topic
+
+
+def _parse_json_object(text: str) -> dict:
+    if not text:
+        return {}
+    cleaned = re.sub(r"```(?:json)?\s*", "", str(text)).replace("```", "").strip()
+    match = re.search(r"\{.*\}", cleaned, re.DOTALL)
+    if not match:
+        return {}
+    try:
+        parsed = json.loads(match.group(0))
+        return parsed if isinstance(parsed, dict) else {}
+    except json.JSONDecodeError:
+        return {}
+
+
+def _build_fallback_safety_training_plan(message: str) -> dict:
+    topic = _clean_safety_training_topic(message)
+    payload = _extract_safety_training_request_payload(message)
+    return {
+        "topic": topic,
+        "template": "标准安全培训PPT大纲",
+        "content_focus": ["安全生产责任", "现场风险识别", "安全意识提升", "培训纪律与行为规范"],
+        "audience": "参训员工",
+        "time": "",
+        "location": "",
+        "goal": "提升参训人员安全意识和施工现场风险防控能力",
+        "notes": payload["request"],
+        "normalized_request": f"围绕{topic}生成安全培训PPT大纲",
+    }
+
+
+def _normalize_safety_training_plan(message: str, raw_plan: dict) -> dict:
+    plan = _build_fallback_safety_training_plan(message)
+    if not isinstance(raw_plan, dict):
+        return plan
+
+    for key in ("topic", "template", "audience", "time", "location", "goal", "notes", "normalized_request"):
+        value = raw_plan.get(key)
+        if isinstance(value, str) and value.strip():
+            plan[key] = value.strip()
+
+    focus = raw_plan.get("content_focus")
+    if isinstance(focus, list):
+        normalized_focus = [str(item).strip() for item in focus if str(item).strip()]
+        if normalized_focus:
+            plan["content_focus"] = normalized_focus
+    elif isinstance(focus, str) and focus.strip():
+        plan["content_focus"] = [item.strip() for item in re.split(r"[、,,;\n]", focus) if item.strip()]
+
+    if "培训" not in plan["topic"]:
+        plan["topic"] = f"{plan['topic']}安全培训"
+    if "PPT大纲" not in plan["template"]:
+        plan["template"] = f"{plan['template']}PPT大纲"
+    return plan
+
+
+def _build_safety_training_generation_message(message: str, plan: dict) -> str:
+    payload = _extract_safety_training_request_payload(message)
+    focus_text = "、".join(plan.get("content_focus") or [])
+    lines = [
+        "输出类型:安全培训PPT大纲",
+        "请基于以下结构化需求生成安全培训PPT大纲,不要生成通知正文,不要切换到其他文档生成任务。",
+        f"主题:{plan.get('topic') or '安全培训'}",
+        f"模板:{plan.get('template') or '标准安全培训PPT大纲'}",
+        f"内容要点:{focus_text or '安全生产责任、风险识别、应急处置、安全意识提升'}",
+        f"参训对象:{plan.get('audience') or '参训员工'}",
+        f"培训时间:{plan.get('time') or '未指定'}",
+        f"培训地点:{plan.get('location') or '未指定'}",
+        f"培训目标:{plan.get('goal') or '提升参训人员安全意识和风险防控能力'}",
+        f"其他要求:{plan.get('notes') or '无'}",
+        f"归一化需求:{plan.get('normalized_request') or ''}",
+        f"原始需求:{payload['request'] or message}",
+    ]
+    if payload["filename"] or payload["document_content"]:
+        lines.extend([
+            f"上传文档名称:{payload['filename'] or '未命名文档'}",
+            f"上传文档大小:{payload['filesize'] or '未知'}",
+            "上传文档内容:",
+            payload["document_content"] or "无",
+        ])
+    return "\n".join(lines)
+
+
+async def _infer_safety_training_plan(message: str) -> dict:
+    payload = _extract_safety_training_request_payload(message)
+    planning_input = payload["request"] or message
+    if payload["document_content"]:
+        planning_input = (
+            f"{planning_input}\n\n"
+            f"上传文档名称:{payload['filename'] or '未命名文档'}\n"
+            f"上传文档内容摘要:{payload['document_content'][:3000]}"
+        )
+
+    try:
+        response = await qwen_service.chat([
+            {"role": "system", "content": SAFETY_TRAINING_PLAN_SYSTEM_PROMPT},
+            {"role": "user", "content": planning_input},
+        ])
+        return _normalize_safety_training_plan(message, _parse_json_object(response))
+    except Exception as e:
+        logger.warning(f"[safety_training] 需求整理失败,使用兜底结构: {type(e).__name__}: {e}")
+        return _build_fallback_safety_training_plan(message)
+
+
+def _clean_ai_writing_response(content: str) -> str:
+    text = str(content or "").strip()
+    if not text:
+        return ""
+
+    text = re.sub(r"```(?:html)?\s*", "", text, flags=re.IGNORECASE).replace("```", "").strip()
+
+    body_match = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
+    if body_match:
+        text = body_match.group(1).strip()
+
+    first_content_tag = re.search(
+        r"<(?:article|section|main|div|h[1-6]|p|table|ul|ol)\b",
+        text,
+        re.IGNORECASE,
+    )
+    if first_content_tag and text[:first_content_tag.start()].strip():
+        text = text[first_content_tag.start():]
+
+    cleanup_patterns = (
+        r"<!DOCTYPE[^>]*>",
+        r"<html[^>]*>",
+        r"</html>",
+        r"<head[^>]*>.*?</head>",
+        r"<body[^>]*>",
+        r"</body>",
+        r"<style[^>]*>.*?</style>",
+        r"<script[^>]*>.*?</script>",
+        r"<meta[^>]*>",
+        r"<title[^>]*>.*?</title>",
+    )
+    for pattern in cleanup_patterns:
+        text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.DOTALL)
+
+    return text.strip()
+
+
+async def _generate_ai_writing_response(message: str) -> str:
+    rag_context = await _rag_search(message, top_k=10)
+    system_content = load_prompt(
+        "document_writing",
+        userMessage=message,
+        contextJSON=rag_context if rag_context else "暂无相关知识库内容",
+    )
+
+    messages = [
+        {"role": "system", "content": system_content},
+        {
+            "role": "user",
+            "content": (
+                "请根据上面的写作规范和我的原始需求,直接生成可放入富文本编辑器的公文正文 HTML 片段。"
+                "不要输出道歉、解释、DOCTYPE、html、head、body、style 或 script 标签。\n\n"
+                f"原始需求:\n{message}"
+            ),
+        },
+    ]
+
+    raw_response = await deepseek_service.chat(messages)
+    raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
+    answer_text = _clean_ai_writing_response(raw_answer or raw_response)
+    if raw_thinking:
+        thinking_summary = await summarize_thinking_content(
+            user_question=message,
+            raw_thinking=raw_thinking,
+            final_answer=answer_text,
+            chat_service=deepseek_service,
+            context="document_writing",
+        )
+        return (
+            f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
+            if thinking_summary
+            else answer_text
+        )
+    return answer_text
+
+
+async def _generate_ppt_outline_response(message: str) -> str:
+    training_plan = await _infer_safety_training_plan(message)
+    generation_message = _build_safety_training_generation_message(message, training_plan)
+    rag_context = await _rag_search(generation_message, top_k=10)
+    system_content = load_prompt(
+        "ppt_outline",
+        userMessage=generation_message,
+        contextJSON=rag_context if rag_context else "暂无相关知识库内容",
+    )
+
+    messages = [
+        {"role": "system", "content": system_content},
+        {"role": "user", "content": "请直接输出安全培训PPT大纲正文,从标题开始,不要解释提示词或安全规则。"},
+    ]
+
+    raw_response = await qwen_service.chat(messages)
+    raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
+    answer_text = raw_answer or raw_response
+    if raw_thinking:
+        thinking_summary = await summarize_thinking_content(
+            user_question=message,
+            raw_thinking=raw_thinking,
+            final_answer=answer_text,
+            chat_service=qwen_service,
+            context="ppt_outline",
+        )
+        return (
+            f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
+            if thinking_summary
+            else answer_text
+        )
+    return answer_text
+
+
+def _persist_message_pair(db: Session, conv_id: int, user, user_content: str, ai_content: str):
+    now_ts = int(time.time())
+    user_message = AIMessage(
+        ai_conversation_id=conv_id,
+        user_id=user.user_id,
+        type="user",
+        content=user_content,
+        created_at=now_ts,
+        updated_at=now_ts,
+        is_deleted=0,
+    )
+    db.add(user_message)
+    db.commit()
+    db.refresh(user_message)
+
+    ai_message = AIMessage(
+        ai_conversation_id=conv_id,
+        user_id=user.user_id,
+        type="ai",
+        content=ai_content,
+        prev_user_id=user_message.id,
+        created_at=now_ts,
+        updated_at=now_ts,
+        is_deleted=0,
+    )
+    db.add(ai_message)
+    db.commit()
+    db.refresh(ai_message)
+    return user_message, ai_message
+
+
 def _build_history_messages(conv_id: int, limit: int = 10) -> list:
 def _build_history_messages(conv_id: int, limit: int = 10) -> list:
     """从数据库读取最近对话历史,构建 messages 列表"""
     """从数据库读取最近对话历史,构建 messages 列表"""
     db = SessionLocal()
     db = SessionLocal()
@@ -460,6 +647,7 @@ async def send_deepseek_message(
             db.commit()
             db.commit()
 
 
         response_text = ""
         response_text = ""
+        ai_message_id = 0
 
 
         if data.business_type == 0:
         if data.business_type == 0:
             # AI问答:意图识别 + RAG
             # AI问答:意图识别 + RAG
@@ -488,8 +676,7 @@ async def send_deepseek_message(
                 ]
                 ]
 
 
                 qwen_response = await qwen_service.chat(messages)
                 qwen_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(
-                    qwen_response)
+                raw_thinking, raw_answer = split_thinking_and_answer(qwen_response)
                 answer_source = raw_answer or qwen_response
                 answer_source = raw_answer or qwen_response
 
 
                 # 兼容模型直接返回 JSON 的场景
                 # 兼容模型直接返回 JSON 的场景
@@ -519,94 +706,76 @@ async def send_deepseek_message(
                 else:
                 else:
                     response_text = answer_text
                     response_text = answer_text
             except Exception as e:
             except Exception as e:
-                error_detail = str(e).strip() if str(
-                    e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(
-                    f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
                 response_text = f"处理失败: {error_detail}"
 
 
         elif data.business_type == 1:
         elif data.business_type == 1:
             # PPT大纲生成
             # PPT大纲生成
             try:
             try:
-                rag_context = await _rag_search(message, top_k=10)
-
-                # 使用prompt加载器加载PPT大纲生成prompt
-                system_content = load_prompt(
-                    "ppt_outline",
-                    userMessage=message,
-                    contextJSON=rag_context if rag_context else "暂无相关知识库内容"
+                response_text = await _generate_ppt_outline_response(message)
+                _, ai_message = _persist_message_pair(
+                    db=db,
+                    conv_id=conv_id,
+                    user=user,
+                    user_content=message,
+                    ai_content=response_text,
                 )
                 )
-
-                messages = [
-                    {"role": "user", "content": system_content},
-                ]
-
-                raw_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(
-                    raw_response)
-                answer_text = raw_answer or raw_response
-                if raw_thinking:
-                    thinking_summary = await summarize_thinking_content(
-                        user_question=message,
-                        raw_thinking=raw_thinking,
-                        final_answer=answer_text,
-                        chat_service=qwen_service,
-                        context="ppt_outline",
-                    )
-                    response_text = (
-                        f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
-                        if thinking_summary
-                        else answer_text
-                    )
-                else:
-                    response_text = answer_text
+                ai_message_id = ai_message.id
+                _refresh_conversation_snapshot(db, conv_id, user.user_id)
+                db.commit()
+                return {
+                    "statusCode": 200,
+                    "msg": "success",
+                    "data": {
+                        "conversation_id": conv_id,
+                        "ai_conversation_id": conv_id,
+                        "response": response_text,
+                        "reply": response_text,
+                        "content": response_text,
+                        "message": response_text,
+                        "ai_message_id": ai_message_id,
+                        "user_id": user.user_id,
+                        "business_type": data.business_type,
+                    },
+                }
             except Exception as e:
             except Exception as e:
-                error_detail = str(e).strip() if str(
-                    e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(
-                    f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
                 response_text = f"处理失败: {error_detail}"
 
 
         elif data.business_type == 2:
         elif data.business_type == 2:
             # AI写作
             # AI写作
             try:
             try:
-                rag_context = await _rag_search(message, top_k=10)
-
-                # 使用prompt加载器加载公文写作prompt
-                system_content = load_prompt(
-                    "document_writing",
-                    userMessage=message,
-                    contextJSON=rag_context if rag_context else "暂无相关知识库内容"
+                response_text = await _generate_ai_writing_response(message)
+                _, ai_message = _persist_message_pair(
+                    db=db,
+                    conv_id=conv_id,
+                    user=user,
+                    user_content=message,
+                    ai_content=response_text,
                 )
                 )
-
-                messages = [
-                    {"role": "user", "content": system_content},
-                ]
-
-                raw_response = await qwen_service.chat(messages)
-                raw_thinking, raw_answer = split_thinking_and_answer(
-                    raw_response)
-                answer_text = raw_answer or raw_response
-                if raw_thinking:
-                    thinking_summary = await summarize_thinking_content(
-                        user_question=message,
-                        raw_thinking=raw_thinking,
-                        final_answer=answer_text,
-                        chat_service=qwen_service,
-                        context="document_writing",
-                    )
-                    response_text = (
-                        f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
-                        if thinking_summary
-                        else answer_text
-                    )
-                else:
-                    response_text = answer_text
+                ai_message_id = ai_message.id
+                _refresh_conversation_snapshot(db, conv_id, user.user_id)
+                db.commit()
+                return {
+                    "statusCode": 200,
+                    "msg": "success",
+                    "data": {
+                        "conversation_id": conv_id,
+                        "ai_conversation_id": conv_id,
+                        "response": response_text,
+                        "reply": response_text,
+                        "content": response_text,
+                        "message": response_text,
+                        "ai_message_id": ai_message_id,
+                        "user_id": user.user_id,
+                        "business_type": data.business_type,
+                    },
+                }
             except Exception as e:
             except Exception as e:
-                error_detail = str(e).strip() if str(
-                    e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(
-                    f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
                 response_text = f"处理失败: {error_detail}"
 
 
         elif data.business_type == 3:
         elif data.business_type == 3:
@@ -631,8 +800,7 @@ async def send_deepseek_message(
                     {"role": "user", "content": message},
                     {"role": "user", "content": message},
                 ]
                 ]
 
 
-                raw_response = await qwen_service.chat(messages)
-                response_text = _extract_exam_json_content(raw_response)
+                response_text = await qwen_service.chat(messages)
 
 
                 now_ts = int(time.time())
                 now_ts = int(time.time())
                 user_message = AIMessage(
                 user_message = AIMessage(
@@ -671,10 +839,8 @@ async def send_deepseek_message(
                     )
                     )
                     db.commit()
                     db.commit()
             except Exception as e:
             except Exception as e:
-                error_detail = str(e).strip() if str(
-                    e).strip() else f"未知错误({type(e).__name__})"
-                logger.error(
-                    f"[send_deepseek_message] 考试工坊异常: {type(e).__name__}: {error_detail}")
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] 考试工坊异常: {type(e).__name__}: {error_detail}")
                 response_text = f"处理失败: {error_detail}"
                 response_text = f"处理失败: {error_detail}"
 
 
         else:
         else:
@@ -918,8 +1084,7 @@ async def stream_chat(request: Request, data: StreamChatRequest):
             thinking_buf = ""
             thinking_buf = ""
             in_think = False
             in_think = False
             thinking_done = False
             thinking_done = False
-            max_input_chars = getattr(
-                settings.thinking_summary, "max_input_chars", 1500)
+            max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
 
 
             async for chunk in qwen_service.stream_chat(messages):
             async for chunk in qwen_service.stream_chat(messages):
                 buffer += chunk
                 buffer += chunk
@@ -942,15 +1107,13 @@ async def stream_chat(request: Request, data: StreamChatRequest):
                         end_idx = lower.find("</think>")
                         end_idx = lower.find("</think>")
                         if end_idx == -1:
                         if end_idx == -1:
                             if max_input_chars and len(thinking_buf) < max_input_chars:
                             if max_input_chars and len(thinking_buf) < max_input_chars:
-                                thinking_buf += buffer[: max_input_chars -
-                                                       len(thinking_buf)]
+                                thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
                             buffer = ""
                             buffer = ""
                             break
                             break
 
 
                         if max_input_chars and len(thinking_buf) < max_input_chars:
                         if max_input_chars and len(thinking_buf) < max_input_chars:
                             thinking_part = buffer[:end_idx]
                             thinking_part = buffer[:end_idx]
-                            thinking_buf += thinking_part[: max_input_chars - len(
-                                thinking_buf)]
+                            thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
 
 
                         buffer = buffer[end_idx + len("</think>"):]
                         buffer = buffer[end_idx + len("</think>"):]
                         in_think = False
                         in_think = False
@@ -1166,8 +1329,7 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 if rag_context:
                 if rag_context:
                     context_parts.append(f"??????\n{rag_context}")
                     context_parts.append(f"??????\n{rag_context}")
                 if data.online_search_content:
                 if data.online_search_content:
-                    context_parts.append(
-                        f"???????\n{data.online_search_content}")
+                    context_parts.append(f"???????\n{data.online_search_content}")
 
 
                 context_json = "\n\n".join(
                 context_json = "\n\n".join(
                     context_parts) if context_parts else "?????????"
                     context_parts) if context_parts else "?????????"
@@ -1186,10 +1348,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 8. 流式输出并收集完整回复
             # 8. 流式输出并收集完整回复
             full_response = ""
             full_response = ""
             try:
             try:
-                summary_enabled = getattr(
-                    settings.thinking_summary, "enabled", True)
-                max_input_chars = getattr(
-                    settings.thinking_summary, "max_input_chars", 1500)
+                summary_enabled = getattr(settings.thinking_summary, "enabled", True)
+                max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
 
 
                 buffer = ""
                 buffer = ""
                 pre_answer = ""
                 pre_answer = ""
@@ -1218,24 +1378,22 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                                     break
                                     break
 
 
                                 pre_answer += buffer[:start_idx]
                                 pre_answer += buffer[:start_idx]
-                                buffer = buffer[start_idx + len("<think>"):]
+                                buffer = buffer[start_idx + len("<think>") :]
                                 in_think = True
                                 in_think = True
                                 continue
                                 continue
 
 
                             end_idx = lower.find("</think>")
                             end_idx = lower.find("</think>")
                             if end_idx == -1:
                             if end_idx == -1:
                                 if max_input_chars and len(thinking_buf) < max_input_chars:
                                 if max_input_chars and len(thinking_buf) < max_input_chars:
-                                    thinking_buf += buffer[: max_input_chars -
-                                                           len(thinking_buf)]
+                                    thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
                                 buffer = ""
                                 buffer = ""
                                 break
                                 break
 
 
                             if max_input_chars and len(thinking_buf) < max_input_chars:
                             if max_input_chars and len(thinking_buf) < max_input_chars:
                                 thinking_part = buffer[:end_idx]
                                 thinking_part = buffer[:end_idx]
-                                thinking_buf += thinking_part[: max_input_chars - len(
-                                    thinking_buf)]
+                                thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
 
 
-                            buffer = buffer[end_idx + len("</think>"):]
+                            buffer = buffer[end_idx + len("</think>") :]
                             in_think = False
                             in_think = False
                             thinking_done = True
                             thinking_done = True
 
 
@@ -1255,8 +1413,7 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                             answer_chunk = (pre_answer + buffer).lstrip()
                             answer_chunk = (pre_answer + buffer).lstrip()
                             if answer_chunk:
                             if answer_chunk:
                                 full_response += answer_chunk
                                 full_response += answer_chunk
-                                escaped_answer = answer_chunk.replace(
-                                    '\n', '\\n')
+                                escaped_answer = answer_chunk.replace('\n', '\\n')
                                 yield f"data: {escaped_answer}\n\n"
                                 yield f"data: {escaped_answer}\n\n"
 
 
                             pre_answer = ""
                             pre_answer = ""
@@ -1380,8 +1537,7 @@ async def guess_you_want(
             if not questions:
             if not questions:
                 questions = ["该话题的具体应用场景?", "有哪些注意事项?", "相关案例分析?"]
                 questions = ["该话题的具体应用场景?", "有哪些注意事项?", "相关案例分析?"]
 
 
-        questions = _finalize_related_questions(
-            questions, ai_msg.content, limit=3)
+        questions = _finalize_related_questions(questions, ai_msg.content, limit=3)
 
 
         guess_json = json.dumps({"questions": questions}, ensure_ascii=False)
         guess_json = json.dumps({"questions": questions}, ensure_ascii=False)
 
 

+ 94 - 5
shudao-chat-py/routers/hazard.py

@@ -375,7 +375,22 @@ async def hazard(
                 e,
                 e,
                 traceback.format_exc(),
                 traceback.format_exc(),
             )
             )
-            return {"statusCode": 500, "msg": f"下载图片失败: {str(e)}"}
+            error_msg = "图片下载失败"
+            if isinstance(e, httpx.TimeoutException):
+                error_msg = "图片下载超时,请检查网络连接或稍后重试"
+            elif isinstance(e, httpx.ConnectError):
+                error_msg = "无法连接到图片服务器,请检查图片链接是否正确"
+            elif hasattr(e, 'response') and e.response is not None:
+                status_code = e.response.status_code
+                if status_code == 404:
+                    error_msg = "图片不存在,请检查图片链接是否正确"
+                elif status_code == 403:
+                    error_msg = "无权访问该图片,请检查图片链接权限"
+                elif status_code >= 500:
+                    error_msg = "图片服务器暂时不可用,请稍后重试"
+                else:
+                    error_msg = f"图片下载失败(错误代码:{status_code})"
+            return {"statusCode": 500, "msg": error_msg}
 
 
         logger.info(
         logger.info(
             "[hazard][%s] image downloaded: bytes=%s",
             "[hazard][%s] image downloaded: bytes=%s",
@@ -396,6 +411,51 @@ async def hazard(
                 (yolo_result or {}).get("model_type", "") if isinstance(yolo_result, dict) else "",
                 (yolo_result or {}).get("model_type", "") if isinstance(yolo_result, dict) else "",
             )
             )
             logger.debug("[hazard][%s] yolo result raw: %s", request_id, json.dumps(yolo_result, ensure_ascii=False, default=str))
             logger.debug("[hazard][%s] yolo result raw: %s", request_id, json.dumps(yolo_result, ensure_ascii=False, default=str))
+        except httpx.TimeoutException as e:
+            logger.error(
+                "[hazard][%s] YOLO检测超时: scene_key=%r, error=%s, traceback=%s",
+                request_id,
+                scene_key,
+                e,
+                traceback.format_exc(),
+            )
+            return {"statusCode": 500, "msg": "AI识别服务响应超时,请稍后重试"}
+        except httpx.ConnectError as e:
+            logger.error(
+                "[hazard][%s] YOLO服务连接失败: scene_key=%r, error=%s, traceback=%s",
+                request_id,
+                scene_key,
+                e,
+                traceback.format_exc(),
+            )
+            return {"statusCode": 500, "msg": "无法连接到AI识别服务,请联系管理员"}
+        except httpx.HTTPStatusError as e:
+            logger.error(
+                "[hazard][%s] YOLO服务返回错误状态: scene_key=%r, status_code=%s, error=%s, traceback=%s",
+                request_id,
+                scene_key,
+                e.response.status_code if hasattr(e, 'response') else 'unknown',
+                e,
+                traceback.format_exc(),
+            )
+            status_code = e.response.status_code if hasattr(e, 'response') else 500
+            if status_code == 400:
+                return {"statusCode": 500, "msg": "图片格式不支持或图片已损坏,请更换图片"}
+            elif status_code == 404:
+                return {"statusCode": 500, "msg": f"未找到场景'{SCENE_DISPLAY_NAMES.get(scene_key, scene_key)}'的识别模型"}
+            elif status_code >= 500:
+                return {"statusCode": 500, "msg": "AI识别服务暂时不可用,请稍后重试"}
+            else:
+                return {"statusCode": 500, "msg": f"AI识别服务返回错误(代码:{status_code})"}
+        except ValueError as e:
+            logger.error(
+                "[hazard][%s] YOLO返回数据格式错误: scene_key=%r, error=%s, traceback=%s",
+                request_id,
+                scene_key,
+                e,
+                traceback.format_exc(),
+            )
+            return {"statusCode": 500, "msg": "AI识别服务返回数据格式错误,请联系管理员"}
         except Exception as e:
         except Exception as e:
             logger.error(
             logger.error(
                 "[hazard][%s] YOLO检测失败: scene_key=%r, error=%s, traceback=%s",
                 "[hazard][%s] YOLO检测失败: scene_key=%r, error=%s, traceback=%s",
@@ -404,7 +464,13 @@ async def hazard(
                 e,
                 e,
                 traceback.format_exc(),
                 traceback.format_exc(),
             )
             )
-            return {"statusCode": 500, "msg": f"YOLO API返回错误: {str(e)}"}
+            error_msg = str(e)
+            if "timeout" in error_msg.lower():
+                return {"statusCode": 500, "msg": "AI识别服务响应超时,请稍后重试"}
+            elif "connection" in error_msg.lower():
+                return {"statusCode": 500, "msg": "无法连接到AI识别服务,请联系管理员"}
+            else:
+                return {"statusCode": 500, "msg": f"AI识别失败:{error_msg}"}
 
 
         labels = yolo_result.get("labels", []) or []
         labels = yolo_result.get("labels", []) or []
         boxes = yolo_result.get("boxes", []) or []
         boxes = yolo_result.get("boxes", []) or []
@@ -421,7 +487,7 @@ async def hazard(
             )
             )
             return {
             return {
                 "statusCode": 200,
                 "statusCode": 200,
-                "msg": "识别成功",
+                "msg": "未检测到隐患",
                 "data": {
                 "data": {
                     "scene_name": scene_key,
                     "scene_name": scene_key,
                     "scene_display_name": SCENE_DISPLAY_NAMES.get(scene_key, scene_key),
                     "scene_display_name": SCENE_DISPLAY_NAMES.get(scene_key, scene_key),
@@ -436,6 +502,7 @@ async def hazard(
                     "display_labels": [],
                     "display_labels": [],
                     "third_scenes": [],
                     "third_scenes": [],
                     "element_hazards": {},
                     "element_hazards": {},
+                    "no_hazards_detected": True,
                 },
                 },
             }
             }
 
 
@@ -594,7 +661,18 @@ async def hazard(
             )
             )
             db.rollback()
             db.rollback()
             logger.info("[hazard][%s] db rollback executed after record save failure", request_id)
             logger.info("[hazard][%s] db rollback executed after record save failure", request_id)
-            return {"statusCode": 500, "msg": f"识别失败: {str(e)}"}
+            
+            error_msg = str(e)
+            if "connection" in error_msg.lower() or "lost connection" in error_msg.lower():
+                return {"statusCode": 500, "msg": "数据库连接失败,请稍后重试"}
+            elif "duplicate" in error_msg.lower() or "unique" in error_msg.lower():
+                return {"statusCode": 500, "msg": "记录已存在,请勿重复提交"}
+            elif "timeout" in error_msg.lower():
+                return {"statusCode": 500, "msg": "数据库操作超时,请稍后重试"}
+            elif "disk" in error_msg.lower() or "space" in error_msg.lower():
+                return {"statusCode": 500, "msg": "存储空间不足,请联系管理员"}
+            else:
+                return {"statusCode": 500, "msg": f"保存识别记录失败:{error_msg}"}
 
 
         display_labels = _dedupe_list([item["label"] for item in detection_results])
         display_labels = _dedupe_list([item["label"] for item in detection_results])
 
 
@@ -638,7 +716,18 @@ async def hazard(
         )
         )
         db.rollback()
         db.rollback()
         logger.info("[hazard][%s] db rollback executed in outer exception handler", request_id)
         logger.info("[hazard][%s] db rollback executed in outer exception handler", request_id)
-        return {"statusCode": 500, "msg": f"处理失败: {str(e)}"}
+        
+        error_msg = str(e)
+        if "connection" in error_msg.lower():
+            return {"statusCode": 500, "msg": "服务连接失败,请稍后重试"}
+        elif "timeout" in error_msg.lower():
+            return {"statusCode": 500, "msg": "服务响应超时,请稍后重试"}
+        elif "memory" in error_msg.lower() or "out of memory" in error_msg.lower():
+            return {"statusCode": 500, "msg": "系统资源不足,请稍后重试或联系管理员"}
+        elif "permission" in error_msg.lower() or "denied" in error_msg.lower():
+            return {"statusCode": 500, "msg": "权限不足,请联系管理员"}
+        else:
+            return {"statusCode": 500, "msg": f"识别处理失败:{error_msg}"}
 
 
 
 
 @router.post("/save_step")
 @router.post("/save_step")

+ 2 - 9
shudao-chat-py/routers/points.py

@@ -5,7 +5,6 @@ from models.user_data import UserData
 from models.total import User
 from models.total import User
 from models.points import PointsConsumptionLog
 from models.points import PointsConsumptionLog
 from utils.logger import logger
 from utils.logger import logger
-import time
 
 
 router = APIRouter()
 router = APIRouter()
 
 
@@ -92,15 +91,12 @@ async def consume_points(request: Request):
             new_balance = current_points - REQUIRED_POINTS
             new_balance = current_points - REQUIRED_POINTS
             user.points = new_balance
             user.points = new_balance
 
 
-            now = int(time.time())
             log = PointsConsumptionLog(
             log = PointsConsumptionLog(
                 user_id=str(user.id),
                 user_id=str(user.id),
                 file_name=file_name,
                 file_name=file_name,
                 file_url=file_url,
                 file_url=file_url,
                 points_consumed=REQUIRED_POINTS,
                 points_consumed=REQUIRED_POINTS,
-                balance_after=new_balance,
-                created_at=now,
-                updated_at=now,
+                balance_after=new_balance
             )
             )
             db.add(log)
             db.add(log)
             db.commit()
             db.commit()
@@ -136,15 +132,12 @@ async def consume_points(request: Request):
         new_balance = current_points - REQUIRED_POINTS
         new_balance = current_points - REQUIRED_POINTS
         user_data.points = new_balance
         user_data.points = new_balance
 
 
-        now = int(time.time())
         log = PointsConsumptionLog(
         log = PointsConsumptionLog(
             user_id=user_info.account,
             user_id=user_info.account,
             file_name=file_name,
             file_name=file_name,
             file_url=file_url,
             file_url=file_url,
             points_consumed=REQUIRED_POINTS,
             points_consumed=REQUIRED_POINTS,
-            balance_after=new_balance,
-            created_at=now,
-            updated_at=now,
+            balance_after=new_balance
         )
         )
         db.add(log)
         db.add(log)
         db.commit()
         db.commit()

+ 33 - 2
shudao-chat-py/routers/report_compat.py

@@ -2,7 +2,7 @@
 报告兼容路由
 报告兼容路由
 完全对齐 Go 版本的接口实现,保持外部一致性
 完全对齐 Go 版本的接口实现,保持外部一致性
 """
 """
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, File, Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from fastapi.responses import StreamingResponse, JSONResponse
 import httpx
 import httpx
 import json
 import json
@@ -72,6 +72,24 @@ def _build_aichat_complete_flow_body(
     return json.dumps(payload, ensure_ascii=False).encode("utf-8")
     return json.dumps(payload, ensure_ascii=False).encode("utf-8")
 
 
 
 
+def _augment_message_with_uploaded_documents(message: str, uploaded_documents) -> str:
+    parts = [message]
+    for item in uploaded_documents or []:
+        content = (item.content or "").strip()
+        if not item.file_name or not content:
+            continue
+        parts.append(
+            "\n".join([
+                "【用户上传文档】",
+                f"文件名:{item.file_name}",
+                f"文件类型:{item.file_type or '未知'}",
+                "文档内容:",
+                content,
+            ])
+        )
+    return "\n\n".join(parts)
+
+
 async def fallback_to_local_stream(
 async def fallback_to_local_stream(
     request_data: ReportCompleteFlowRequest,
     request_data: ReportCompleteFlowRequest,
     request: Request
     request: Request
@@ -80,7 +98,10 @@ async def fallback_to_local_stream(
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
 
 
     stream_request = StreamChatRequest(
     stream_request = StreamChatRequest(
-        message=request_data.user_question,
+        message=_augment_message_with_uploaded_documents(
+            request_data.user_question,
+            request_data.uploaded_documents,
+        ),
         ai_conversation_id=request_data.ai_conversation_id,
         ai_conversation_id=request_data.ai_conversation_id,
         business_type=0
         business_type=0
     )
     )
@@ -212,6 +233,16 @@ async def complete_flow(request: Request):
     return await fallback_to_local_stream(request_data, request)
     return await fallback_to_local_stream(request_data, request)
 
 
 
 
+@router.post("/attachments/parse")
+async def parse_attachment(request: Request, file: UploadFile = File(...)):
+    """Parse an uploaded attachment through aichat."""
+    user = getattr(request.state, "user", None)
+    if not user:
+        return JSONResponse(content={"statusCode": 401, "msg": "未授权"}, status_code=401)
+
+    return await aichat_proxy.proxy_upload("/attachments/parse", request, file)
+
+
 @router.post("/report/update-ai-message")
 @router.post("/report/update-ai-message")
 async def update_ai_message(request: Request):
 async def update_ai_message(request: Request):
     """
     """

+ 14 - 8
shudao-chat-py/routers/scene.py

@@ -536,9 +536,12 @@ async def delete_recognition_record(
         return {"statusCode": 401, "msg": "未授权"}
         return {"statusCode": 401, "msg": "未授权"}
 
 
     record_id = _resolve_record_id(data.recognition_id, data.recognition_record_id)
     record_id = _resolve_record_id(data.recognition_id, data.recognition_record_id)
+    user_id = getattr(user, "user_id", None)
+    user_code = _get_user_code(user)
     logger.info(
     logger.info(
-        "delete_recognition_record start: user_code=%s record_id=%s",
-        _get_user_code(user),
+        "delete_recognition_record start: user_id=%s user_code=%s record_id=%s",
+        user_id,
+        user_code,
         record_id,
         record_id,
     )
     )
     if not record_id:
     if not record_id:
@@ -549,14 +552,15 @@ async def delete_recognition_record(
         db.query(RecognitionRecord)
         db.query(RecognitionRecord)
         .filter(
         .filter(
             RecognitionRecord.id == record_id,
             RecognitionRecord.id == record_id,
-            RecognitionRecord.user_id == _get_user_code(user),
+            RecognitionRecord.user_id == user_id,
         )
         )
         .update({"is_deleted": 1, "deleted_at": int(time.time())})
         .update({"is_deleted": 1, "deleted_at": int(time.time())})
     )
     )
     db.commit()
     db.commit()
     logger.info(
     logger.info(
-        "delete_recognition_record success: user_code=%s record_id=%s affected_rows=%s",
-        _get_user_code(user),
+        "delete_recognition_record success: user_id=%s user_code=%s record_id=%s affected_rows=%s",
+        user_id,
+        user_code,
         record_id,
         record_id,
         affected_rows,
         affected_rows,
     )
     )
@@ -617,12 +621,13 @@ async def get_latest_recognition_record(
         logger.warning("get_latest_recognition_record unauthorized: missing request.state.user")
         logger.warning("get_latest_recognition_record unauthorized: missing request.state.user")
         return {"statusCode": 401, "msg": "未授权"}
         return {"statusCode": 401, "msg": "未授权"}
 
 
+    user_id = getattr(user, "user_id", None)
     user_code = _get_user_code(user)
     user_code = _get_user_code(user)
-    logger.info("get_latest_recognition_record start: user_code=%s", user_code)
+    logger.info("get_latest_recognition_record start: user_id=%s user_code=%s", user_id, user_code)
     record = (
     record = (
         db.query(RecognitionRecord)
         db.query(RecognitionRecord)
         .filter(
         .filter(
-            RecognitionRecord.user_id == _get_user_code(user),
+            RecognitionRecord.user_id == user_id,
             RecognitionRecord.is_deleted == 0,
             RecognitionRecord.is_deleted == 0,
         )
         )
         .order_by(RecognitionRecord.created_at.desc())
         .order_by(RecognitionRecord.created_at.desc())
@@ -737,8 +742,9 @@ async def get_recognition_records(
     if page_size > 100:
     if page_size > 100:
         page_size = 100
         page_size = 100
 
 
+    user_id = getattr(user, "user_id", None)
     query = db.query(RecognitionRecord).filter(
     query = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == _get_user_code(user),
+        RecognitionRecord.user_id == user_id,
         RecognitionRecord.is_deleted == 0,
         RecognitionRecord.is_deleted == 0,
     )
     )
 
 

+ 129 - 37
shudao-chat-py/routers/total.py

@@ -4,11 +4,12 @@ from sqlalchemy.orm import Session
 from pydantic import BaseModel
 from pydantic import BaseModel
 from typing import Optional
 from typing import Optional
 from database import get_db
 from database import get_db
-from models.total import RecommendQuestion, PolicyFile, FunctionCard, HotQuestion, FeedbackQuestion
+from models.total import RecommendQuestion, PolicyFile, FunctionCard, HotQuestion, FeedbackQuestion, User
 from models.chat import AIMessage
 from models.chat import AIMessage
 from models.user_data import UserData
 from models.user_data import UserData
 from services.oss_service import oss_service
 from services.oss_service import oss_service
 from utils.crypto import decrypt_url
 from utils.crypto import decrypt_url
+from utils.config import get_proxy_url
 import time
 import time
 import httpx
 import httpx
 
 
@@ -28,41 +29,48 @@ async def get_recommend_question(db: Session = Depends(get_db)):
 
 
 @router.get("/get_policy_file")
 @router.get("/get_policy_file")
 async def get_policy_file(
 async def get_policy_file(
-    policy_type: Optional[int] = None,
+    policy_type: Optional[str] = None,
     page: int = 1,
     page: int = 1,
-    page_size: int = 20,
+    pageSize: int = 20,
+    search: str = "",
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     """获取策略文件列表"""
     """获取策略文件列表"""
     query = db.query(PolicyFile).filter(PolicyFile.is_deleted == 0)
     query = db.query(PolicyFile).filter(PolicyFile.is_deleted == 0)
 
 
-    if policy_type is not None and policy_type != 0:
-        query = query.filter(PolicyFile.policy_type == policy_type)
-
-    total = query.count()
-
-    offset = (page - 1) * page_size
+    # 只有当policy_type有效且不为0或空字符串时才添加类型过滤
+    if policy_type and policy_type != "" and policy_type != "0":
+        try:
+            policy_type_int = int(policy_type)
+            query = query.filter(PolicyFile.policy_type == policy_type_int)
+        except ValueError:
+            pass  # 忽略无效的类型值
+    
+    if search:
+        query = query.filter(PolicyFile.policy_name.like(f"%{search}%"))
+
+    offset = (page - 1) * pageSize
     files = query.order_by(PolicyFile.updated_at.desc()).offset(
     files = query.order_by(PolicyFile.updated_at.desc()).offset(
-        offset).limit(page_size).all()
+        offset).limit(pageSize).all()
 
 
     return {
     return {
         "statusCode": 200,
         "statusCode": 200,
         "msg": "success",
         "msg": "success",
-        "data": {
-            "total": total,
-            "items": [
-                {
-                    "id": f.id,
-                    "policy_name": f.policy_name,
-                    "policy_file_url": f.policy_file_url,
-                    "policy_type": f.policy_type,
-                    "file_type": f.file_type,
-                    "view_count": f.view_count,
-                    "created_at": f.created_at
-                }
-                for f in files
-            ]
-        }
+        "data": [
+            {
+                "id": f.id,
+                "policy_name": f.policy_name,
+                "policy_file_url": get_proxy_url(f.policy_file_url),
+                "policy_type": f.policy_type,
+                "file_type": f.file_type,
+                "file_tag": getattr(f, 'file_tag', ''),
+                "publish_time": getattr(f, 'publish_time', f.created_at),
+                "view_count": f.view_count,
+                "created_at": f.created_at,
+                "updated_at": f.updated_at
+            }
+            for f in files
+        ]
     }
     }
 
 
 
 
@@ -162,25 +170,109 @@ async def submit_feedback(request: SubmitFeedbackRequest, req: Request, db: Sess
 
 
 
 
 class LikeDislikeRequest(BaseModel):
 class LikeDislikeRequest(BaseModel):
-    ai_message_id: int
-    action: str  # "like" 或 "dislike"
+    ai_message_id: Optional[int] = None
+    action: Optional[str] = None
+    id: Optional[int] = None
+    user_feedback: Optional[int] = None
+
+
+FEEDBACK_NONE = 0
+FEEDBACK_LIKE = 2
+FEEDBACK_DISLIKE = 3
+FEEDBACK_REWARD_POINTS = {
+    FEEDBACK_NONE: 0,
+    FEEDBACK_LIKE: 2,
+    FEEDBACK_DISLIKE: 1,
+}
+
+
+def _resolve_like_dislike_payload(data: LikeDislikeRequest):
+    message_id = data.ai_message_id or data.id
+    if not message_id:
+        return None, None, "缺少消息ID"
+
+    if data.user_feedback is not None:
+        feedback = int(data.user_feedback)
+    else:
+        action = (data.action or "").strip().lower()
+        if action in ("like", "2"):
+            feedback = FEEDBACK_LIKE
+        elif action in ("dislike", "3"):
+            feedback = FEEDBACK_DISLIKE
+        elif action in ("", "none", "cancel", "0"):
+            feedback = FEEDBACK_NONE
+        else:
+            return None, None, "反馈类型错误"
+
+    if feedback not in (FEEDBACK_NONE, FEEDBACK_LIKE, FEEDBACK_DISLIKE):
+        return None, None, "反馈类型错误"
+
+    return message_id, feedback, None
+
+
+def _find_current_points_holder(db: Session, user_info):
+    user = db.query(User).filter(
+        User.id == user_info.user_id,
+        User.is_deleted == 0,
+    ).first()
+    if user:
+        return user
+
+    return db.query(UserData).filter(
+        UserData.accountID == user_info.account,
+    ).first()
 
 
 
 
 @router.post("/like_and_dislike")
 @router.post("/like_and_dislike")
-async def like_and_dislike(request: LikeDislikeRequest, db: Session = Depends(get_db)):
-    """点赞/踩(对齐Go版本)"""
-    message = db.query(AIMessage).filter(
-        AIMessage.id == request.ai_message_id).first()
+async def like_and_dislike(data: LikeDislikeRequest, request: Request, db: Session = Depends(get_db)):
+    """Save AI message feedback and reward points to the current user."""
+    user_info = request.state.user
+    if not user_info:
+        return {"statusCode": 401, "msg": "未认证"}
+
+    message_id, feedback, error = _resolve_like_dislike_payload(data)
+    if error:
+        return {"statusCode": 400, "msg": error}
+
+    message = db.query(AIMessage).filter(AIMessage.id == message_id).first()
     if not message:
     if not message:
         return {"statusCode": 404, "msg": "消息不存在"}
         return {"statusCode": 404, "msg": "消息不存在"}
 
 
-    # 将action转换为user_feedback:like=2(满意/赞), dislike=3(不满意/踩)
-    user_feedback = 2 if request.action == "like" else 3
-    message.user_feedback = user_feedback
-    message.updated_at = int(time.time())
-    db.commit()
+    if getattr(message, "user_id", user_info.user_id) != user_info.user_id:
+        return {"statusCode": 403, "msg": "无权评价该消息"}
 
 
-    return {"statusCode": 200, "msg": "success"}
+    points_holder = _find_current_points_holder(db, user_info)
+    if not points_holder:
+        return {"statusCode": 404, "msg": "未找到用户数据"}
+
+    previous_feedback = int(message.user_feedback or 0)
+    previous_points = FEEDBACK_REWARD_POINTS.get(previous_feedback, 0)
+    current_points = FEEDBACK_REWARD_POINTS.get(feedback, 0)
+    points_delta = current_points - previous_points
+
+    try:
+        message.user_feedback = feedback
+        message.updated_at = int(time.time())
+
+        new_balance = (points_holder.points or 0) + points_delta
+        points_holder.points = new_balance
+
+        db.commit()
+    except Exception as e:
+        db.rollback()
+        return {"statusCode": 500, "msg": f"反馈提交失败: {str(e)}"}
+
+    return {
+        "statusCode": 200,
+        "msg": "success",
+        "data": {
+            "ai_message_id": message_id,
+            "user_feedback": feedback,
+            "points_added": points_delta,
+            "points_delta": points_delta,
+            "new_balance": new_balance,
+        },
+    }
 
 
 
 
 @router.get("/get_user_data_id")
 @router.get("/get_user_data_id")

+ 47 - 1
shudao-chat-py/services/aichat_proxy.py

@@ -4,7 +4,7 @@ AIChat 代理服务
 """
 """
 import httpx
 import httpx
 from typing import AsyncGenerator, Optional
 from typing import AsyncGenerator, Optional
-from fastapi import Request
+from fastapi import Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from fastapi.responses import StreamingResponse, JSONResponse
 from utils.config import settings
 from utils.config import settings
 from utils.logger import logger
 from utils.logger import logger
@@ -144,6 +144,52 @@ class AIChatProxy:
                 status_code=500
                 status_code=500
             )
             )
 
 
+    async def proxy_upload(
+        self,
+        path: str,
+        request: Request,
+        upload_file: UploadFile,
+        field_name: str = "file",
+    ) -> JSONResponse:
+        """Proxy a multipart upload to aichat."""
+        url = f"{self.base_url}{path}"
+        headers = self._get_auth_headers(request)
+
+        logger.info(f"[AIChat代理] 文件上传请求: {url}, filename={upload_file.filename}")
+
+        try:
+            content = await upload_file.read()
+            files = {
+                field_name: (
+                    upload_file.filename or "upload",
+                    content,
+                    upload_file.content_type or "application/octet-stream",
+                )
+            }
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.post(url, files=files, headers=headers)
+
+            try:
+                response_content = response.json()
+            except Exception:
+                response_content = {"success": False, "message": response.text}
+            return JSONResponse(
+                content=response_content,
+                status_code=response.status_code
+            )
+        except httpx.TimeoutException:
+            logger.error("[AIChat代理] 文件上传请求超时")
+            return JSONResponse(
+                content={"statusCode": 504, "msg": "AIChat服务请求超时"},
+                status_code=504
+            )
+        except Exception as e:
+            logger.error(f"[AIChat代理] 文件上传请求异常: {e}")
+            return JSONResponse(
+                content={"statusCode": 500, "msg": f"AIChat服务异常: {str(e)}"},
+                status_code=500
+            )
+
     async def health_check(self) -> bool:
     async def health_check(self) -> bool:
         """
         """
         检查 aichat 服务健康状态
         检查 aichat 服务健康状态

+ 154 - 0
shudao-chat-py/tests/test_chat_ai_writing_route.py

@@ -0,0 +1,154 @@
+import importlib.util
+import sys
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock, patch
+
+
+CHAT_PATH = Path(__file__).resolve().parents[1] / "routers" / "chat.py"
+sys.path.insert(0, str(CHAT_PATH.parents[1]))
+spec = importlib.util.spec_from_file_location("chat_under_test", CHAT_PATH)
+chat = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(chat)
+
+
+class FakeDB:
+    def __init__(self):
+        self.added = []
+        self.commits = 0
+        self.refreshed = []
+
+    def add(self, obj):
+        self.added.append(obj)
+
+    def commit(self):
+        self.commits += 1
+
+    def refresh(self, obj):
+        if getattr(obj, "id", None) is None:
+            obj.id = len(self.refreshed) + 100
+        self.refreshed.append(obj)
+
+
+class ChatAIWritingRouteTest(unittest.IsolatedAsyncioTestCase):
+    async def test_safety_training_generation_enriches_request_then_uses_legacy_ppt_outline_chain(self):
+        planning_reply = """
+        {
+          "topic": "施工安全培训",
+          "template": "标准安全培训PPT大纲",
+          "content_focus": ["员工安全意识", "施工现场着装规范"],
+          "audience": "蜀道集团员工",
+          "time": "2025/4/10",
+          "location": "蜀道集团",
+          "goal": "提升培训员工的安全意识",
+          "notes": "着装随意",
+          "normalized_request": "围绕2025年第一季度施工安全培训生成安全培训PPT大纲"
+        }
+        """
+        with patch.object(chat, "_rag_search", AsyncMock(return_value="Safety RAG context")) as rag_search, \
+            patch.object(chat, "load_prompt", Mock(return_value="ppt outline prompt")) as load_prompt, \
+            patch.object(chat.qwen_service, "chat", AsyncMock(side_effect=[planning_reply, "PPT outline reply"])) as qwen_chat, \
+            patch.object(chat.deepseek_service, "chat", AsyncMock(return_value="DeepSeek reply")) as deepseek_chat:
+
+            reply = await chat._generate_ppt_outline_response(
+                "生成一份施工安全培训通知,2025年第一季度施工安全培训,在蜀道集团,2025/4/10进行培训,培训员工的安全意识,着装随意。"
+            )
+
+        self.assertEqual(reply, "PPT outline reply")
+        self.assertEqual(qwen_chat.await_count, 2)
+        planning_messages = qwen_chat.await_args_list[0].args[0]
+        self.assertEqual(planning_messages[0]["role"], "system")
+        self.assertIn("安全培训需求整理", planning_messages[0]["content"])
+
+        enriched_message = rag_search.await_args.args[0]
+        self.assertIn("输出类型:安全培训PPT大纲", enriched_message)
+        self.assertIn("主题:施工安全培训", enriched_message)
+        self.assertIn("模板:标准安全培训PPT大纲", enriched_message)
+        self.assertIn("培训时间:2025/4/10", enriched_message)
+        self.assertIn("培训地点:蜀道集团", enriched_message)
+        self.assertNotIn("公文写作", enriched_message)
+        rag_search.assert_awaited_once_with(enriched_message, top_k=10)
+        load_prompt.assert_called_once_with(
+            "ppt_outline",
+            userMessage=enriched_message,
+            contextJSON="Safety RAG context",
+        )
+        generation_messages = qwen_chat.await_args_list[1].args[0]
+        self.assertEqual(generation_messages[0], {"role": "system", "content": "ppt outline prompt"})
+        self.assertIn("直接输出安全培训PPT大纲", generation_messages[1]["content"])
+        deepseek_chat.assert_not_called()
+
+    def test_safety_training_fallback_plan_keeps_notice_requests_in_training_outline_domain(self):
+        plan = chat._build_fallback_safety_training_plan("生成施工安全培训通知")
+        enriched_message = chat._build_safety_training_generation_message("生成施工安全培训通知", plan)
+
+        self.assertEqual(plan["topic"], "施工安全培训")
+        self.assertIn("输出类型:安全培训PPT大纲", enriched_message)
+        self.assertIn("主题:施工安全培训", enriched_message)
+        self.assertIn("原始需求:生成施工安全培训通知", enriched_message)
+        self.assertNotIn("公文写作", enriched_message)
+
+    async def test_ai_writing_generation_uses_deepseek_non_streaming(self):
+        with patch.object(chat, "_rag_search", AsyncMock(return_value="RAG context")) as rag_search, \
+            patch.object(chat, "load_prompt", Mock(return_value="loaded prompt")) as load_prompt, \
+            patch.object(chat.deepseek_service, "chat", AsyncMock(return_value="DeepSeek reply")) as deepseek_chat, \
+            patch.object(chat.qwen_service, "chat", AsyncMock(return_value="Qwen reply")) as qwen_chat:
+
+            reply = await chat._generate_ai_writing_response("Draft a notice")
+
+        self.assertEqual(reply, "DeepSeek reply")
+        rag_search.assert_awaited_once_with("Draft a notice", top_k=10)
+        load_prompt.assert_called_once_with(
+            "document_writing",
+            userMessage="Draft a notice",
+            contextJSON="RAG context",
+        )
+        deepseek_messages = deepseek_chat.await_args.args[0]
+        self.assertEqual(deepseek_messages[0], {"role": "system", "content": "loaded prompt"})
+        self.assertEqual(deepseek_messages[1]["role"], "user")
+        self.assertIn("直接生成可放入富文本编辑器的公文正文 HTML 片段", deepseek_messages[1]["content"])
+        self.assertIn("Draft a notice", deepseek_messages[1]["content"])
+        qwen_chat.assert_not_called()
+
+    async def test_ai_writing_generation_cleans_full_html_document_for_rich_editor(self):
+        raw_html = """抱歉,我只能帮助您生成蜀道集团的公文内容。
+        <!DOCTYPE html>
+        <html><head><style>body{color:red}</style></head>
+        <body><div class="document"><h1>安全生产责任制</h1><p>正文内容</p></div></body></html>
+        """
+        with patch.object(chat, "_rag_search", AsyncMock(return_value="RAG context")), \
+            patch.object(chat, "load_prompt", Mock(return_value="loaded prompt")), \
+            patch.object(chat.deepseek_service, "chat", AsyncMock(return_value=raw_html)):
+
+            reply = await chat._generate_ai_writing_response("生成安全生产责任制")
+
+        self.assertIn('<div class="document">', reply)
+        self.assertIn("<h1>安全生产责任制</h1>", reply)
+        self.assertNotIn("抱歉", reply)
+        self.assertNotIn("<!DOCTYPE", reply)
+        self.assertNotIn("<style>", reply)
+        self.assertNotIn("<body>", reply)
+
+    def test_ai_writing_exchange_is_persisted_as_user_and_ai_messages(self):
+        db = FakeDB()
+
+        user_message, ai_message = chat._persist_message_pair(
+            db=db,
+            conv_id=123,
+            user=SimpleNamespace(user_id=70430),
+            user_content="user request",
+            ai_content="ai reply",
+        )
+
+        self.assertEqual(len(db.added), 2)
+        self.assertEqual(user_message.type, "user")
+        self.assertEqual(user_message.content, "user request")
+        self.assertEqual(ai_message.type, "ai")
+        self.assertEqual(ai_message.content, "ai reply")
+        self.assertEqual(ai_message.prev_user_id, user_message.id)
+        self.assertEqual(db.commits, 2)
+
+
+if __name__ == "__main__":
+    unittest.main()

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

@@ -204,7 +204,9 @@ def get_base_url() -> str:
 
 
 
 
 def get_proxy_url(original_url: str) -> str:
 def get_proxy_url(original_url: str) -> str:
-    """将原始URL转换为代理URL"""
+    """将原始URL转换为代理URL(加密)"""
     if not original_url:
     if not original_url:
         return ""
         return ""
-    return f"{settings.base_url}/apiv1/oss/parse?url={original_url}"
+    from .crypto import encrypt_url
+    encrypted = encrypt_url(original_url)
+    return f"{settings.base_url}/apiv1/oss/parse?url={encrypted}"

+ 11 - 1
shudao-vue-frontend/src/components/QuestionInput.vue

@@ -41,7 +41,7 @@
             :autosize="{ minRows: 1, maxRows: 4 }"
             :autosize="{ minRows: 1, maxRows: 4 }"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             :disabled="loading"
             :disabled="loading"
-            @keydown.enter.exact.prevent="handleSubmit"
+            @keydown.enter="handleEnterKey"
             class="message-input"
             class="message-input"
           />
           />
           
           
@@ -69,6 +69,7 @@
 <script setup>
 <script setup>
 import { reactive, ref } from 'vue'
 import { reactive, ref } from 'vue'
 import { Position, Link, Paperclip, Microphone, Setting, Document, Files } from '@element-plus/icons-vue'
 import { Position, Link, Paperclip, Microphone, Setting, Document, Files } from '@element-plus/icons-vue'
+import { handleChatInputEnterKey } from '@/utils/chatInputKeydown.js'
 
 
 const emit = defineEmits(['submit', 'cancel'])
 const emit = defineEmits(['submit', 'cancel'])
 
 
@@ -111,6 +112,15 @@ const handleSubmit = () => {
   })
   })
 }
 }
 
 
+const handleEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSubmit,
+    updateValue: (value) => {
+      form.question = value
+    }
+  })
+}
+
 const handleCancel = () => {
 const handleCancel = () => {
   emit('cancel')
   emit('cancel')
 }
 }

+ 3 - 0
shudao-vue-frontend/src/request/apis.js

@@ -23,6 +23,9 @@ export const apis = {
 
 
   //上传oss
   //上传oss
   uploadOss: (data) => request.post('/oss/upload', data),
   uploadOss: (data) => request.post('/oss/upload', data),
+
+  // 解析AI问答上传附件
+  parseAttachment: (data) => request.post('/attachments/parse', data),
   
   
   // 获取功能卡片
   // 获取功能卡片
   getFunctionCard: (params) => request.get('/get_function_card', { params }),
   getFunctionCard: (params) => request.get('/get_function_card', { params }),

+ 58 - 0
shudao-vue-frontend/src/utils/aiWritingContent.js

@@ -0,0 +1,58 @@
+function stripCodeFences(content) {
+  return String(content || '')
+    .replace(/```(?:html)?\s*/gi, '')
+    .replace(/```/g, '')
+    .trim()
+}
+
+function escapeHtml(content) {
+  return String(content || '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+function plainTextToHtml(content) {
+  return String(content || '')
+    .split(/\n{2,}/)
+    .map(part => part.trim())
+    .filter(Boolean)
+    .map(part => `<p>${escapeHtml(part).replace(/\n/g, '<br>')}</p>`)
+    .join('')
+}
+
+export function prepareAIWritingEditorHtml(content) {
+  let html = stripCodeFences(content)
+  if (!html) return ''
+
+  const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
+  if (bodyMatch) {
+    html = bodyMatch[1].trim()
+  }
+
+  const firstContentTag = html.search(/<(article|section|main|div|h[1-6]|p|table|ul|ol)\b/i)
+  if (firstContentTag > 0 && html.slice(0, firstContentTag).trim()) {
+    html = html.slice(firstContentTag)
+  }
+
+  html = html
+    .replace(/<!DOCTYPE[^>]*>/gi, '')
+    .replace(/<html[^>]*>/gi, '')
+    .replace(/<\/html>/gi, '')
+    .replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
+    .replace(/<body[^>]*>/gi, '')
+    .replace(/<\/body>/gi, '')
+    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
+    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
+    .replace(/<meta[^>]*>/gi, '')
+    .replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
+    .trim()
+
+  if (!/<[^>]+>/.test(html)) {
+    return plainTextToHtml(html)
+  }
+
+  return html
+}

+ 31 - 0
shudao-vue-frontend/src/utils/aiWritingContent.test.js

@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+
+import { prepareAIWritingEditorHtml } from './aiWritingContent'
+
+describe('aiWritingContent', () => {
+  it('keeps rich editor content as an HTML fragment instead of a full HTML document', () => {
+    const raw = `抱歉,我只能帮助您生成蜀道集团的公文内容。
+<!DOCTYPE html>
+<html>
+  <head>
+    <style>body { background: red; }</style>
+  </head>
+  <body>
+    <div class="document"><h1>安全生产责任制</h1><p>正文内容</p></div>
+  </body>
+</html>`
+
+    const html = prepareAIWritingEditorHtml(raw)
+
+    expect(html).toContain('<div class="document">')
+    expect(html).toContain('<h1>安全生产责任制</h1>')
+    expect(html).not.toContain('抱歉')
+    expect(html).not.toContain('<!DOCTYPE')
+    expect(html).not.toContain('<style>')
+    expect(html).not.toContain('<body>')
+  })
+
+  it('converts plain text into editable paragraphs', () => {
+    expect(prepareAIWritingEditorHtml('第一段\n\n第二段')).toBe('<p>第一段</p><p>第二段</p>')
+  })
+})

+ 29 - 0
shudao-vue-frontend/src/utils/aiWritingRequest.js

@@ -0,0 +1,29 @@
+export function shouldAttachDocumentToRequest(businessType) {
+  return Number(businessType) === 1 || Number(businessType) === 2
+}
+
+export function buildDocumentGenerationRequestMessage(question, file = null) {
+  const normalizedQuestion = typeof question === 'string' ? question.trim() : ''
+  if (!file) return normalizedQuestion
+
+  const content = typeof file.content === 'string' ? file.content : ''
+  const name = file.name || ''
+  const size = Number.isFinite(Number(file.size)) ? Number(file.size) : 0
+
+  return `<word>${content}</word><filename>${name}</filename><filesize>${size}</filesize>${normalizedQuestion}`
+}
+
+export function buildDocumentGenerationUserMessage(question, file = null) {
+  return {
+    content: typeof question === 'string' ? question.trim() : '',
+    file: file || null
+  }
+}
+
+export function buildAIWritingRequestMessage(question, file = null) {
+  return buildDocumentGenerationRequestMessage(question, file)
+}
+
+export function buildAIWritingUserMessage(question, file = null) {
+  return buildDocumentGenerationUserMessage(question, file)
+}

+ 69 - 0
shudao-vue-frontend/src/utils/aiWritingRequest.test.js

@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+  buildAIWritingRequestMessage,
+  buildAIWritingUserMessage,
+  buildDocumentGenerationRequestMessage,
+  buildDocumentGenerationUserMessage,
+  shouldAttachDocumentToRequest
+} from './aiWritingRequest'
+
+describe('aiWritingRequest', () => {
+  it('sends uploaded Word content with the user requirement in the legacy tag format', () => {
+    const file = {
+      name: 'notice.docx',
+      size: 2048,
+      content: 'Existing document body'
+    }
+
+    expect(buildAIWritingRequestMessage('Please polish this notice', file)).toBe(
+      '<word>Existing document body</word><filename>notice.docx</filename><filesize>2048</filesize>Please polish this notice'
+    )
+  })
+
+  it('keeps the visible chat message readable while preserving file metadata', () => {
+    const file = {
+      name: 'summary.docx',
+      size: 4096,
+      type: '.docx',
+      icon: '/word.png',
+      content: 'Extracted text'
+    }
+
+    expect(buildAIWritingUserMessage('Generate a summary', file)).toEqual({
+      content: 'Generate a summary',
+      file
+    })
+  })
+
+  it('falls back to plain text when no file is attached', () => {
+    expect(buildAIWritingRequestMessage('Draft a report')).toBe('Draft a report')
+    expect(buildAIWritingUserMessage('Draft a report')).toEqual({
+      content: 'Draft a report',
+      file: null
+    })
+  })
+
+  it('builds the same legacy tagged request for safety training document generation', () => {
+    const file = {
+      name: 'training.docx',
+      size: 8192,
+      content: 'Safety training source'
+    }
+
+    expect(buildDocumentGenerationRequestMessage('生成桥梁施工安全培训大纲', file)).toBe(
+      '<word>Safety training source</word><filename>training.docx</filename><filesize>8192</filesize>生成桥梁施工安全培训大纲'
+    )
+    expect(buildDocumentGenerationUserMessage('生成桥梁施工安全培训大纲', file)).toEqual({
+      content: '生成桥梁施工安全培训大纲',
+      file
+    })
+  })
+
+  it('only attaches uploaded Word content for document generation business types', () => {
+    expect(shouldAttachDocumentToRequest(1)).toBe(true)
+    expect(shouldAttachDocumentToRequest(2)).toBe(true)
+    expect(shouldAttachDocumentToRequest(0)).toBe(false)
+    expect(shouldAttachDocumentToRequest(3)).toBe(false)
+  })
+})

+ 19 - 0
shudao-vue-frontend/src/utils/attachmentContext.js

@@ -0,0 +1,19 @@
+export function buildUploadedDocumentPayload(file) {
+  if (!file || typeof file.content !== 'string' || !file.content.trim()) {
+    return null
+  }
+
+  const normalizedType = String(file.type || file.fileType || '')
+    .replace(/^\./, '')
+    .toLowerCase()
+
+  return {
+    file_name: file.name || file.fileName || '',
+    file_type: normalizedType,
+    content: file.content,
+    attachment_id: file.attachmentId || file.attachment_id || '',
+    char_count: Number.isFinite(Number(file.charCount ?? file.char_count))
+      ? Number(file.charCount ?? file.char_count)
+      : file.content.length
+  }
+}

+ 28 - 0
shudao-vue-frontend/src/utils/attachmentContext.test.js

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildUploadedDocumentPayload } from './attachmentContext'
+
+describe('attachmentContext', () => {
+  it('builds report uploaded document payload from parsed attachment', () => {
+    const file = {
+      name: '专项施工方案.pdf',
+      type: '.pdf',
+      content: '第一章 总则\n第二章 安全措施',
+      attachmentId: 'sha256:abc',
+      charCount: 18
+    }
+
+    expect(buildUploadedDocumentPayload(file)).toEqual({
+      file_name: '专项施工方案.pdf',
+      file_type: 'pdf',
+      content: '第一章 总则\n第二章 安全措施',
+      attachment_id: 'sha256:abc',
+      char_count: 18
+    })
+  })
+
+  it('returns null when no parsed text is available', () => {
+    expect(buildUploadedDocumentPayload(null)).toBeNull()
+    expect(buildUploadedDocumentPayload({ name: 'empty.pdf', content: '   ' })).toBeNull()
+  })
+})

+ 10 - 0
shudao-vue-frontend/src/utils/attachmentFile.js

@@ -0,0 +1,10 @@
+const UNIFIED_DOCUMENT_TYPES = new Set(['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.txt'])
+
+export function getAttachmentCardIcon(fileType, documentIcon, fallbackIcon = '') {
+  const normalizedType = String(fileType || '')
+    .trim()
+    .toLowerCase()
+  const extension = normalizedType.startsWith('.') ? normalizedType : `.${normalizedType}`
+
+  return UNIFIED_DOCUMENT_TYPES.has(extension) ? documentIcon : fallbackIcon
+}

+ 18 - 0
shudao-vue-frontend/src/utils/attachmentFile.test.js

@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+
+import { getAttachmentCardIcon } from './attachmentFile'
+
+describe('attachmentFile', () => {
+  it('uses the document card icon for supported upload formats', () => {
+    const documentIcon = '/assets/doc-card.png'
+
+    expect(getAttachmentCardIcon('.docx', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('pdf', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('.pptx', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('.txt', documentIcon)).toBe(documentIcon)
+  })
+
+  it('uses fallback icon for unknown formats', () => {
+    expect(getAttachmentCardIcon('.zip', '/assets/doc-card.png', 'fallback')).toBe('fallback')
+  })
+})

+ 103 - 1
shudao-vue-frontend/src/utils/chatHistoryPersistence.js

@@ -1,3 +1,57 @@
+const getReportDisplayName = (report) => {
+  return report?.report?.display_name ||
+    report?._fullContent?.display_name ||
+    report?.source_file ||
+    ''
+}
+
+const normalizeReportKeyPart = (value, { fileName = false } = {}) => {
+  let normalized = String(value || '')
+    .normalize('NFKC')
+    .trim()
+
+  if (fileName) {
+    normalized = normalized
+      .replace(/[\u2010-\u2015\u2212-]/g, '-')
+      .replace(/[\/\\_]+/g, '')
+      .replace(/\.[a-z0-9]{1,8}$/i, '')
+      .replace(/^\d+[-_、.\s]+(?=[\u4e00-\u9fff])/, '')
+  }
+
+  return normalized
+    .replace(/\s+/g, '')
+    .toLowerCase()
+}
+
+export const dedupeReportsByFileAndScene = (reports) => {
+  if (!Array.isArray(reports)) {
+    return []
+  }
+
+  const seen = new Set()
+
+  return reports.filter((report) => {
+    if (!report || report.type === 'category_title') {
+      return true
+    }
+
+    const fileName = normalizeReportKeyPart(getReportDisplayName(report), { fileName: true })
+    if (!fileName) {
+      return true
+    }
+
+    const sceneCategory = normalizeReportKeyPart(report.metadata?.secondary_category || '')
+    const key = `${fileName}\u0000${sceneCategory}`
+
+    if (seen.has(key)) {
+      return false
+    }
+
+    seen.add(key)
+    return true
+  })
+}
+
 export const hydratePersistedReports = (reports) => {
 export const hydratePersistedReports = (reports) => {
   if (!Array.isArray(reports)) {
   if (!Array.isArray(reports)) {
     return []
     return []
@@ -25,7 +79,55 @@ export const hydratePersistedReports = (reports) => {
 }
 }
 
 
 export const normalizeReportsForPersistence = (reports) => {
 export const normalizeReportsForPersistence = (reports) => {
-  return hydratePersistedReports(reports)
+  return dedupeReportsByFileAndScene(hydratePersistedReports(reports))
+}
+
+export const applyReportChunkToMessage = (message, streamingReports, event) => {
+  if (!message || !Array.isArray(message.reports) || !event) {
+    return null
+  }
+
+  const fileIndex = event.file_index
+  let reportIndex = streamingReports?.get?.(fileIndex)
+  if (reportIndex === undefined) {
+    reportIndex = message.reports.findIndex(report => report?.file_index === fileIndex)
+  }
+
+  const target = message.reports[reportIndex]
+  if (!target || target.type === 'category_title') {
+    return null
+  }
+
+  const partialReport = event.partial_report && typeof event.partial_report === 'object'
+    ? event.partial_report
+    : {}
+  const fields = ['display_name', 'summary', 'analysis', 'clauses']
+
+  target.report = {
+    display_name: target.report?.display_name || '',
+    summary: target.report?.summary || '',
+    analysis: target.report?.analysis || '',
+    clauses: target.report?.clauses || ''
+  }
+  target._fullContent = {
+    display_name: target._fullContent?.display_name || target.report.display_name || '',
+    summary: target._fullContent?.summary || target.report.summary || '',
+    analysis: target._fullContent?.analysis || target.report.analysis || '',
+    clauses: target._fullContent?.clauses || target.report.clauses || ''
+  }
+
+  fields.forEach((field) => {
+    if (typeof partialReport[field] === 'string') {
+      target.report[field] = partialReport[field]
+      target._fullContent[field] = partialReport[field]
+    }
+  })
+
+  target.status = target.status || 'streaming'
+  target._streamingStarted = true
+  target._rawReportChunk = `${target._rawReportChunk || ''}${event.chunk || ''}`
+
+  return target
 }
 }
 
 
 const extractBalancedJson = (text) => {
 const extractBalancedJson = (text) => {

+ 140 - 0
shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js

@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'
 import {
 import {
   buildAIMessageUpdatePayload,
   buildAIMessageUpdatePayload,
   buildPersistedAIMessageContent,
   buildPersistedAIMessageContent,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   extractRelatedQuestions,
+  applyReportChunkToMessage,
   hydratePersistedReports,
   hydratePersistedReports,
   normalizeReportsForPersistence,
   normalizeReportsForPersistence,
   shouldClearSummaryForOnlineAnswer,
   shouldClearSummaryForOnlineAnswer,
@@ -11,6 +13,40 @@ import {
 } from './chatHistoryPersistence'
 } from './chatHistoryPersistence'
 
 
 describe('chatHistoryPersistence', () => {
 describe('chatHistoryPersistence', () => {
+  it('applies streamed report chunks to an existing placeholder report', () => {
+    const message = {
+      reports: [
+        {
+          file_index: 1,
+          source_file: 'bridge-spec.pdf',
+          status: 'streaming',
+          report: {
+            display_name: '',
+            summary: '',
+            analysis: '',
+            clauses: ''
+          }
+        }
+      ]
+    }
+    const streamingReports = new Map([[1, 0]])
+
+    const target = applyReportChunkToMessage(message, streamingReports, {
+      file_index: 1,
+      chunk: '{"summary":"partial',
+      partial_report: {
+        display_name: 'bridge-spec',
+        summary: 'partial summary'
+      }
+    })
+
+    expect(target).toBe(message.reports[0])
+    expect(target.report.display_name).toBe('bridge-spec')
+    expect(target.report.summary).toBe('partial summary')
+    expect(target._streamingStarted).toBe(true)
+    expect(target._rawReportChunk).toBe('{"summary":"partial')
+  })
+
   it('fills report fields from _fullContent before persistence', () => {
   it('fills report fields from _fullContent before persistence', () => {
     const reports = [
     const reports = [
       {
       {
@@ -140,6 +176,110 @@ describe('chatHistoryPersistence', () => {
     )
     )
   })
   })
 
 
+  it('keeps only the first report for the same displayed file name and scene category', () => {
+    const reports = [
+      {
+        file_index: 1,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T33000-2016).json',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T33000-2016)',
+          summary: 'first report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 2,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+          summary: 'duplicate report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 3,
+        status: 'completed',
+        source_file: '企业安全生产标准化基本规范',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '动火作业'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+          summary: 'different scene report',
+          analysis: '',
+          clauses: ''
+        }
+      }
+    ]
+
+    expect(dedupeReportsByFileAndScene(reports).map(report => report.file_index)).toEqual([1, 3])
+  })
+
+  it('treats standard-code slash, underscore, spacing, and dash variants as the same file name', () => {
+    const reports = [
+      {
+        file_index: 3,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T33000-2016).json',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范(GB/T 33000—2016)',
+          summary: 'first report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 4,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范(GB/T 33000-2016)',
+          summary: 'duplicate report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 5,
+        status: 'completed',
+        source_file: '企业安全生产标准化基本规范',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '动火作业'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范',
+          summary: 'different scene report',
+          analysis: '',
+          clauses: ''
+        }
+      }
+    ]
+
+    expect(dedupeReportsByFileAndScene(reports).map(report => report.file_index)).toEqual([3, 5])
+  })
+
   it('builds an update payload for completed non-typing messages', () => {
   it('builds an update payload for completed non-typing messages', () => {
     const payload = buildAIMessageUpdatePayload({
     const payload = buildAIMessageUpdatePayload({
       type: 'ai',
       type: 'ai',

+ 32 - 0
shudao-vue-frontend/src/utils/chatInputKeydown.js

@@ -0,0 +1,32 @@
+export const insertNewlineAtCursor = (target) => {
+  const value = target?.value || ''
+  const start = Number.isInteger(target?.selectionStart) ? target.selectionStart : value.length
+  const end = Number.isInteger(target?.selectionEnd) ? target.selectionEnd : start
+  const nextValue = `${value.slice(0, start)}\n${value.slice(end)}`
+  const nextCursor = start + 1
+
+  target.value = nextValue
+  target?.setSelectionRange?.(nextCursor, nextCursor)
+
+  return nextValue
+}
+
+export const handleChatInputEnterKey = (event, { submit, updateValue } = {}) => {
+  if (event?.isComposing) return
+
+  const shouldInsertNewline = event?.ctrlKey || event?.metaKey
+  const shouldSubmit = !event?.shiftKey && !event?.altKey && !shouldInsertNewline
+
+  if (!shouldInsertNewline && !shouldSubmit) return
+
+  event?.preventDefault?.()
+
+  if (shouldInsertNewline) {
+    const nextValue = insertNewlineAtCursor(event.target)
+    updateValue?.(nextValue)
+    event.target?.dispatchEvent?.(new Event('input', { bubbles: true }))
+    return
+  }
+
+  submit?.()
+}

+ 75 - 0
shudao-vue-frontend/src/utils/chatInputKeydown.test.js

@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from 'vitest'
+import { handleChatInputEnterKey } from './chatInputKeydown'
+
+const createEvent = ({ ctrlKey = false, value = 'hello', start = value.length, end = start } = {}) => {
+  const target = {
+    value,
+    selectionStart: start,
+    selectionEnd: end,
+    setSelectionRange: vi.fn(function setSelectionRange(nextStart, nextEnd) {
+      this.selectionStart = nextStart
+      this.selectionEnd = nextEnd
+    }),
+    dispatchEvent: vi.fn()
+  }
+
+  return {
+    key: 'Enter',
+    ctrlKey,
+    metaKey: false,
+    shiftKey: false,
+    altKey: false,
+    target,
+    preventDefault: vi.fn(),
+    stopPropagation: vi.fn()
+  }
+}
+
+describe('handleChatInputEnterKey', () => {
+  it('submits on plain Enter without inserting a newline', () => {
+    const event = createEvent()
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(event.preventDefault).toHaveBeenCalledOnce()
+    expect(submit).toHaveBeenCalledOnce()
+    expect(updateValue).not.toHaveBeenCalled()
+    expect(event.target.value).toBe('hello')
+  })
+
+  it('inserts a newline at the cursor on Ctrl Enter without submitting', () => {
+    const event = createEvent({ ctrlKey: true, value: 'helloworld', start: 5, end: 5 })
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(event.preventDefault).toHaveBeenCalledOnce()
+    expect(submit).not.toHaveBeenCalled()
+    expect(updateValue).toHaveBeenCalledWith('hello\nworld')
+    expect(event.target.value).toBe('hello\nworld')
+    expect(event.target.setSelectionRange).toHaveBeenCalledWith(6, 6)
+  })
+
+  it('replaces the selected text with a newline on Ctrl Enter', () => {
+    const event = createEvent({ ctrlKey: true, value: 'hello world', start: 5, end: 6 })
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(updateValue).toHaveBeenCalledWith('hello\nworld')
+    expect(event.target.setSelectionRange).toHaveBeenCalledWith(6, 6)
+  })
+})

+ 41 - 0
shudao-vue-frontend/src/utils/generatedDocumentCard.js

@@ -0,0 +1,41 @@
+function toDate(value) {
+  if (value instanceof Date) return value
+
+  if (typeof value === 'number') {
+    const timestampMs = String(value).length === 10 ? value * 1000 : value
+    return new Date(timestampMs)
+  }
+
+  if (typeof value === 'string' && value.trim()) {
+    const numericValue = Number(value)
+    if (Number.isFinite(numericValue) && /^\d+$/.test(value.trim())) {
+      const timestampMs = value.trim().length === 10 ? numericValue * 1000 : numericValue
+      return new Date(timestampMs)
+    }
+
+    return new Date(value)
+  }
+
+  return null
+}
+
+function padTime(value) {
+  return String(value).padStart(2, '0')
+}
+
+export function formatGeneratedDocumentCardTime(timestamp) {
+  const date = toDate(timestamp)
+
+  if (!date || Number.isNaN(date.getTime())) {
+    return '未知时间'
+  }
+
+  return [
+    `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}/`,
+    `${padTime(date.getHours())}:${padTime(date.getMinutes())}`
+  ].join(' ')
+}
+
+export function getGeneratedDocumentCardTime(message) {
+  return formatGeneratedDocumentCardTime(message?.timestamp)
+}

+ 29 - 0
shudao-vue-frontend/src/utils/generatedDocumentCard.test.js

@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+  formatGeneratedDocumentCardTime,
+  getGeneratedDocumentCardTime
+} from './generatedDocumentCard'
+
+describe('generatedDocumentCard', () => {
+  it('formats generated document card time as full date and minute', () => {
+    const date = new Date(2026, 3, 28, 14, 56, 30)
+
+    expect(formatGeneratedDocumentCardTime(date)).toBe('2026/4/28/ 14:56')
+  })
+
+  it('keeps minute and hour padded while leaving month and day unpadded', () => {
+    const date = new Date(2026, 3, 8, 9, 5, 0)
+
+    expect(formatGeneratedDocumentCardTime(date)).toBe('2026/4/8/ 09:05')
+  })
+
+  it('uses the full generated document time format for AI writing cards', () => {
+    const date = new Date(2026, 3, 28, 15, 9, 0)
+
+    expect(getGeneratedDocumentCardTime({
+      isAIWriting: true,
+      timestamp: date
+    })).toBe('2026/4/28/ 15:09')
+  })
+})

+ 37 - 0
shudao-vue-frontend/src/utils/resizableSidebar.js

@@ -0,0 +1,37 @@
+export const AI_WRITING_SIDEBAR_SIZE = Object.freeze({
+  min: 360,
+  default: 520,
+  max: 900,
+  minMain: 360,
+  keyboardStep: 32
+})
+
+function toFiniteNumber(value, fallback) {
+  const numberValue = Number(value)
+  return Number.isFinite(numberValue) ? numberValue : fallback
+}
+
+function clamp(value, min, max) {
+  return Math.min(Math.max(value, min), max)
+}
+
+export function calculateResizableSidebarWidth({
+  pointerX,
+  containerRight,
+  containerWidth,
+  minWidth = AI_WRITING_SIDEBAR_SIZE.min,
+  maxWidth = AI_WRITING_SIDEBAR_SIZE.max,
+  minMainWidth = AI_WRITING_SIDEBAR_SIZE.minMain
+} = {}) {
+  const safeMinWidth = Math.max(0, toFiniteNumber(minWidth, AI_WRITING_SIDEBAR_SIZE.min))
+  const safeMaxWidth = Math.max(safeMinWidth, toFiniteNumber(maxWidth, AI_WRITING_SIDEBAR_SIZE.max))
+  const safeMainWidth = Math.max(0, toFiniteNumber(minMainWidth, AI_WRITING_SIDEBAR_SIZE.minMain))
+  const safeContainerWidth = Math.max(0, toFiniteNumber(containerWidth, safeMaxWidth + safeMainWidth))
+  const availableMaxWidth = Math.max(
+    safeMinWidth,
+    Math.min(safeMaxWidth, safeContainerWidth - safeMainWidth)
+  )
+
+  const rawWidth = toFiniteNumber(containerRight, 0) - toFiniteNumber(pointerX, 0)
+  return Math.round(clamp(rawWidth, safeMinWidth, availableMaxWidth))
+}

+ 57 - 0
shudao-vue-frontend/src/utils/resizableSidebar.test.js

@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+  AI_WRITING_SIDEBAR_SIZE,
+  calculateResizableSidebarWidth
+} from './resizableSidebar'
+
+describe('calculateResizableSidebarWidth', () => {
+  it('expands the right sidebar when dragging its left edge leftward', () => {
+    expect(calculateResizableSidebarWidth({
+      pointerX: 620,
+      containerRight: 1200,
+      containerWidth: 980
+    })).toBe(580)
+  })
+
+  it('clamps to the configured minimum width', () => {
+    expect(calculateResizableSidebarWidth({
+      pointerX: 1050,
+      containerRight: 1200,
+      containerWidth: 980,
+      minWidth: 360
+    })).toBe(360)
+  })
+
+  it('keeps enough room for the main chat area', () => {
+    expect(calculateResizableSidebarWidth({
+      pointerX: 400,
+      containerRight: 1200,
+      containerWidth: 900,
+      maxWidth: 760,
+      minMainWidth: 420
+    })).toBe(480)
+  })
+
+  it('uses the shared AI writing sidebar size config as default limits', () => {
+    expect(AI_WRITING_SIDEBAR_SIZE).toEqual({
+      min: 360,
+      default: 520,
+      max: 760,
+      minMain: 360,
+      keyboardStep: 32
+    })
+
+    expect(calculateResizableSidebarWidth({
+      pointerX: 0,
+      containerRight: 1200,
+      containerWidth: 2000
+    })).toBe(AI_WRITING_SIDEBAR_SIZE.max)
+
+    expect(calculateResizableSidebarWidth({
+      pointerX: 1190,
+      containerRight: 1200,
+      containerWidth: 2000
+    })).toBe(AI_WRITING_SIDEBAR_SIZE.min)
+  })
+})

+ 6 - 0
shudao-vue-frontend/src/utils/safetyTrainingNavigation.js

@@ -0,0 +1,6 @@
+export function buildNewSafetyTrainingTaskRoute() {
+  return {
+    path: '/chat',
+    query: { mode: 'safety-training' }
+  }
+}

+ 12 - 0
shudao-vue-frontend/src/utils/safetyTrainingNavigation.test.js

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildNewSafetyTrainingTaskRoute } from './safetyTrainingNavigation'
+
+describe('safetyTrainingNavigation', () => {
+  it('routes new safety training tasks to the new chat safety-training mode', () => {
+    expect(buildNewSafetyTrainingTaskRoute()).toEqual({
+      path: '/chat',
+      query: { mode: 'safety-training' }
+    })
+  })
+})

+ 15 - 0
shudao-vue-frontend/src/utils/safetyTrainingRouteLoading.js

@@ -0,0 +1,15 @@
+export function getSafetyTrainingRouteConversationId(query = {}) {
+  const rawId = Array.isArray(query.id) ? query.id[0] : query.id
+
+  if (rawId === undefined || rawId === null || rawId === '') {
+    return null
+  }
+
+  const id = Number(String(rawId).trim())
+
+  return Number.isInteger(id) && id > 0 ? id : null
+}
+
+export function shouldShowSafetyTrainingRouteLoading({ query = {}, isLoading = false } = {}) {
+  return Boolean(isLoading && getSafetyTrainingRouteConversationId(query))
+}

+ 25 - 0
shudao-vue-frontend/src/utils/safetyTrainingRouteLoading.test.js

@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+  getSafetyTrainingRouteConversationId,
+  shouldShowSafetyTrainingRouteLoading
+} from './safetyTrainingRouteLoading'
+
+describe('safetyTrainingRouteLoading', () => {
+  it('uses a positive route id as the conversation to load', () => {
+    expect(getSafetyTrainingRouteConversationId({ id: '9876' })).toBe(9876)
+    expect(getSafetyTrainingRouteConversationId({ id: 42 })).toBe(42)
+  })
+
+  it('ignores invalid route ids', () => {
+    expect(getSafetyTrainingRouteConversationId({ id: 'abc' })).toBeNull()
+    expect(getSafetyTrainingRouteConversationId({ id: '0' })).toBeNull()
+    expect(getSafetyTrainingRouteConversationId({})).toBeNull()
+  })
+
+  it('only shows the route loading state while a valid route conversation is loading', () => {
+    expect(shouldShowSafetyTrainingRouteLoading({ query: { id: '9876' }, isLoading: true })).toBe(true)
+    expect(shouldShowSafetyTrainingRouteLoading({ query: { id: '9876' }, isLoading: false })).toBe(false)
+    expect(shouldShowSafetyTrainingRouteLoading({ query: { id: 'abc' }, isLoading: true })).toBe(false)
+  })
+})

+ 20 - 0
shudao-vue-frontend/src/views/Chat.pointsBalance.test.js

@@ -0,0 +1,20 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+const chatSource = readFileSync(resolve(__dirname, 'Chat.vue'), 'utf8')
+
+describe('Chat points balance card', () => {
+  it('renders the remaining points card from the points balance service', () => {
+    expect(chatSource).toContain("import { getBalance as getPointsBalance } from '@/services/pointsService.js'")
+    expect(chatSource).toContain('class="points-display"')
+    expect(chatSource).toContain('剩余积分')
+    expect(chatSource).toContain('{{ userPointsBalance }}')
+    expect(chatSource).toContain('const userPointsBalance = ref(0)')
+    expect(chatSource).toContain('userPointsBalance.value = await getPointsBalance()')
+    expect(chatSource).toContain('fetchPointsBalance()')
+  })
+})

+ 51 - 0
shudao-vue-frontend/src/views/Chat.thinkingPanelOrder.test.js

@@ -0,0 +1,51 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+const readView = (path) => readFileSync(resolve(__dirname, path), 'utf8')
+
+const getTemplate = (source) => {
+  const templateStart = source.indexOf('<template>')
+  const templateEnd = source.lastIndexOf('</template>')
+  expect(templateStart).toBeGreaterThanOrEqual(0)
+  expect(templateEnd).toBeGreaterThan(templateStart)
+  return source.slice(templateStart, templateEnd)
+}
+
+const expectThinkingPanelBeforeAnswer = (source) => {
+  const template = getTemplate(source)
+  const responseStart = template.indexOf('<div class="ai-response-content">')
+  const thinkingPanel = template.indexOf('class="thinking-panel"', responseStart)
+  const questionSummary = template.indexOf('class="question-summary"', responseStart)
+  const aiText = template.indexOf('class="ai-text"', responseStart)
+
+  expect(responseStart).toBeGreaterThanOrEqual(0)
+  expect(thinkingPanel).toBeGreaterThan(responseStart)
+  expect(questionSummary).toBeGreaterThan(responseStart)
+  expect(aiText).toBeGreaterThan(responseStart)
+  expect(thinkingPanel).toBeLessThan(questionSummary)
+  expect(thinkingPanel).toBeLessThan(aiText)
+  expect(template.slice(thinkingPanel - 80, thinkingPanel)).toContain('v-if="message.thinkingContent"')
+}
+
+describe('Chat thinking panel order', () => {
+  it('renders desktop thinking content before summary and answer content', () => {
+    expectThinkingPanelBeforeAnswer(readView('Chat.vue'))
+  })
+
+  it('renders mobile thinking content before summary and answer content', () => {
+    expectThinkingPanelBeforeAnswer(readView('mobile/m-Chat.vue'))
+  })
+
+  it('updates mobile thinking content as soon as SSE thinking summaries arrive', () => {
+    const source = readView('mobile/m-Chat.vue')
+
+    expect(source).toContain('const appendThinkingContent =')
+    expect(source).toContain("appendThinkingContent(aiMessage, '意图分析', data.thinking_content)")
+    expect(source).toContain("appendThinkingContent(aiMessage, '正式回答', data.thinking_content)")
+  })
+})

+ 14 - 0
shudao-vue-frontend/src/views/Chat.userMessageWhitespace.test.js

@@ -0,0 +1,14 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+const chatSource = readFileSync(resolve(__dirname, 'Chat.vue'), 'utf8')
+
+describe('Chat user message whitespace', () => {
+  it('preserves line breaks in sent user messages', () => {
+    expect(chatSource).toMatch(/\.user-message[\s\S]*?\.message-text\s*\{[\s\S]*?white-space:\s*pre-wrap/)
+  })
+})

Разлика између датотеке није приказан због своје велике величине
+ 535 - 119
shudao-vue-frontend/src/views/Chat.vue


+ 30 - 2
shudao-vue-frontend/src/views/HazardDetection.vue

@@ -1804,8 +1804,16 @@ const startIdentification = async () => {
                 (Array.isArray(backendData.third_scenes) &&
                 (Array.isArray(backendData.third_scenes) &&
                     backendData.third_scenes.length > 0);
                     backendData.third_scenes.length > 0);
 
 
+            // 检查是否是"未检测到隐患"的情况
+            const isNoHazardsDetected = response.msg === "未检测到隐患" || 
+                                       backendData.no_hazards_detected === true;
+
             if (!hasDetections && !hasLabels) {
             if (!hasDetections && !hasLabels) {
-                ElMessage.warning("未检测到隐患,已完成识别");
+                if (isNoHazardsDetected) {
+                    ElMessage.info("未检测到任何关键要素");
+                } else {
+                    ElMessage.warning("未检测到隐患,已完成识别");
+                }
             } else {
             } else {
                 ElMessage.success("隐患提示完成!");
                 ElMessage.success("隐患提示完成!");
             }
             }
@@ -1891,7 +1899,27 @@ const startIdentification = async () => {
             }
             }
         }
         }
     } catch (error) {
     } catch (error) {
-        ElMessage.error("隐患提示失败: " + (error.msg || "未知错误"));
+        console.error("[startIdentification] 捕获到异常:", {
+            error: error,
+            errorType: typeof error,
+            errorConstructor: error?.constructor?.name,
+            errorMessage: error?.message,
+            errorMsg: error?.msg,
+            errorStack: error?.stack,
+            errorKeys: error ? Object.keys(error) : [],
+            errorStringified: JSON.stringify(error, Object.getOwnPropertyNames(error))
+        });
+        
+        let errorMessage = error?.msg || error?.message || "未知错误";
+        console.error("[startIdentification] 最终错误消息:", errorMessage);
+        
+        // 将"暂未识别到支持的场景"的错误消息改为更友好的提示
+        if (errorMessage.includes("暂未识别到支持的场景") || 
+            errorMessage.includes("请尝试更换更清晰的图片")) {
+            ElMessage.info("未检测到任何关键要素");
+        } else {
+            ElMessage.error("隐患提示失败: " + errorMessage);
+        }
         isDragOver.value = false;
         isDragOver.value = false;
     } finally {
     } finally {
         isIdentifying.value = false;
         isIdentifying.value = false;

+ 127 - 24
shudao-vue-frontend/src/views/SafetyHazard.vue

@@ -117,6 +117,20 @@
 
 
       <div class="work-content">
       <div class="work-content">
 
 
+        <div v-if="isRouteConversationLoadingVisible" class="route-loading-overlay">
+
+          <div class="route-loading-content">
+
+            <div class="route-loading-spinner"></div>
+
+            <div class="route-loading-title">正在加载安全培训大纲...</div>
+
+            <div class="route-loading-description">请稍候,AI智能助手正在整理可编辑内容</div>
+
+          </div>
+
+        </div>
+
         <!-- 步骤一:AI聊天界面 -->
         <!-- 步骤一:AI聊天界面 -->
 
 
         <div v-if="currentStep === 'step1'" class="step1-content">
         <div v-if="currentStep === 'step1'" class="step1-content">
@@ -1813,7 +1827,7 @@
 
 
       <!-- 推荐问题 -->
       <!-- 推荐问题 -->
 
 
-      <div v-if="currentStep === 'step1' && !showChat && !selectedFile" class="recommended-questions">
+      <div v-if="currentStep === 'step1' && !showChat && !selectedFile && !isRouteConversationLoadingVisible" class="recommended-questions">
 
 
         <div v-for="(question, index) in hotQuestions" :key="question.id || index" class="question-tag"
         <div v-for="(question, index) in hotQuestions" :key="question.id || index" class="question-tag"
           @click="handleRecommendedQuestion(question.question)">
           @click="handleRecommendedQuestion(question.question)">
@@ -1860,7 +1874,7 @@
 
 
       <!-- 底部输入区域 -->
       <!-- 底部输入区域 -->
 
 
-      <div v-if="currentStep === 'step1'" class="chat-input-section">
+      <div v-if="currentStep === 'step1' && !isRouteConversationLoadingVisible" class="chat-input-section">
 
 
         <div class="input-container">
         <div class="input-container">
 
 
@@ -2074,6 +2088,11 @@ import Sidebar from '@/components/Sidebar.vue'
 import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
 import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
 
 
 import { apis } from '@/request/apis.js'
 import { apis } from '@/request/apis.js'
+import { buildNewSafetyTrainingTaskRoute } from '@/utils/safetyTrainingNavigation.js'
+import {
+  getSafetyTrainingRouteConversationId,
+  shouldShowSafetyTrainingRouteLoading
+} from '@/utils/safetyTrainingRouteLoading.js'
 
 
 // ===== 已删除:getUserId - 不再需要,改用token =====
 // ===== 已删除:getUserId - 不再需要,改用token =====
 // import { getUserId } from '@/utils/userManager.js'
 // import { getUserId } from '@/utils/userManager.js'
@@ -2263,10 +2282,17 @@ const isApplyingTemplate = ref(false) // 控制是否正在应用模板
 
 
 const isLoadingHistory = ref(false) // 控制是否正在加载历史记录
 const isLoadingHistory = ref(false) // 控制是否正在加载历史记录
 
 
+const isLoadingRouteConversation = ref(false) // 控制从卡片跳转进入旧路由时的中间加载态
+
 const isGeneratingTrainingMaterial = ref(false) // 控制是否正在生成培训讲义
 const isGeneratingTrainingMaterial = ref(false) // 控制是否正在生成培训讲义
 
 
 const isProcessing = ref(false) // 控制是否正在处理中(生成PPT或下载时禁用其他操作)
 const isProcessing = ref(false) // 控制是否正在处理中(生成PPT或下载时禁用其他操作)
 
 
+const isRouteConversationLoadingVisible = computed(() => shouldShowSafetyTrainingRouteLoading({
+  query: route.query,
+  isLoading: isLoadingRouteConversation.value
+}))
+
 
 
 
 
 // 功能卡片和热点问题数据
 // 功能卡片和热点问题数据
@@ -8073,7 +8099,7 @@ const handleNewChatClick = () => {
 
 
   }
   }
 
 
-  createNewChat()
+  router.push(buildNewSafetyTrainingTaskRoute())
 
 
 }
 }
 
 
@@ -9369,6 +9395,8 @@ const handleHistoryItem = async (historyItem) => {
 
 
     isLoadingHistory.value = false
     isLoadingHistory.value = false
 
 
+    isLoadingRouteConversation.value = false
+
   }
   }
 
 
 }
 }
@@ -11357,28 +11385,35 @@ onMounted(async () => {
   console.log('🚀 页面初始化开始,优先加载历史记录...')
   console.log('🚀 页面初始化开始,优先加载历史记录...')
 
 
   // 检查URL中是否有id参数,如果有,将其设置为当前的 ai_conversation_id
   // 检查URL中是否有id参数,如果有,将其设置为当前的 ai_conversation_id
-  if (route.query.id) {
-    const id = parseInt(route.query.id);
-    if (!isNaN(id) && id > 0) {
-      ai_conversation_id.value = id;
-      console.log('从URL获取到对话ID:', id);
-      
-      // 等待历史记录加载完成后,自动选中并触发点击事件
-      const checkHistory = setInterval(() => {
-        if (historyData.value && historyData.value.length > 0) {
-          clearInterval(checkHistory);
-          const targetItem = historyData.value.find(item => item.id === id);
-          if (targetItem) {
-            handleHistoryItem(targetItem);
-          } else {
-            handleHistoryItem({ id }); // 降级处理
-          }
+  const routeConversationId = getSafetyTrainingRouteConversationId(route.query)
+  if (routeConversationId) {
+    ai_conversation_id.value = routeConversationId
+    isLoadingRouteConversation.value = true
+    console.log('从URL获取到对话ID:', routeConversationId)
+
+    let hasLoadedRouteConversation = false
+
+    // 等待历史记录加载完成后,自动选中并触发点击事件
+    const checkHistory = setInterval(() => {
+      if (historyData.value && historyData.value.length > 0) {
+        clearInterval(checkHistory)
+        hasLoadedRouteConversation = true
+        const targetItem = historyData.value.find(item => item.id === routeConversationId)
+        if (targetItem) {
+          handleHistoryItem(targetItem)
+        } else {
+          handleHistoryItem({ id: routeConversationId }) // 降级处理
         }
         }
-      }, 500);
-      
-      // 5秒后自动清除定时器,避免死循环
-      setTimeout(() => clearInterval(checkHistory), 5000);
-    }
+      }
+    }, 500)
+
+    // 5秒后自动清除定时器,避免死循环
+    setTimeout(() => {
+      clearInterval(checkHistory)
+      if (!hasLoadedRouteConversation) {
+        isLoadingRouteConversation.value = false
+      }
+    }, 5000)
   }
   }
 
 
   // 设置初始加载状态
   // 设置初始加载状态
@@ -26111,6 +26146,74 @@ html {
 
 
 }
 }
 
 
+.route-loading-overlay {
+
+  position: absolute;
+
+  inset: 0;
+
+  z-index: 1200;
+
+  display: flex;
+
+  align-items: center;
+
+  justify-content: center;
+
+  background: #EBF3FF;
+
+}
+
+.route-loading-content {
+
+  display: flex;
+
+  flex-direction: column;
+
+  align-items: center;
+
+  gap: 12px;
+
+  text-align: center;
+
+}
+
+.route-loading-spinner {
+
+  width: 40px;
+
+  height: 40px;
+
+  border: 3px solid rgba(64, 158, 255, 0.18);
+
+  border-top-color: #409EFF;
+
+  border-radius: 50%;
+
+  animation: spin 0.9s linear infinite;
+
+}
+
+.route-loading-title {
+
+  color: #2C3E50;
+
+  font-size: 18px;
+
+  font-weight: 600;
+
+}
+
+.route-loading-description {
+
+  color: #6B7280;
+
+  font-size: 14px;
+
+  line-height: 1.5;
+
+}
+
 
 
 
 
 /* 聊天内容区域 */
 /* 聊天内容区域 */

+ 139 - 19
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -161,6 +161,20 @@
                 </div>
                 </div>
             </div>
             </div>
             
             
+                  <!-- 思考过程 -->
+                  <div v-if="message.thinkingContent" class="thinking-panel">
+                    <button class="thinking-panel-header" @click="toggleThinkingPanel(message)">
+                      <div class="thinking-panel-title">
+                        <span class="thinking-panel-badge">{{ message.showThinking !== false ? '已思考' : '思考过程' }}</span>
+                        <span class="thinking-panel-label">模型思考过程</span>
+                      </div>
+                      <span class="thinking-panel-arrow">{{ message.showThinking !== false ? '收起' : '展开' }}</span>
+                    </button>
+                    <div v-show="message.showThinking !== false" class="thinking-panel-body">
+                      <StreamMarkdown :content="message.thinkingContent" :streaming="false" />
+                    </div>
+                  </div>
+
                   <!-- 问题总结 -->
                   <!-- 问题总结 -->
                   <div v-if="message.summary" class="question-summary">
                   <div v-if="message.summary" class="question-summary">
                     <StreamMarkdown :content="message.summary" :streaming="false" />
                     <StreamMarkdown :content="message.summary" :streaming="false" />
@@ -176,9 +190,9 @@
                     </div>
                     </div>
                   </div>
                   </div>
                   
                   
-                  <!-- 报告列表 -->
+              <!-- 报告列表 -->
               <div v-if="message.reports && message.reports.length > 0" class="reports-list">
               <div v-if="message.reports && message.reports.length > 0" class="reports-list">
-                <template v-for="(report, rIndex) in message.reports" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
+                <template v-for="(report, rIndex) in dedupeReportsByFileAndScene(message.reports)" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
                   <!-- 类别标题 -->
                   <!-- 类别标题 -->
                   <CategoryTitle
                   <CategoryTitle
                     v-if="report.type === 'category_title'"
                     v-if="report.type === 'category_title'"
@@ -239,7 +253,6 @@
                       class="action-btn thumbs-up-btn" 
                       class="action-btn thumbs-up-btn" 
                       :class="{ active: message.userFeedback === 'like' }"
                       :class="{ active: message.userFeedback === 'like' }"
                       @click="handleThumbsUp(message)"
                       @click="handleThumbsUp(message)"
-                      :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
                     >
                     >
                       <img :src="likeIcon" alt="点赞" class="action-icon">
                       <img :src="likeIcon" alt="点赞" class="action-icon">
                     </button>
                     </button>
@@ -247,7 +260,6 @@
                       class="action-btn thumbs-down-btn"
                       class="action-btn thumbs-down-btn"
                       :class="{ active: message.userFeedback === 'dislike' }"
                       :class="{ active: message.userFeedback === 'dislike' }"
                       @click="handleThumbsDown(message)"
                       @click="handleThumbsDown(message)"
-                      :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
                     >
                     >
                       <img :src="dislikeIcon" alt="踩" class="action-icon">
                       <img :src="dislikeIcon" alt="踩" class="action-icon">
                     </button>
                     </button>
@@ -471,7 +483,9 @@ import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
   buildAIMessageUpdatePayload,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   extractRelatedQuestions,
   hydratePersistedReports,
   hydratePersistedReports,
   normalizeReportsForPersistence,
   normalizeReportsForPersistence,
@@ -1264,6 +1278,7 @@ const getConversationMessages = async (conversationId) => {
         let displayContent = userContent || ''
         let displayContent = userContent || ''
         let reports = []
         let reports = []
         let summary = message.summary || '' // 从后端恢复summary字段
         let summary = message.summary || '' // 从后端恢复summary字段
+        let thinkingContent = message.thinkingContent || message.thinking_content || ''
         
         
         if (message.type === 'ai') {
         if (message.type === 'ai') {
           try {
           try {
@@ -1289,6 +1304,19 @@ const getConversationMessages = async (conversationId) => {
                   if (parsedContent.summary) {
                   if (parsedContent.summary) {
                     summary = parsedContent.summary
                     summary = parsedContent.summary
                   }
                   }
+                  if (parsedContent.thinkingContent) {
+                    thinkingContent = parsedContent.thinkingContent
+                  }
+                } else if (parsedContent.answer || parsedContent.content || parsedContent.thinkingContent) {
+                  if (parsedContent.thinkingContent) {
+                    thinkingContent = parsedContent.thinkingContent
+                  }
+                  const answerContent = parsedContent.answer || parsedContent.content || ''
+                  const processedContent = String(answerContent)
+                    .replace(/\\n/g, '\n')
+                    .replace(/\\t/g, '\t')
+                    .replace(/\\r/g, '\r')
+                  displayContent = renderMarkdownContent(processedContent)
                 } else if (Array.isArray(parsedContent)) {
                 } else if (Array.isArray(parsedContent)) {
                   // 旧格式,直接是reports数组
                   // 旧格式,直接是reports数组
                   reports = hydratePersistedReports(parsedContent)
                   reports = hydratePersistedReports(parsedContent)
@@ -1338,6 +1366,8 @@ const getConversationMessages = async (conversationId) => {
             displayContent: displayContent,
             displayContent: displayContent,
           reports: reports, // 添加reports数组
           reports: reports, // 添加reports数组
           summary: summary, // 添加summary字段
           summary: summary, // 添加summary字段
+          thinkingContent: thinkingContent,
+          showThinking: Boolean(thinkingContent),
           totalFiles: totalFiles, // 总文件数
           totalFiles: totalFiles, // 总文件数
           completedCount: completedCount, // 完成数
           completedCount: completedCount, // 完成数
           progress: progress, // 进度
           progress: progress, // 进度
@@ -2347,6 +2377,27 @@ const startReportFieldTypewriter = (report, field, fullContent, speed = 50) => {
   })
   })
 }
 }
 
 
+const appendThinkingContent = (aiMessage, sectionTitle, content) => {
+  const normalized = (content || '').trim()
+  if (!normalized) return
+
+  const section = sectionTitle
+    ? `### ${sectionTitle}\n\n${normalized}`
+    : normalized
+
+  if (!aiMessage.thinkingContent) {
+    aiMessage.thinkingContent = section
+  } else if (!aiMessage.thinkingContent.includes(section)) {
+    aiMessage.thinkingContent = `${aiMessage.thinkingContent}\n\n---\n\n${section}`
+  }
+
+  aiMessage.showThinking = true
+}
+
+const toggleThinkingPanel = (message) => {
+  message.showThinking = message.showThinking === false
+}
+
 // SSE消息处理函数
 // SSE消息处理函数
 const handleSSEMessage = (data, aiMessageIndex) => {
 const handleSSEMessage = (data, aiMessageIndex) => {
   const aiMessage = chatMessages.value[aiMessageIndex]
   const aiMessage = chatMessages.value[aiMessageIndex]
@@ -2392,6 +2443,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
   switch (data.type) {
   switch (data.type) {
     case 'intent':
     case 'intent':
       aiMessage.isProfessionalQuestion = data.is_professional_question !== false
       aiMessage.isProfessionalQuestion = data.is_professional_question !== false
+      appendThinkingContent(aiMessage, '意图分析', data.thinking_content)
       // 检查是否为专业问题
       // 检查是否为专业问题
       if (data.is_professional_question === false) {
       if (data.is_professional_question === false) {
         // 非专业问题:立即隐藏状态显示组件
         // 非专业问题:立即隐藏状态显示组件
@@ -2437,6 +2489,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
         aiMessage._fullSummary = ''
       }
       }
+      appendThinkingContent(aiMessage, '正式回答', data.thinking_content)
 
 
       const finalContent = data.content || ''
       const finalContent = data.content || ''
       aiMessage.content = finalContent
       aiMessage.content = finalContent
@@ -2539,8 +2592,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
       break
     
     
     case 'report_chunk':
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       break
       
       
     case 'report':
     case 'report':
@@ -2564,8 +2618,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       
       let targetReport
       let targetReport
       if (idx !== undefined) {
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -2574,12 +2630,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         
         // 创建带空内容的报告对象,保留所有原始字段
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
           report: {
             display_name: fullDisplayName, // 直接显示
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           },
           status: 'completed',
           status: 'completed',
           metadata: {
           metadata: {
@@ -2591,7 +2648,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             summary: fullSummary,
             analysis: fullAnalysis,
             analysis: fullAnalysis,
             clauses: fullClauses
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         }
         targetReport = aiMessage.reports[idx]
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)
         streamingReports.value.delete(reportData.file_index)
@@ -3149,6 +3207,8 @@ const handleReportGeneratorSubmit = async (data) => {
     isTyping: true,
     isTyping: true,
     content: '',
     content: '',
     displayContent: '',
     displayContent: '',
+    thinkingContent: '',
+    showThinking: true,
     timestamp: new Date().toISOString(),
     timestamp: new Date().toISOString(),
     // 新增:状态管理
     // 新增:状态管理
     currentStatus: 'querying_kb', // 当前状态
     currentStatus: 'querying_kb', // 当前状态
@@ -4212,14 +4272,7 @@ const syncFeedbackToBackend = async (message) => {
     
     
     if (response.statusCode === 200) {
     if (response.statusCode === 200) {
     console.log('反馈同步成功')
     console.log('反馈同步成功')
-      // 根据反馈类型显示不同提示
-      if (feedback === 2) {
-        showToastMessage('点赞成功')
-      } else if (feedback === 3) {
-        showToastMessage('点踩成功')
-      } else {
-        showToastMessage('已取消反馈')
-      }
+      showToastMessage(feedback === 0 ? '已取消反馈' : '反馈成功')
     } else {
     } else {
       console.error('反馈同步失败:', response.msg)
       console.error('反馈同步失败:', response.msg)
       showToastMessage('反馈提交失败,请稍后重试', 'error')
       showToastMessage('反馈提交失败,请稍后重试', 'error')
@@ -4578,6 +4631,8 @@ onActivated(async () => {
             font-size: 20px;
             font-size: 20px;
             line-height: 1.4;
             line-height: 1.4;
             word-wrap: break-word;
             word-wrap: break-word;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
           }
           }
         }
         }
         
         
@@ -4730,6 +4785,71 @@ onActivated(async () => {
     line-height: 1.8;
     line-height: 1.8;
     color: #606266;
     color: #606266;
   }
   }
+
+  .thinking-panel {
+    margin-bottom: 12px;
+    background: #f8fafc;
+    border: 1px solid #e5e7eb;
+    border-radius: 8px;
+    overflow: hidden;
+  }
+
+  .thinking-panel-header {
+    width: 100%;
+    padding: 10px 12px;
+    border: none;
+    background: transparent;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    cursor: pointer;
+    text-align: left;
+  }
+
+  .thinking-panel-title {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    min-width: 0;
+  }
+
+  .thinking-panel-badge {
+    flex-shrink: 0;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    padding: 2px 8px;
+    border-radius: 999px;
+    background: #e8f0ff;
+    color: #3b82f6;
+    font-size: 12px;
+    font-weight: 600;
+    line-height: 20px;
+  }
+
+  .thinking-panel-label {
+    color: #374151;
+    font-size: 13px;
+    font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .thinking-panel-arrow {
+    flex-shrink: 0;
+    color: #6b7280;
+    font-size: 12px;
+  }
+
+  .thinking-panel-body {
+    padding: 0 12px 12px;
+    color: #4b5563;
+    font-size: 13px;
+    line-height: 1.7;
+    border-top: 1px solid #eef2f7;
+  }
   
   
   .reports-list {
   .reports-list {
     margin-top: 12px;
     margin-top: 12px;

Неке датотеке нису приказане због велике количине промена