Browse Source

修复AI写作和安全培训(正在修复)

zkn 4 weeks ago
parent
commit
c3ea93bf89

+ 356 - 60
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
@@ -252,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()
@@ -342,6 +647,7 @@ async def send_deepseek_message(
             db.commit()
 
         response_text = ""
+        ai_message_id = 0
 
         if data.business_type == 0:
             # AI问答:意图识别 + RAG
@@ -407,37 +713,32 @@ async def send_deepseek_message(
         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}")
@@ -446,37 +747,32 @@ async def send_deepseek_message(
         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}")

+ 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()

+ 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)
+  })
+})

+ 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)
+  })
+})

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

@@ -55,7 +55,11 @@
       </div>
     </div>
 
-    <div class="chat-main-area">
+    <div
+      ref="chatMainAreaRef"
+      class="chat-main-area"
+      :class="{ 'is-ai-writing-resizing': aiWritingSidebarResizing }"
+    >
       <!-- 右侧AI问答区域 -->
       <div class="main-chat" :class="{ 'sidebar-open': webSearchSidebarVisible }">
       <!-- 聊天头部 -->
@@ -301,18 +305,24 @@
                       </div>
                     </div>
 
-                    <!-- AI写作:状态文字 + 文件卡片 -->
-                    <div v-if="message.isAIWriting" class="ai-writing-block">
+                    <!-- AI写作/安全培训:状态文字 + 文件卡片 -->
+                    <div v-if="message.isAIWriting || message.isSafetyTrainingDocument" class="ai-writing-block">
                       <div class="ai-writing-status-text">
                         {{ message.aiWritingStatusText || 'AI智能助手正在为您输出...' }}
                       </div>
-                      <div class="ai-writing-file-card" @click="openAIWritingSidebar(message)">
-                        <div class="file-card-icon">
-                          <span class="word-icon">W</span>
+                      <div class="ai-writing-file-card" @click="handleGeneratedDocumentCardClick(message)">
+                        <div class="file-card-icon" :class="{ 'safety-file-card-icon': message.isSafetyTrainingDocument }">
+                          <img
+                            v-if="message.isSafetyTrainingDocument"
+                            :src="safetyTrainingCardIcon"
+                            alt="安全培训"
+                            class="safety-card-icon"
+                          >
+                          <span v-else class="word-icon">W</span>
                         </div>
                         <div class="file-card-info">
-                          <div class="file-card-title">{{ message.aiWritingTitle || currentQuestion || 'AI写作文档' }}</div>
-                          <div class="file-card-time">创建于 {{ formatTime(message.timestamp) }}</div>
+                          <div class="file-card-title">{{ getGeneratedDocumentTitle(message) }}</div>
+                          <div class="file-card-time">{{ getGeneratedDocumentCardTime(message) }}</div>
                         </div>
                       </div>
                     </div>
@@ -584,10 +594,42 @@
 
       <!-- AI写作侧边栏 -->
       <transition name="sidebar-slide">
-        <div v-if="aiWritingSidebarVisible" class="ai-writing-sidebar">
+        <div
+          v-if="aiWritingSidebarVisible"
+          class="ai-writing-sidebar"
+          :class="{ 'is-resizing': aiWritingSidebarResizing }"
+          :style="aiWritingSidebarStyle"
+        >
+          <div
+            class="ai-writing-sidebar-resizer"
+            role="separator"
+            aria-orientation="vertical"
+            :aria-valuenow="aiWritingSidebarWidth"
+            :aria-valuemin="AI_WRITING_SIDEBAR_SIZE.min"
+            :aria-valuemax="aiWritingSidebarMaxWidth"
+            tabindex="0"
+            title="Resize"
+            @pointerdown.prevent="startAIWritingSidebarResize"
+            @keydown="handleAIWritingSidebarResizeKeydown"
+          ></div>
           <div class="sidebar-header">
             <h3 class="sidebar-title">{{ aiWritingSidebarTitle }}</h3>
             <div class="sidebar-header-actions">
+              <button
+                v-if="!aiWritingIsGenerating"
+                class="sidebar-save-btn"
+                @click="saveAIWritingSidebarDocument"
+              >
+                保存
+              </button>
+              <button
+                v-if="!aiWritingIsGenerating"
+                class="sidebar-download-btn"
+                @click="downloadAIWritingSidebarDocument"
+                title="下载Word"
+              >
+                下载
+              </button>
               <button class="sidebar-close-btn" @click="closeAIWritingSidebar">
                 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                   <path d="M12 4L4 12M4 4L12 12" stroke="#909399" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@@ -595,17 +637,23 @@
               </button>
             </div>
           </div>
-          <div class="sidebar-toolbar" v-if="!aiWritingIsGenerating">
-            <span class="toolbar-label">正文</span>
-            <span class="toolbar-font-size">15px</span>
-            <div class="toolbar-divider"></div>
-            <button class="toolbar-btn"><b>B</b></button>
-            <button class="toolbar-btn"><s>S</s></button>
-            <button class="toolbar-btn"><u>U</u></button>
-            <button class="toolbar-btn"><i>I</i></button>
-          </div>
           <div class="sidebar-content">
