Sfoglia il codice sorgente

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

# Conflicts:
#	shudao-chat-py/routers/chat.py   resolved by origin/server_test(远端) version
FanHong 3 settimane fa
parent
commit
4b6ef6a81c
41 ha cambiato i file con 2637 aggiunte e 455 eliminazioni
  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
 
 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
 
 
@@ -12,4 +13,4 @@ class PointsConsumptionLog(Base):
     file_url = Column(Text)                                    # 允许为空
     points_consumed = Column(Integer, nullable=False, default=10)  # 默认10积分
     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 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):
@@ -14,6 +23,7 @@ class ReportCompleteFlowRequest(BaseModel):
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     is_network_search_enabled: 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):

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

@@ -9,6 +9,7 @@ from models.total import RecommendQuestion
 from utils.config import settings
 from utils.logger import logger
 from services.qwen_service import qwen_service
+from services.deepseek_service import deepseek_service
 from utils.prompt_loader import load_prompt
 from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
 import time
@@ -171,124 +172,6 @@ def _finalize_related_questions(questions: list, content: str, limit: int = 3) -
     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:
     latest_message = (
         db.query(AIMessage)
@@ -370,6 +253,310 @@ async def _rag_search(message: str, top_k: int = 5) -> str:
     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:
     """从数据库读取最近对话历史,构建 messages 列表"""
     db = SessionLocal()
@@ -460,6 +647,7 @@ async def send_deepseek_message(
             db.commit()
 
         response_text = ""
+        ai_message_id = 0
 
         if data.business_type == 0:
             # AI问答:意图识别 + RAG
@@ -488,8 +676,7 @@ async def send_deepseek_message(
                 ]
 
                 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
 
                 # 兼容模型直接返回 JSON 的场景
@@ -519,94 +706,76 @@ async def send_deepseek_message(
                 else:
                     response_text = answer_text
             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}"
 
         elif data.business_type == 1:
             # PPT大纲生成
             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:
-                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}"
 
         elif data.business_type == 2:
             # AI写作
             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:
-                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}"
 
         elif data.business_type == 3:
@@ -631,8 +800,7 @@ async def send_deepseek_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())
                 user_message = AIMessage(
@@ -671,10 +839,8 @@ async def send_deepseek_message(
                     )
                     db.commit()
             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}"
 
         else:
@@ -918,8 +1084,7 @@ async def stream_chat(request: Request, data: StreamChatRequest):
             thinking_buf = ""
             in_think = 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):
                 buffer += chunk
@@ -942,15 +1107,13 @@ async def stream_chat(request: Request, data: StreamChatRequest):
                         end_idx = lower.find("</think>")
                         if end_idx == -1:
                             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 = ""
                             break
 
                         if max_input_chars and len(thinking_buf) < max_input_chars:
                             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>"):]
                         in_think = False
@@ -1166,8 +1329,7 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                 if rag_context:
                     context_parts.append(f"??????\n{rag_context}")
                 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_parts) if context_parts else "?????????"
@@ -1186,10 +1348,8 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 8. 流式输出并收集完整回复
             full_response = ""
             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 = ""
                 pre_answer = ""
@@ -1218,24 +1378,22 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                                     break
 
                                 pre_answer += buffer[:start_idx]
-                                buffer = buffer[start_idx + len("<think>"):]
+                                buffer = buffer[start_idx + len("<think>") :]
                                 in_think = True
                                 continue
 
                             end_idx = lower.find("</think>")
                             if end_idx == -1:
                                 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 = ""
                                 break
 
                             if max_input_chars and len(thinking_buf) < max_input_chars:
                                 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
                             thinking_done = True
 
@@ -1255,8 +1413,7 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
                             answer_chunk = (pre_answer + buffer).lstrip()
                             if 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"
 
                             pre_answer = ""
@@ -1380,8 +1537,7 @@ async def guess_you_want(
             if not 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)
 

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

