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

@@ -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,