-            <div class="sidebar-doc-content" v-text="aiWritingSidebarContent"></div>
+            <div v-if="!aiWritingIsGenerating" class="ai-writing-rich-editor-container">
+              <Toolbar
+                class="ai-writing-rich-toolbar"
+                :editor="aiWritingEditorRef"
+                :defaultConfig="aiWritingToolbarConfig"
+                :mode="aiWritingEditorMode"
+              />
+              <Editor
+                class="ai-writing-rich-editor"
+                v-model="aiWritingEditorContent"
+                :defaultConfig="aiWritingEditorConfig"
+                :mode="aiWritingEditorMode"
+                @onCreated="handleAIWritingEditorCreated"
+                @onChange="handleAIWritingEditorChange"
+              />
+            </div>
             <div v-if="aiWritingIsGenerating" class="sidebar-generating-hint">
               <span class="generating-text-hint">AI智能助手正在输出</span>
               <span class="generating-dots">
@@ -670,6 +718,8 @@
 <script setup>
 import { ref, nextTick, reactive, triggerRef, markRaw, onMounted, onBeforeUnmount, computed, watch, onActivated } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+import '@wangeditor/editor/dist/css/style.css'
 import Sidebar from '@/components/Sidebar.vue'
 import ExamWorkshop from '@/views/ExamWorkshop.vue'
 import * as mammoth from 'mammoth'
@@ -690,6 +740,17 @@ import {
   shouldClearSummaryForOnlineAnswer,
   splitHtmlIntoTypewriterChunks
 } from '@/utils/chatHistoryPersistence.js'
+import {
+  buildDocumentGenerationRequestMessage,
+  buildDocumentGenerationUserMessage,
+  shouldAttachDocumentToRequest
+} from '@/utils/aiWritingRequest.js'
+import { prepareAIWritingEditorHtml } from '@/utils/aiWritingContent.js'
+import { getGeneratedDocumentCardTime } from '@/utils/generatedDocumentCard.js'
+import {
+  AI_WRITING_SIDEBAR_SIZE,
+  calculateResizableSidebarWidth
+} from '@/utils/resizableSidebar.js'
 import { getToken } from '@/utils/auth.js'
 import { renderMarkdown } from '@/utils/markdown'
 import 'katex/dist/katex.min.css'
@@ -737,6 +798,7 @@ import voiceInputIcon from '@/assets/Chat/18.png'
 import networkSearchIconOn from '@/assets/Chat/24.png'
 import networkSearchIconOff from '@/assets/Chat/25.png'
 import wordDocIcon from '@/assets/Chat/26.png'
+import safetyTrainingCardIcon from '@/assets/Chat/14.png'
 import { Link } from '@element-plus/icons-vue'
 
 import { apis } from '@/request/apis.js'
@@ -1012,25 +1074,299 @@ const webSearchSidebarVisible = ref(false) // 网络搜索侧边栏显示状态
 const aiWritingSidebarVisible = ref(false)
 const aiWritingSidebarTitle = ref('')
 const aiWritingSidebarContent = ref('')
+const aiWritingEditorContent = ref('')
+const aiWritingEditorRef = ref(null)
+const aiWritingActiveMessage = ref(null)
+const aiWritingEditorMode = 'default'
 const aiWritingIsGenerating = ref(false)
 const aiWritingFullContent = ref('') // 保存完整内容,用于点击文件卡片时重新打开
 