@@ -375,7 +375,22 @@ async def hazard(
                 e,
                 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(
             "[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 "",
             )
             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:
             logger.error(
                 "[hazard][%s] YOLO检测失败: scene_key=%r, error=%s, traceback=%s",
@@ -404,7 +464,13 @@ async def hazard(
                 e,
                 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 []
         boxes = yolo_result.get("boxes", []) or []
@@ -421,7 +487,7 @@ async def hazard(
             )
             return {
                 "statusCode": 200,
-                "msg": "识别成功",
+                "msg": "未检测到隐患",
                 "data": {
                     "scene_name": scene_key,
                     "scene_display_name": SCENE_DISPLAY_NAMES.get(scene_key, scene_key),
@@ -436,6 +502,7 @@ async def hazard(
                     "display_labels": [],
                     "third_scenes": [],
                     "element_hazards": {},
+                    "no_hazards_detected": True,
                 },
             }
 
@@ -594,7 +661,18 @@ async def hazard(
             )
             db.rollback()
             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])
 
@@ -638,7 +716,18 @@ async def hazard(
         )
         db.rollback()
         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")

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

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

@@ -2,7 +2,7 @@
 报告兼容路由
 完全对齐 Go 版本的接口实现,保持外部一致性
 """
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, File, Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 import httpx
 import json
@@ -72,6 +72,24 @@ def _build_aichat_complete_flow_body(
     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(
     request_data: ReportCompleteFlowRequest,
     request: Request
@@ -80,7 +98,10 @@ async def fallback_to_local_stream(
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
 
     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,
         business_type=0
     )
@@ -212,6 +233,16 @@ async def complete_flow(request: 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")
 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": "未授权"}
 
     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(
-        "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,
     )
     if not record_id:
@@ -549,14 +552,15 @@ async def delete_recognition_record(
         db.query(RecognitionRecord)
         .filter(
             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())})
     )
     db.commit()
     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,
         affected_rows,
     )
@@ -617,12 +621,13 @@ async def get_latest_recognition_record(
         logger.warning("get_latest_recognition_record unauthorized: missing request.state.user")
         return {"statusCode": 401, "msg": "未授权"}
 
+    user_id = getattr(user, "user_id", None)
     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 = (
         db.query(RecognitionRecord)
         .filter(
-            RecognitionRecord.user_id == _get_user_code(user),
+            RecognitionRecord.user_id == user_id,
             RecognitionRecord.is_deleted == 0,
         )
         .order_by(RecognitionRecord.created_at.desc())
@@ -737,8 +742,9 @@ async def get_recognition_records(
     if page_size > 100:
         page_size = 100
 
+    user_id = getattr(user, "user_id", None)
     query = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == _get_user_code(user),
+        RecognitionRecord.user_id == user_id,
         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 typing import Optional
 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.user_data import UserData
 from services.oss_service import oss_service
 from utils.crypto import decrypt_url
+from utils.config import get_proxy_url
 import time
 import httpx
 
@@ -28,41 +29,48 @@ async def get_recommend_question(db: Session = Depends(get_db)):
 
 @router.get("/get_policy_file")
 async def get_policy_file(
-    policy_type: Optional[int] = None,
+    policy_type: Optional[str] = None,
     page: int = 1,
-    page_size: int = 20,
+    pageSize: int = 20,
+    search: str = "",
     db: Session = Depends(get_db)
 ):
     """获取策略文件列表"""
     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(
-        offset).limit(page_size).all()
+        offset).limit(pageSize).all()
 
     return {
         "statusCode": 200,
         "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):
-    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")
-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:
         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")

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

@@ -4,7 +4,7 @@ AIChat 代理服务
 """
 import httpx
 from typing import AsyncGenerator, Optional
-from fastapi import Request
+from fastapi import Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from utils.config import settings
 from utils.logger import logger
@@ -144,6 +144,52 @@ class AIChatProxy:
                 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:
         """
         检查 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:
-    """将原始URL转换为代理URL"""
+    """将原始URL转换为代理URL(加密)"""
     if not original_url:
         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 }"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             :disabled="loading"
-            @keydown.enter.exact.prevent="handleSubmit"
+            @keydown.enter="handleEnterKey"
             class="message-input"
           />
           
@@ -69,6 +69,7 @@
 <script setup>
 import { reactive, ref } from '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'])
 
@@ -111,6 +112,15 @@ const handleSubmit = () => {
   })
 }
 
+const handleEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSubmit,
+    updateValue: (value) => {
+      form.question = value
+    }
+  })
+}
+
 const handleCancel = () => {
   emit('cancel')
 }

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

@@ -23,6 +23,9 @@ export const apis = {
 
   //上传oss
   uploadOss: (data) => request.post('/oss/upload', data),
+
+  // 解析AI问答上传附件
+  parseAttachment: (data) => request.post('/attachments/parse', data),
   
   // 获取功能卡片
   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) => {
   if (!Array.isArray(reports)) {
     return []
@@ -25,7 +79,55 @@ export const hydratePersistedReports = (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) => {

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

@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'
 import {
   buildAIMessageUpdatePayload,
   buildPersistedAIMessageContent,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
+  applyReportChunkToMessage,
   hydratePersistedReports,
   normalizeReportsForPersistence,
   shouldClearSummaryForOnlineAnswer,
@@ -11,6 +13,40 @@ import {
 } from './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', () => {
     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', () => {
     const payload = buildAIMessageUpdatePayload({
       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/)
+  })
+})

File diff suppressed because it is too large
+ 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) &&
                     backendData.third_scenes.length > 0);
 
+            // 检查是否是"未检测到隐患"的情况
+            const isNoHazardsDetected = response.msg === "未检测到隐患" || 
+                                       backendData.no_hazards_detected === true;
+
             if (!hasDetections && !hasLabels) {
-                ElMessage.warning("未检测到隐患,已完成识别");
+                if (isNoHazardsDetected) {
+                    ElMessage.info("未检测到任何关键要素");
+                } else {
+                    ElMessage.warning("未检测到隐患,已完成识别");
+                }
             } else {
                 ElMessage.success("隐患提示完成!");
             }
@@ -1891,7 +1899,27 @@ const startIdentification = async () => {
             }
         }
     } 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;
     } finally {
         isIdentifying.value = false;

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

@@ -117,6 +117,20 @@
 
       <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聊天界面 -->
 
         <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"
           @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">
 
@@ -2074,6 +2088,11 @@ import Sidebar from '@/components/Sidebar.vue'
 import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
 
 import { apis } from '@/request/apis.js'
+import { buildNewSafetyTrainingTaskRoute } from '@/utils/safetyTrainingNavigation.js'
+import {
+  getSafetyTrainingRouteConversationId,
+  shouldShowSafetyTrainingRouteLoading
+} from '@/utils/safetyTrainingRouteLoading.js'
 
 // ===== 已删除:getUserId - 不再需要,改用token =====
 // import { getUserId } from '@/utils/userManager.js'
@@ -2263,10 +2282,17 @@ const isApplyingTemplate = ref(false) // 控制是否正在应用模板
 
 const isLoadingHistory = ref(false) // 控制是否正在加载历史记录
 
+const isLoadingRouteConversation = ref(false) // 控制从卡片跳转进入旧路由时的中间加载态
+
 const isGeneratingTrainingMaterial = ref(false) // 控制是否正在生成培训讲义
 
 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
 
+    isLoadingRouteConversation.value = false
+
   }
 
 }
@@ -11357,28 +11385,35 @@ onMounted(async () => {
   console.log('🚀 页面初始化开始,优先加载历史记录...')
 
   // 检查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 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">
                     <StreamMarkdown :content="message.summary" :streaming="false" />
@@ -176,9 +190,9 @@
                     </div>
                   </div>
                   
-                  <!-- 报告列表 -->
+              <!-- 报告列表 -->
               <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
                     v-if="report.type === 'category_title'"
@@ -239,7 +253,6 @@
                       class="action-btn thumbs-up-btn" 
                       :class="{ active: message.userFeedback === 'like' }"
                       @click="handleThumbsUp(message)"
-                      :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
                     >
                       <img :src="likeIcon" alt="点赞" class="action-icon">
                     </button>
@@ -247,7 +260,6 @@
                       class="action-btn thumbs-down-btn"
                       :class="{ active: message.userFeedback === 'dislike' }"
                       @click="handleThumbsDown(message)"
-                      :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
                     >
                       <img :src="dislikeIcon" alt="踩" class="action-icon">
                     </button>
@@ -471,7 +483,9 @@ import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   hydratePersistedReports,
   normalizeReportsForPersistence,
@@ -1264,6 +1278,7 @@ const getConversationMessages = async (conversationId) => {
         let displayContent = userContent || ''
         let reports = []
         let summary = message.summary || '' // 从后端恢复summary字段
+        let thinkingContent = message.thinkingContent || message.thinking_content || ''
         
         if (message.type === 'ai') {
           try {
@@ -1289,6 +1304,19 @@ const getConversationMessages = async (conversationId) => {
                   if (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)) {
                   // 旧格式,直接是reports数组
                   reports = hydratePersistedReports(parsedContent)
@@ -1338,6 +1366,8 @@ const getConversationMessages = async (conversationId) => {
             displayContent: displayContent,
           reports: reports, // 添加reports数组
           summary: summary, // 添加summary字段
+          thinkingContent: thinkingContent,
+          showThinking: Boolean(thinkingContent),
           totalFiles: totalFiles, // 总文件数
           completedCount: completedCount, // 完成数
           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消息处理函数
 const handleSSEMessage = (data, aiMessageIndex) => {
   const aiMessage = chatMessages.value[aiMessageIndex]
@@ -2392,6 +2443,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
   switch (data.type) {
     case 'intent':
       aiMessage.isProfessionalQuestion = data.is_professional_question !== false
+      appendThinkingContent(aiMessage, '意图分析', data.thinking_content)
       // 检查是否为专业问题
       if (data.is_professional_question === false) {
         // 非专业问题:立即隐藏状态显示组件
@@ -2437,6 +2489,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
       }
+      appendThinkingContent(aiMessage, '正式回答', data.thinking_content)
 
       const finalContent = data.content || ''
       aiMessage.content = finalContent
@@ -2539,8 +2592,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       
     case 'report':
@@ -2564,8 +2618,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       let targetReport
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -2574,12 +2630,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           status: 'completed',
           metadata: {
@@ -2591,7 +2648,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)
@@ -3149,6 +3207,8 @@ const handleReportGeneratorSubmit = async (data) => {
     isTyping: true,
     content: '',
     displayContent: '',
+    thinkingContent: '',
+    showThinking: true,
     timestamp: new Date().toISOString(),
     // 新增:状态管理
     currentStatus: 'querying_kb', // 当前状态
@@ -4212,14 +4272,7 @@ const syncFeedbackToBackend = async (message) => {
     
     if (response.statusCode === 200) {
     console.log('反馈同步成功')
-      // 根据反馈类型显示不同提示
-      if (feedback === 2) {
-        showToastMessage('点赞成功')
-      } else if (feedback === 3) {
-        showToastMessage('点踩成功')
-      } else {
-        showToastMessage('已取消反馈')
-      }
+      showToastMessage(feedback === 0 ? '已取消反馈' : '反馈成功')
     } else {
       console.error('反馈同步失败:', response.msg)
       showToastMessage('反馈提交失败,请稍后重试', 'error')
@@ -4578,6 +4631,8 @@ onActivated(async () => {
             font-size: 20px;
             line-height: 1.4;
             word-wrap: break-word;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
           }
         }
         
@@ -4730,6 +4785,71 @@ onActivated(async () => {
     line-height: 1.8;
     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 {
     margin-top: 12px;

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