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