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:
@@ -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"]
|
||||
|
||||
@@ -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**: 詳細的處理過程描述
|
||||
|
||||
@@ -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}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user