-const normalizeAiWritingText = (content) => {
-  if (!content) return ''
-  return String(content).replace(/\r\n/g, '\n')
+const chatMainAreaRef = ref(null)
+const aiWritingSidebarWidth = ref(AI_WRITING_SIDEBAR_SIZE.default)
+const aiWritingSidebarResizing = ref(false)
+let aiWritingSidebarResizeFrame = null
+
+const aiWritingToolbarConfig = {
+  excludeKeys: [
+    'group-video',
+    'group-more-style',
+    'fullScreen',
+    'emotion',
+    'insertLink',
+    'insertImage',
+    'uploadImage',
+    'insertTable',
+    'codeBlock',
+    'divider'
+  ]
+}
+
+const aiWritingEditorConfig = {
+  placeholder: '请输入内容...',
+  readOnly: false,
+  autoFocus: false,
+  scroll: true,
+  hoverbarKeys: {
+    text: { menuKeys: [] },
+    link: { menuKeys: [] },
+    image: { menuKeys: [] },
+    table: { menuKeys: [] },
+    video: { menuKeys: [] }
+  },
+  MENU_CONF: {
+    color: {
+      colors: ['#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff', '#ff0000', '#00ff00', '#0000ff']
+    },
+    fontFamily: {
+      fontFamilyList: ['黑体', '楷体', '仿宋', '微软雅黑', 'Arial', 'Tahoma', 'Verdana']
+    },
+    fontSize: {
+      fontSizeList: ['12px', '13px', '14px', '15px', '16px', '19px', '22px', '24px', '32px', '48px']
+    },
+    uploadImage: {
+      server: '',
+      allowedFileTypes: [],
+      maxFileSize: 0,
+      maxNumberOfFiles: 0,
+      customInsert: () => {},
+      customBrowseAndUpload: () => {},
+      customUpload: () => {}
+    }
+  }
+}
+
+const getAIWritingSidebarResizeRect = () => {
+  return chatMainAreaRef.value?.getBoundingClientRect?.() || {
+    right: window.innerWidth,
+    width: window.innerWidth
+  }
+}
+
+const getAIWritingSidebarMaxWidth = () => {
+  const rect = getAIWritingSidebarResizeRect()
+  return Math.max(
+    AI_WRITING_SIDEBAR_SIZE.min,
+    Math.min(AI_WRITING_SIDEBAR_SIZE.max, rect.width - AI_WRITING_SIDEBAR_SIZE.minMain)
+  )
+}
+
+const clampAIWritingSidebarWidth = (width) => {
+  const rect = getAIWritingSidebarResizeRect()
+  return calculateResizableSidebarWidth({
+    pointerX: rect.right - width,
+    containerRight: rect.right,
+    containerWidth: rect.width,
+    minWidth: AI_WRITING_SIDEBAR_SIZE.min,
+    maxWidth: AI_WRITING_SIDEBAR_SIZE.max,
+    minMainWidth: AI_WRITING_SIDEBAR_SIZE.minMain
+  })
+}
+
+const aiWritingSidebarMaxWidth = computed(() => getAIWritingSidebarMaxWidth())
+
+const aiWritingSidebarStyle = computed(() => ({
+  '--ai-writing-sidebar-width': `${aiWritingSidebarWidth.value}px`,
+  '--ai-writing-sidebar-min-width': `${AI_WRITING_SIDEBAR_SIZE.min}px`,
+  '--ai-writing-sidebar-max-width': `${AI_WRITING_SIDEBAR_SIZE.max}px`
+}))
+
+const applyAIWritingSidebarPointerWidth = (pointerX) => {
+  const rect = getAIWritingSidebarResizeRect()
+  aiWritingSidebarWidth.value = calculateResizableSidebarWidth({
+    pointerX,
+    containerRight: rect.right,
+    containerWidth: rect.width,
+    minWidth: AI_WRITING_SIDEBAR_SIZE.min,
+    maxWidth: AI_WRITING_SIDEBAR_SIZE.max,
+    minMainWidth: AI_WRITING_SIDEBAR_SIZE.minMain
+  })
+}
+
+const stopAIWritingSidebarResize = () => {
+  aiWritingSidebarResizing.value = false
+  if (aiWritingSidebarResizeFrame) {
+    cancelAnimationFrame(aiWritingSidebarResizeFrame)
+    aiWritingSidebarResizeFrame = null
+  }
+  window.removeEventListener('pointermove', handleAIWritingSidebarPointerMove)
+  window.removeEventListener('pointerup', stopAIWritingSidebarResize)
+  window.removeEventListener('pointercancel', stopAIWritingSidebarResize)
+  document.body.style.cursor = ''
+  document.body.style.userSelect = ''
+}
+
+const handleAIWritingSidebarPointerMove = (event) => {
+  if (!aiWritingSidebarResizing.value) return
+  const pointerX = event.clientX
+  if (aiWritingSidebarResizeFrame) {
+    cancelAnimationFrame(aiWritingSidebarResizeFrame)
+  }
+  aiWritingSidebarResizeFrame = requestAnimationFrame(() => {
+    applyAIWritingSidebarPointerWidth(pointerX)
+    aiWritingSidebarResizeFrame = null
+  })
+}
+
+const startAIWritingSidebarResize = (event) => {
+  if (window.innerWidth <= 1200) return
+  aiWritingSidebarResizing.value = true
+  document.body.style.cursor = 'col-resize'
+  document.body.style.userSelect = 'none'
+  applyAIWritingSidebarPointerWidth(event.clientX)
+  window.addEventListener('pointermove', handleAIWritingSidebarPointerMove)
+  window.addEventListener('pointerup', stopAIWritingSidebarResize)
+  window.addEventListener('pointercancel', stopAIWritingSidebarResize)
+}
+
+const handleAIWritingSidebarResizeKeydown = (event) => {
+  if (window.innerWidth <= 1200) return
+
+  if (event.key === 'ArrowLeft') {
+    event.preventDefault()
+    aiWritingSidebarWidth.value = clampAIWritingSidebarWidth(
+      aiWritingSidebarWidth.value + AI_WRITING_SIDEBAR_SIZE.keyboardStep
+    )
+  } else if (event.key === 'ArrowRight') {
+    event.preventDefault()
+    aiWritingSidebarWidth.value = clampAIWritingSidebarWidth(
+      aiWritingSidebarWidth.value - AI_WRITING_SIDEBAR_SIZE.keyboardStep
+    )
+  } else if (event.key === 'Home') {
+    event.preventDefault()
+    aiWritingSidebarWidth.value = AI_WRITING_SIDEBAR_SIZE.min
+  } else if (event.key === 'End') {
+    event.preventDefault()
+    aiWritingSidebarWidth.value = getAIWritingSidebarMaxWidth()
+  }
+}
+
+const syncAIWritingEditorContent = (content) => {
+  const html = prepareAIWritingEditorHtml(content)
+  aiWritingSidebarContent.value = html
+  aiWritingEditorContent.value = html
+
+  nextTick(() => {
+    setTimeout(() => {
+      if (!aiWritingEditorRef.value) return
+      try {
+        aiWritingEditorRef.value.setHtml(html)
+      } catch (error) {
+        console.error('设置AI写作富文本编辑器内容失败:', error)
+      }
+    }, 0)
+  })
+}
+
+const getAIWritingEditorHtml = () => {
+  if (aiWritingEditorRef.value?.getHtml) {
+    try {
+      return aiWritingEditorRef.value.getHtml()
+    } catch (error) {
+      console.error('读取AI写作富文本编辑器内容失败:', error)
+    }
+  }
+  return aiWritingEditorContent.value || aiWritingSidebarContent.value || ''
+}
+
+const handleAIWritingEditorCreated = (editor) => {
+  aiWritingEditorRef.value = editor
+  if (aiWritingSidebarContent.value) {
+    syncAIWritingEditorContent(aiWritingSidebarContent.value)
+  }
+}
+
+const handleAIWritingEditorChange = (editor) => {
+  const html = editor?.getHtml ? editor.getHtml() : aiWritingEditorContent.value
+  aiWritingEditorContent.value = html
+  aiWritingSidebarContent.value = html
+  if (aiWritingActiveMessage.value) {
+    aiWritingActiveMessage.value.fullContent = html
+    aiWritingActiveMessage.value.content = html
+  }
+}
+
+const isSafetyTrainingBusinessType = (businessType) => Number(businessType) === 1
+const isAIWritingBusinessType = (businessType) => Number(businessType) === 2
+
+const getGeneratedDocumentTitle = (message) => {
+  if (message?.aiWritingTitle) return message.aiWritingTitle
+  if (message?.userQuestion) return message.userQuestion
+  if (currentQuestion.value) return currentQuestion.value
+  return message?.isSafetyTrainingDocument ? '安全培训大纲' : 'AI写作文档'
 }
 
 // 关闭AI写作侧边栏
 const closeAIWritingSidebar = () => {
+  aiWritingActiveMessage.value = null
   aiWritingSidebarVisible.value = false
 }
 
 // 打开AI写作侧边栏(点击文件卡片时)
 const openAIWritingSidebar = (message) => {
+  aiWritingActiveMessage.value = message
   aiWritingSidebarTitle.value = message.aiWritingTitle || currentQuestion.value || 'AI写作文档'
-  aiWritingSidebarContent.value = normalizeAiWritingText(message.fullContent || message.content || '')
   aiWritingIsGenerating.value = message.aiWritingStatus === 'generating' || message.isTyping
   aiWritingSidebarVisible.value = true
+  if (window.innerWidth > 1200) {
+    aiWritingSidebarWidth.value = clampAIWritingSidebarWidth(aiWritingSidebarWidth.value)
+  }
+  syncAIWritingEditorContent(message.fullContent || message.content || '')
+}
+
+const saveAIWritingSidebarDocument = async () => {
+  const html = getAIWritingEditorHtml()
+  const message = aiWritingActiveMessage.value
+  const aiMessageId = message?.ai_message_id || message?.rawData?.id
+
+  if (!aiMessageId) {
+    ElMessage.warning('暂无可保存的AI消息ID')
+    return
+  }
+
+  try {
+    const response = await apis.saveEditDocument({
+      ai_message_id: aiMessageId,
+      content: html
+    })
+    if (response.statusCode === 200) {
+      message.fullContent = html
+      message.content = html
+      ElMessage.success('保存成功')
+    } else {
+      throw new Error(response.msg || '保存失败')
+    }
+  } catch (error) {
+    ElMessage.error(`保存失败: ${error.message}`)
+  }
+}
+
+const downloadAIWritingSidebarDocument = () => {
+  const html = getAIWritingEditorHtml()
+  if (!html.trim()) {
+    ElMessage.warning('暂无可下载的内容')
+    return
+  }
+
+  const title = (aiWritingSidebarTitle.value || 'AI写作文档').replace(/[<>:"/\\|?*]/g, '').trim() || 'AI写作文档'
+  const documentHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title></head><body>${html}</body></html>`
+  const blob = new Blob([documentHtml], { type: 'application/msword;charset=utf-8' })
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = `${title}.doc`
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  URL.revokeObjectURL(url)
+}
+
+const handleGeneratedDocumentCardClick = (message) => {
+  if (message?.isSafetyTrainingDocument) {
+    handleSafetyDocClick(message)
+    return
+  }
+
+  openAIWritingSidebar(message)
 }
 
 const currentWebSearchData = ref({
@@ -1614,6 +1950,14 @@ const getConversationMessages = async (conversationId) => {
           completedCount = actualReports.filter(r => r.status === 'completed').length
           progress = totalFiles > 0 ? Math.round((completedCount / totalFiles) * 100) : 100
         }
+
+        const isGeneratedDocumentHistory =
+          message.type === 'ai' &&
+          (currentMode.value === 'ai-writing' || currentMode.value === 'safety-training')
+
+        if (isGeneratedDocumentHistory) {
+          displayContent = ''
+        }
         
         return {
           type: message.type, // 'user' 或 'ai'
@@ -1646,7 +1990,16 @@ const getConversationMessages = async (conversationId) => {
             ? (message.webSearchRaw?.total > 0 
                 ? ` <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${totalFiles}</span> 个知识库文件,以及 <span class="file-count">${message.webSearchRaw.total}</span> 个相关网络资源`
                 : ` <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${totalFiles}</span> 个知识库文件`)
-            : ''
+            : '',
+          isAIWriting: isGeneratedDocumentHistory && currentMode.value === 'ai-writing',
+          isSafetyTrainingDocument: isGeneratedDocumentHistory && currentMode.value === 'safety-training',
+          aiWritingTitle: userQuestion || currentQuestion.value || (currentMode.value === 'safety-training' ? '安全培训大纲' : 'AI写作文档'),
+          aiWritingStatus: isGeneratedDocumentHistory ? 'completed' : undefined,
+          aiWritingStatusText: currentMode.value === 'safety-training'
+            ? '已按要求输出大纲,你可以基于当前结果进一步编辑调整~'
+            : '已按照要求输出文章,你可以基于当前结果进一步编辑整理~',
+          fullContent: message.type === 'ai' ? message.content : '',
+          ai_conversation_id: conversationId
         }
       }))
       
@@ -1927,11 +2280,20 @@ const handleAIWritingStream = async (data) => {
   }
 
   currentQuestion.value = data.question
-
+  const attachedFile = shouldAttachDocumentToRequest(data.businessType) ? selectedFile.value : null
+  const userMessagePayload = shouldAttachDocumentToRequest(data.businessType)
+    ? buildDocumentGenerationUserMessage(data.question, attachedFile)
+    : { content: data.question, file: null }
+  const requestMessage = shouldAttachDocumentToRequest(data.businessType)
+    ? buildDocumentGenerationRequestMessage(data.question, attachedFile)
+    : data.question
+  const isAIWritingMode = isAIWritingBusinessType(data.businessType)
+  const isSafetyTrainingMode = isSafetyTrainingBusinessType(data.businessType)
   chatMessages.value.push({
     id: Date.now(),
     type: 'user',
-    content: data.question,
+    content: userMessagePayload.content,
+    file: userMessagePayload.file,
     timestamp: new Date().toISOString()
   })
 
@@ -1946,17 +2308,20 @@ const handleAIWritingStream = async (data) => {
     displayContent: '',
     timestamp: aiMsgTimestamp,
     reports: [],
-    isAIWriting: true,
+    isAIWriting: isAIWritingMode,
+    isSafetyTrainingDocument: isSafetyTrainingMode,
     aiWritingTitle: data.question,
     aiWritingStatusText: 'AI智能助手正在为您输出...',
     aiWritingStatus: 'generating',
     ai_message_id: null
   })
 
-  aiWritingSidebarTitle.value = data.question
-  aiWritingSidebarContent.value = ''
-  aiWritingIsGenerating.value = true
-  aiWritingSidebarVisible.value = true
+  if (isAIWritingMode) {
+    aiWritingSidebarTitle.value = data.question
+    aiWritingSidebarContent.value = ''
+    aiWritingIsGenerating.value = true
+    aiWritingSidebarVisible.value = true
+  }
 
   const aiMessage = chatMessages.value[aiMessageIndex]
   let fullContent = ''
@@ -1976,7 +2341,7 @@ const handleAIWritingStream = async (data) => {
       method: 'POST',
       headers,
       body: JSON.stringify({
-        message: data.question,
+        message: requestMessage,
         ai_conversation_id: ai_conversation_id.value,
         business_type: data.businessType
       })
@@ -2028,6 +2393,7 @@ const handleAIWritingStream = async (data) => {
           if (parsed.type === 'initial') {
             if (parsed.ai_conversation_id) {
               ai_conversation_id.value = parsed.ai_conversation_id
+              aiMessage.ai_conversation_id = parsed.ai_conversation_id
             }
             if (parsed.ai_message_id) {
               aiMessage.ai_message_id = parsed.ai_message_id
@@ -2046,14 +2412,16 @@ const handleAIWritingStream = async (data) => {
           fullContent += normalizedChunk
           aiMessage.fullContent = fullContent
           aiMessage.content = fullContent
-          aiWritingSidebarContent.value = normalizeAiWritingText(fullContent)
+          aiWritingSidebarContent.value = fullContent
         }
       }
     }
 
     aiMessage.isTyping = false
     aiMessage.aiWritingStatus = 'completed'
-    aiMessage.aiWritingStatusText = '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
+    aiMessage.aiWritingStatusText = isSafetyTrainingMode
+      ? '已按要求输出大纲,你可以基于当前结果进一步编辑调整~'
+      : '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
     aiWritingIsGenerating.value = false
     aiWritingFullContent.value = fullContent
     aiWritingSidebarVisible.value = false
@@ -2065,7 +2433,7 @@ const handleAIWritingStream = async (data) => {
     aiMessage.aiWritingStatus = 'error'
     aiMessage.aiWritingStatusText = '生成失败,请重试'
     aiWritingIsGenerating.value = false
-    aiWritingSidebarContent.value = normalizeAiWritingText(fullContent || '生成失败,请重试。')
+    aiWritingSidebarContent.value = fullContent || '生成失败,请重试。'
     ElMessage.error(`请求失败: ${error.message}`)
   } finally {
     isSending.value = false
@@ -2073,23 +2441,27 @@ const handleAIWritingStream = async (data) => {
 }
 
 const handleNonStreamingSubmit = async (data) => {
-  if (data.businessType === 2) {
-    await handleAIWritingStream(data)
-    return
-  }
-
-
   if (!isSending.value) {
     isSending.value = true
   }
 
   currentQuestion.value = data.question
+  const attachedFile = shouldAttachDocumentToRequest(data.businessType) ? selectedFile.value : null
+  const userMessagePayload = shouldAttachDocumentToRequest(data.businessType)
+    ? buildDocumentGenerationUserMessage(data.question, attachedFile)
+    : { content: data.question, file: null }
+  const requestMessage = shouldAttachDocumentToRequest(data.businessType)
+    ? buildDocumentGenerationRequestMessage(data.question, attachedFile)
+    : data.question
+  const isAIWritingMode = isAIWritingBusinessType(data.businessType)
+  const isSafetyTrainingMode = isSafetyTrainingBusinessType(data.businessType)
   
   // 添加用户消息
   chatMessages.value.push({
     id: Date.now(),
     type: 'user',
-    content: data.question,
+    content: userMessagePayload.content,
+    file: userMessagePayload.file,
     timestamp: new Date().toISOString()
   })
   
@@ -2106,8 +2478,9 @@ const handleNonStreamingSubmit = async (data) => {
     timestamp: aiMsgTimestamp,
     reports: [],
     // AI写作模式下,立即显示文件卡片和状态文字
-    ...(data.businessType === 2 ? {
-      isAIWriting: true,
+    ...(isAIWritingMode || isSafetyTrainingMode ? {
+      isAIWriting: isAIWritingMode,
+      isSafetyTrainingDocument: isSafetyTrainingMode,
       aiWritingTitle: data.question,
       aiWritingStatusText: 'AI智能助手正在为您输出...',
       aiWritingStatus: 'generating'
@@ -2115,16 +2488,19 @@ const handleNonStreamingSubmit = async (data) => {
   })
   
   // AI写作模式:立即打开侧边栏,显示生成中状态
-  if (data.businessType === 2) {
+  if (isAIWritingMode) {
     aiWritingSidebarTitle.value = data.question
     aiWritingSidebarContent.value = ''
     aiWritingIsGenerating.value = true
     aiWritingSidebarVisible.value = true
+  } else if (isSafetyTrainingMode) {
+    aiWritingSidebarVisible.value = false
+    aiWritingIsGenerating.value = false
   }
   
   try {
     const response = await apis.sendDeepseekMessage({
-      message: data.question,
+      message: requestMessage,
       business_type: data.businessType,
       enable_online_model: isOnlineModel.value,
       conversation_id: ai_conversation_id.value
@@ -2145,6 +2521,11 @@ const handleNonStreamingSubmit = async (data) => {
         responseData.conversation_id ||
         response.ai_conversation_id ||
         response.conversation_id
+      const aiMessageId =
+        responseData.ai_message_id ||
+        responseData.message_id ||
+        response.ai_message_id ||
+        response.message_id
       
       // 如果用户已经点击了停止,则不再继续输出
       if (aiMessage._stopped) {
@@ -2161,24 +2542,24 @@ const handleNonStreamingSubmit = async (data) => {
       // 更新 conversation ID
       if (conversationId) {
         ai_conversation_id.value = conversationId
+        aiMessage.ai_conversation_id = conversationId
+      }
+      if (aiMessageId) {
+        aiMessage.ai_message_id = aiMessageId
       }
       
       // 添加打字机效果显示
-      if (data.businessType === 1) {
-        // 安全培训: 只展示一个输出文档
-        aiMessage.isDocument = true
-        aiMessage.displayContent = `<div class="safety-training-doc-card">
-          <div class="doc-header">
-            <span class="doc-icon">📄</span>
-            <span class="doc-title">安全培训生成文档</span>
-          </div>
-          <div class="doc-actions">
-            <button class="view-doc-btn">查看详情</button>
-          </div>
-        </div>`
-        // 实际内容保存起来,点击查看详情时可以使用
+      if (isSafetyTrainingMode) {
+        // 安全培训: 复用AI写作卡片样式,但不打开侧边栏
+        aiMessage.isSafetyTrainingDocument = true
+        aiMessage.isAIWriting = false
+        aiMessage.aiWritingTitle = data.question
         aiMessage.fullContent = aiReply
-      } else if (data.businessType === 2) {
+        aiMessage.displayContent = ''
+        aiMessage.aiWritingStatus = 'completed'
+        aiMessage.aiWritingStatusText = '已按要求输出大纲,你可以基于当前结果进一步编辑调整~'
+        aiMessage.ai_conversation_id = conversationId || ai_conversation_id.value
+      } else if (isAIWritingMode) {
         // AI写作: 显示文件卡片 + 打开侧边栏输出
         aiMessage.isAIWriting = true
         aiMessage.aiWritingTitle = data.question
@@ -2311,10 +2692,25 @@ const isCategoryExpanded = (messageIndex, category) => {
 
 // 处理安全培训文档点击
 const handleSafetyDocClick = (message) => {
-  if (message.rawData && message.rawData.ai_conversation_id) {
-    router.push({ path: '/safety-hazard', query: { id: message.rawData.ai_conversation_id } })
-  } else if (ai_conversation_id.value) {
-    router.push({ path: '/safety-hazard', query: { id: ai_conversation_id.value } })
+  const conversationId =
+    message?.ai_conversation_id ||
+    message?.conversationId ||
+    message?.rawData?.ai_conversation_id ||
+    ai_conversation_id.value
+
+  if (conversationId && (message?.fullContent || message?.content)) {
+    sessionStorage.setItem('safety-training-draft', JSON.stringify({
+      ai_conversation_id: conversationId,
+      ai_message_id: message.ai_message_id || message.rawData?.id || null,
+      title: getGeneratedDocumentTitle(message),
+      content: message.fullContent || message.content || '',
+      userQuestion: message.userQuestion || message.aiWritingTitle || currentQuestion.value || '',
+      timestamp: Date.now()
+    }))
+  }
+
+  if (conversationId) {
+    router.push({ path: '/safety-hazard', query: { id: conversationId } })
   }
 }
 
@@ -3996,7 +4392,6 @@ const handleFileSelect = async (event) => {
       icon: getFileIcon(fileExtension),
       content: extractedContent
     }
-    
     ElMessage.success(`文件读取成功,共提取 ${extractedContent.length} 个字符`)
   } catch (error) {
     console.error('文件读取失败:', error)
@@ -5090,6 +5485,10 @@ const handleBrowserResize = () => {
     
     // 强制浏览器完成layout重排
     document.body.offsetHeight
+
+    if (aiWritingSidebarVisible.value && window.innerWidth > 1200) {
+      aiWritingSidebarWidth.value = clampAIWritingSidebarWidth(aiWritingSidebarWidth.value)
+    }
     
     // 临时解除所有吸附状态,以便重新计算正确位置
     const stickyStates = {}
@@ -5301,6 +5700,13 @@ onBeforeUnmount(() => {
   window.removeEventListener('beforeunload', handlePageUnload)
   window.removeEventListener('unload', handlePageUnload)
   document.removeEventListener('visibilitychange', handleVisibilityChange)
+
+  stopAIWritingSidebarResize()
+
+  if (aiWritingEditorRef.value?.destroy) {
+    aiWritingEditorRef.value.destroy()
+    aiWritingEditorRef.value = null
+  }
 })
 
 // 页面重新激活时,重新渲染所有AI消息的markdown内容
@@ -5359,6 +5765,15 @@ onActivated(async () => {
   min-width: 0;
   height: 100vh;
   overflow: hidden;
+
+  &.is-ai-writing-resizing {
+    cursor: col-resize;
+
+    .main-chat,
+    .ai-writing-sidebar {
+      transition: none;
+    }
+  }
 }
 
 /* 中间历史记录栏 */
@@ -7613,6 +8028,17 @@ onActivated(async () => {
         font-weight: 700;
         font-family: 'Segoe UI', sans-serif;
       }
+
+      &.safety-file-card-icon {
+        background: #EAF7EF;
+        border: 1px solid #B7E2C3;
+      }
+
+      .safety-card-icon {
+        width: 24px;
+        height: 24px;
+        object-fit: contain;
+      }
     }
     
     .file-card-info {
@@ -7640,10 +8066,10 @@ onActivated(async () => {
 /* AI写作侧边栏样式 */
 .ai-writing-sidebar {
   position: relative;
-  flex: 0 0 40%;
-  width: 40%;
-  min-width: 360px;
-  max-width: 520px;
+  flex: 0 0 var(--ai-writing-sidebar-width);
+  width: var(--ai-writing-sidebar-width);
+  min-width: var(--ai-writing-sidebar-min-width);
+  max-width: var(--ai-writing-sidebar-max-width);
   height: 100%;
   background: white;
   box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
@@ -7652,6 +8078,45 @@ onActivated(async () => {
   flex-direction: column;
   overflow: hidden;
   border-left: 1px solid #E4E7ED;
+  transition: flex-basis 0.18s ease, width 0.18s ease;
+
+  &.is-resizing {
+    box-shadow: -8px 0 28px rgba(62, 123, 250, 0.14);
+
+    .ai-writing-sidebar-resizer::after {
+      background: #3E7BFA;
+      box-shadow: 0 0 0 3px rgba(62, 123, 250, 0.12);
+    }
+  }
+
+  .ai-writing-sidebar-resizer {
+    position: absolute;
+    left: -5px;
+    top: 0;
+    bottom: 0;
+    width: 10px;
+    z-index: 3;
+    cursor: col-resize;
+    outline: none;
+
+    &::after {
+      content: '';
+      position: absolute;
+      left: 4px;
+      top: 16px;
+      bottom: 16px;
+      width: 2px;
+      border-radius: 999px;
+      background: transparent;
+      transition: background 0.18s ease, box-shadow 0.18s ease;
+    }
+
+    &:hover::after,
+    &:focus-visible::after {
+      background: #3E7BFA;
+      box-shadow: 0 0 0 3px rgba(62, 123, 250, 0.12);
+    }
+  }
   
   .sidebar-header {
     display: flex;
@@ -7694,6 +8159,36 @@ onActivated(async () => {
           background: #E4E7ED;
         }
       }
+
+      .sidebar-save-btn,
+      .sidebar-download-btn {
+        height: 32px;
+        padding: 0 12px;
+        border: 1px solid #D9E2F2;
+        background: #fff;
+        color: #3E7BFA;
+        border-radius: 6px;
+        cursor: pointer;
+        font-size: 13px;
+        font-weight: 500;
+        transition: all 0.2s ease;
+
+        &:hover {
+          background: #EEF4FF;
+          border-color: #BFD3FF;
+        }
+      }
+
+      .sidebar-save-btn {
+        background: #3E7BFA;
+        border-color: #3E7BFA;
+        color: #fff;
+
+        &:hover {
+          background: #2F66D8;
+          border-color: #2F66D8;
+        }
+      }
     }
   }
   
@@ -7747,38 +8242,54 @@ onActivated(async () => {
   .sidebar-content {
     flex: 1;
     overflow-y: auto;
-    padding: 24px;
+    padding: 0;
     
-    .sidebar-doc-content {
-      font-size: 15px;
-      line-height: 1.8;
-      color: #303133;
-      white-space: pre-wrap;
-      word-break: break-word;
-      
-      :deep(h1), :deep(h2), :deep(h3) {
-        margin: 16px 0 8px 0;
-        color: #1F2937;
+    .ai-writing-rich-editor-container {
+      height: 100%;
+      min-height: 0;
+      display: flex;
+      flex-direction: column;
+      background: #fff;
+
+      .ai-writing-rich-toolbar {
+        border-bottom: 1px solid #EEF0F4;
+        padding: 0 12px;
+        flex-shrink: 0;
       }
-      
-      :deep(p) {
-        margin: 8px 0;
+
+      .ai-writing-rich-editor {
+        flex: 1;
+        min-height: 0;
+        overflow: hidden;
       }
-      
-      :deep(ul), :deep(ol) {
-        padding-left: 24px;
-        margin: 8px 0;
+
+      :deep(.w-e-text-container) {
+        height: calc(100vh - 154px) !important;
+        background: #fff;
       }
-      
-      :deep(li) {
-        margin: 4px 0;
+
+      :deep(.w-e-scroll) {
+        min-height: 100%;
+      }
+
+      :deep(.w-e-text) {
+        padding: 24px 28px 40px 28px;
+        font-size: 15px;
+        line-height: 1.8;
+        color: #303133;
+      }
+
+      :deep(.w-e-text h1),
+      :deep(.w-e-text h2),
+      :deep(.w-e-text h3) {
+        color: #1F2937;
       }
     }
     
     .sidebar-generating-hint {
       display: flex;
       align-items: center;
-      margin-top: 24px;
+      margin: 24px;
       color: #3E7BFA;
       font-size: 14px;
       
@@ -7815,6 +8326,10 @@ onActivated(async () => {
     height: 45vh;
     border-left: none;
     border-top: 1px solid #E4E7ED;
+
+    .ai-writing-sidebar-resizer {
+      display: none;
+    }
   }
 }
 

+ 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;
+
+}
+
 
 
 /* 聊天内容区域 */