feat: Add Chat UX improvements with notifications and @mention support

- Add ActionBar component with expandable toolbar for mobile
- Add @mention functionality with autocomplete dropdown
- Add browser notification system (push, sound, vibration)
- Add NotificationSettings modal for user preferences
- Add mention badges on room list cards
- Add ReportPreview with Markdown rendering and copy/download
- Add message copy functionality with hover actions
- Add backend mentions field to messages with Alembic migration
- Add lots field to rooms, remove templates
- Optimize WebSocket database session handling
- Various UX polish (animations, accessibility)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 08:20:37 +08:00
parent 92834dbe0e
commit 599802b818
72 changed files with 6810 additions and 702 deletions

View File

@@ -3,6 +3,6 @@
AI-powered incident report generation using DIFY service.
"""
from app.modules.report_generation.models import GeneratedReport, ReportStatus
from app.modules.report_generation.router import router
from app.modules.report_generation.router import router, health_router
__all__ = ["GeneratedReport", "ReportStatus", "router"]
__all__ = ["GeneratedReport", "ReportStatus", "router", "health_router"]

View File

@@ -89,6 +89,10 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
elif resolved_at is None:
resolved_at = "尚未解決"
# Format LOT batch numbers
lots = room_data.get('lots', [])
lots_str = ", ".join(lots) if lots else ""
lines = [
"## 事件資訊",
f"- 標題: {room_data.get('title', '未命名')}",
@@ -96,6 +100,7 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
f"- 嚴重程度: {severity}",
f"- 目前狀態: {status}",
f"- 發生地點: {room_data.get('location', '未指定')}",
f"- 影響批號 (LOT): {lots_str}",
f"- 建立時間: {created_at}",
f"- 解決時間: {resolved_at}",
]
@@ -189,7 +194,7 @@ def _format_instructions() -> str:
請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位:
1. **summary**: 事件摘要 (50-100字)
1. **summary**: 事件摘要 (50-300字)
2. **timeline**: 按時間順序的事件時間軸
3. **participants**: 參與人員及其角色
4. **resolution_process**: 詳細的處理過程描述

View File

@@ -7,6 +7,7 @@ FastAPI router with all report-related endpoints:
- GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx
"""
import logging
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -64,6 +65,33 @@ async def _broadcast_report_progress(
await ws_manager.broadcast_to_room(room_id, payload)
router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"])
health_router = APIRouter(prefix="/api/reports", tags=["Report Generation"])
@health_router.get("/health", response_model=schemas.HealthCheckResponse)
async def check_dify_health():
"""Check DIFY AI service connection status
Returns:
Health check result with status and message
"""
if not settings.DIFY_API_KEY:
return schemas.HealthCheckResponse(
status="error",
message="DIFY_API_KEY 未設定,請聯繫系統管理員"
)
try:
result = await dify_service.test_connection()
return schemas.HealthCheckResponse(
status=result["status"],
message=result["message"]
)
except DifyAPIError as e:
return schemas.HealthCheckResponse(
status="error",
message=str(e)
)
@router.post("/generate", response_model=schemas.ReportGenerateResponse, status_code=status.HTTP_202_ACCEPTED)
@@ -202,6 +230,75 @@ async def get_report_status(
)
@router.get("/{report_id}/markdown", response_model=schemas.ReportMarkdownResponse)
async def get_report_markdown(
room_id: str,
report_id: str,
db: Session = Depends(get_db),
room_context: dict = Depends(require_room_member),
):
"""Get report content as Markdown for in-page preview
Args:
room_id: Room ID
report_id: Report ID to get markdown for
Returns:
Markdown formatted report content
"""
report = (
db.query(GeneratedReport)
.filter(
GeneratedReport.report_id == report_id,
GeneratedReport.room_id == room_id,
)
.first()
)
if not report:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report not found",
)
if report.status != ReportStatus.COMPLETED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Report is not ready. Status: {report.status}",
)
if not report.report_json:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report content not found",
)
# Collect room data for markdown generation
data_service = ReportDataService(db)
room_data = data_service.collect_room_data(room_id)
if not room_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
prompt_data = data_service.to_prompt_dict(room_data)
# Generate markdown
markdown_content = docx_service.to_markdown(
room_data=prompt_data["room_data"],
ai_content=report.report_json,
files=prompt_data["files"],
)
return schemas.ReportMarkdownResponse(
report_id=report.report_id,
report_title=report.report_title,
markdown=markdown_content,
)
@router.get("/{report_id}/download")
async def download_report(
room_id: str,
@@ -262,11 +359,16 @@ async def download_report(
filename = report.report_title or f"report_{report.report_id[:8]}"
filename = f"{filename}.docx"
# Use RFC 5987 format for non-ASCII filenames
# Provide ASCII fallback for older clients + UTF-8 encoded version
ascii_filename = f"report_{report.report_id[:8]}.docx"
encoded_filename = quote(filename)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}",
},
)

View File

@@ -67,6 +67,13 @@ class ReportListResponse(BaseModel):
total: int
class ReportMarkdownResponse(BaseModel):
"""Report content in Markdown format for in-page preview"""
report_id: str
report_title: Optional[str] = None
markdown: str = Field(..., description="Full report content in Markdown format")
# AI Report Content Schemas (validated JSON from DIFY)
class TimelineEvent(BaseModel):
"""Single event in timeline"""
@@ -103,3 +110,10 @@ class AIReportContent(BaseModel):
class ErrorResponse(BaseModel):
"""Error response"""
detail: str
# Health Check Response
class HealthCheckResponse(BaseModel):
"""DIFY service health check response"""
status: str = Field(..., description="Status: 'ok' or 'error'")
message: str = Field(..., description="Human-readable status message")

View File

@@ -252,6 +252,37 @@ class DifyService:
"final_resolution section missing 'content' field when has_resolution is true"
)
async def test_connection(self) -> Dict[str, Any]:
"""Test connection to DIFY API
Returns:
Dict with status and message
Raises:
DifyAPIError: If connection or API fails
"""
if not self.api_key:
raise DifyAPIError("DIFY_API_KEY 未設定,請聯繫系統管理員")
# Send a simple test query
url = f"{self.base_url}/parameters"
headers = {
"Authorization": f"Bearer {self.api_key}",
}
try:
response = await self._client.get(url, headers=headers)
response.raise_for_status()
return {"status": "ok", "message": "AI 服務連線正常"}
except httpx.TimeoutException:
raise DifyAPIError("無法連接 AI 服務,請求逾時")
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise DifyAPIError("DIFY API Key 無效,請檢查設定")
raise DifyAPIError(f"AI 服務回應錯誤: {e.response.status_code}")
except httpx.RequestError as e:
raise DifyAPIError(f"無法連接 AI 服務: {str(e)}")
async def close(self):
"""Close HTTP client"""
await self._client.aclose()

View File

@@ -140,7 +140,7 @@ class DocxAssemblyService:
def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]):
"""Add metadata summary table"""
table = doc.add_table(rows=4, cols=4)
table = doc.add_table(rows=5, cols=4)
table.style = "Table Grid"
# Row 1: Type and Severity
@@ -178,8 +178,15 @@ class DocxAssemblyService:
else:
cells[3].text = "尚未解決"
# Row 4: Description (spanning all columns)
# Row 4: LOT batch numbers (spanning all columns)
cells = table.rows[3].cells
cells[0].text = "影響批號"
cells[1].merge(cells[3])
lots = room_data.get("lots", [])
cells[1].text = ", ".join(lots) if lots else ""
# Row 5: Description (spanning all columns)
cells = table.rows[4].cells
cells[0].text = "事件描述"
# Merge remaining cells for description
cells[1].merge(cells[3])
@@ -401,6 +408,183 @@ class DocxAssemblyService:
logger.error(f"Failed to download file from MinIO: {object_path} - {e}")
return None
def to_markdown(
self,
room_data: Dict[str, Any],
ai_content: Dict[str, Any],
files: List[Dict[str, Any]] = None,
) -> str:
"""Convert report content to Markdown format
Args:
room_data: Room metadata (title, type, severity, status, etc.)
ai_content: AI-generated content (summary, timeline, participants, etc.)
files: List of files with metadata (optional)
Returns:
Markdown formatted string
"""
lines = []
# Title
title = room_data.get("title", "未命名事件")
lines.append(f"# 事件報告:{title}")
lines.append("")
# Generation timestamp
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
lines.append(f"*報告產生時間:{timestamp}*")
lines.append("")
# Metadata section
lines.append("## 基本資訊")
lines.append("")
lines.append("| 項目 | 內容 |")
lines.append("|------|------|")
incident_type = room_data.get("incident_type", "other")
lines.append(f"| 事件類型 | {self.INCIDENT_TYPE_MAP.get(incident_type, incident_type)} |")
severity = room_data.get("severity", "medium")
lines.append(f"| 嚴重程度 | {self.SEVERITY_MAP.get(severity, severity)} |")
status = room_data.get("status", "active")
lines.append(f"| 目前狀態 | {self.STATUS_MAP.get(status, status)} |")
lines.append(f"| 發生地點 | {room_data.get('location') or '未指定'} |")
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |")
else:
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime):
lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |")
elif resolved_at:
lines.append(f"| 解決時間 | {str(resolved_at)} |")
else:
lines.append("| 解決時間 | 尚未解決 |")
lots = room_data.get("lots", [])
lines.append(f"| 影響批號 | {', '.join(lots) if lots else ''} |")
lines.append("")
# Description
description = room_data.get("description")
if description:
lines.append("**事件描述:**")
lines.append(f"> {description}")
lines.append("")
# Summary section
lines.append("## 事件摘要")
lines.append("")
summary = ai_content.get("summary", {})
summary_content = summary.get("content", "無摘要內容")
lines.append(summary_content)
lines.append("")
# Timeline section
lines.append("## 事件時間軸")
lines.append("")
timeline = ai_content.get("timeline", {})
events = timeline.get("events", [])
if events:
lines.append("| 時間 | 事件 |")
lines.append("|------|------|")
for event in events:
time = event.get("time", "")
desc = event.get("description", "")
# Escape pipe characters in content
desc = desc.replace("|", "\\|")
lines.append(f"| {time} | {desc} |")
else:
lines.append("無時間軸記錄")
lines.append("")
# Participants section
lines.append("## 參與人員")
lines.append("")
participants = ai_content.get("participants", {})
members = participants.get("members", [])
if members:
lines.append("| 姓名 | 角色 |")
lines.append("|------|------|")
for member in members:
name = member.get("name", "")
role = member.get("role", "")
lines.append(f"| {name} | {role} |")
else:
lines.append("無參與人員記錄")
lines.append("")
# Resolution process section
lines.append("## 處理過程")
lines.append("")
resolution = ai_content.get("resolution_process", {})
resolution_content = resolution.get("content", "無處理過程記錄")
lines.append(resolution_content)
lines.append("")
# Current status section
lines.append("## 目前狀態")
lines.append("")
current_status = ai_content.get("current_status", {})
cs_status = current_status.get("status", "unknown")
cs_text = self.STATUS_MAP.get(cs_status, cs_status)
cs_description = current_status.get("description", "")
lines.append(f"**狀態:** {cs_text}")
if cs_description:
lines.append("")
lines.append(cs_description)
lines.append("")
# Final resolution section
lines.append("## 最終處置結果")
lines.append("")
final = ai_content.get("final_resolution", {})
has_resolution = final.get("has_resolution", False)
final_content = final.get("content", "")
if has_resolution:
if final_content:
lines.append(final_content)
else:
lines.append("事件已解決,但無詳細說明。")
else:
lines.append("事件尚未解決或無最終處置結果。")
lines.append("")
# File list section
if files:
lines.append("## 附件清單")
lines.append("")
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |")
lines.append("|----------|------|--------|----------|")
file_type_map = {
"image": "圖片",
"document": "文件",
"log": "記錄檔",
}
for f in files:
filename = f.get("filename", "")
file_type = f.get("file_type", "file")
type_text = file_type_map.get(file_type, file_type)
uploader = f.get("uploader_name") or f.get("uploader_id", "")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M")
else:
uploaded_text = str(uploaded_at) if uploaded_at else ""
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |")
lines.append("")
return "\n".join(lines)
def upload_report(
self,
report_data: io.BytesIO,

View File

@@ -62,6 +62,7 @@ class RoomReportData:
location: Optional[str]
description: Optional[str]
resolution_notes: Optional[str]
lots: List[str] # Affected LOT batch numbers
created_at: datetime
resolved_at: Optional[datetime]
created_by: str
@@ -111,6 +112,7 @@ class ReportDataService:
location=room.location,
description=room.description,
resolution_notes=room.resolution_notes,
lots=room.lots or [], # LOT batch numbers (JSON array)
created_at=room.created_at,
resolved_at=room.resolved_at,
created_by=room.created_by,
@@ -214,6 +216,7 @@ class ReportDataService:
"location": data.location,
"description": data.description,
"resolution_notes": data.resolution_notes,
"lots": data.lots, # Affected LOT batch numbers
"created_at": data.created_at,
"resolved_at": data.resolved_at,
"created_by": data.created_by,