back
This commit is contained in:
19
.env
Normal file
19
.env
Normal file
@@ -0,0 +1,19 @@
|
||||
# Dify API Base URL (Common for all apps)
|
||||
DIFY_API_BASE_URL="https://dify.theaken.com/v1"
|
||||
|
||||
# --- Dify API Keys for Specific Apps ---
|
||||
DIFY_TRANSLATOR_API_KEY="app-YOPrF2ro5fshzMkCZviIuUJd"
|
||||
DIFY_SUMMARIZER_API_KEY="app-oFptWFRlSgvwhJ8DzZKN08a0"
|
||||
DIFY_ACTION_EXTRACTOR_API_KEY="app-UHU5IrVcwE0nVvgzubpGRqym"
|
||||
DIFY_STT_API_KEY="app-xQeSipaQecs0cuKeLvYDaRsu"
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL="redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND="redis://localhost:6379/0"
|
||||
|
||||
# Flask App Configuration
|
||||
FLASK_RUN_PORT=12000
|
||||
|
||||
# Database and JWT Configuration
|
||||
DATABASE_URL="mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060"
|
||||
JWT_SECRET_KEY="your-super-secret-key-that-no-one-should-know"
|
BIN
__pycache__/action_item_routes.cpython-312.pyc
Normal file
BIN
__pycache__/action_item_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ai_routes.cpython-312.pyc
Normal file
BIN
__pycache__/ai_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/api_routes.cpython-312.pyc
Normal file
BIN
__pycache__/api_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/celery_app.cpython-312.pyc
Normal file
BIN
__pycache__/celery_app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/celery_worker.cpython-312.pyc
Normal file
BIN
__pycache__/celery_worker.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-312.pyc
Normal file
BIN
__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/tasks.cpython-312.pyc
Normal file
BIN
__pycache__/tasks.cpython-312.pyc
Normal file
Binary file not shown.
112
action_item_routes.py
Normal file
112
action_item_routes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# action_item_routes.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
from datetime import date
|
||||
from models import db, User, Meeting, ActionItem
|
||||
|
||||
action_bp = Blueprint("action_bp", __name__, url_prefix="/api")
|
||||
|
||||
def _parse_date(s: str | None):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(s)[:10])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _resolve_owner_id(owner_val: str | None):
|
||||
"""把 'owner'(使用者名稱字串) 轉到 ms_users.id,查不到就回 None。"""
|
||||
if not owner_val:
|
||||
return None
|
||||
owner_val = str(owner_val).strip()
|
||||
if not owner_val:
|
||||
return None
|
||||
user = User.query.filter_by(username=owner_val).first()
|
||||
return user.id if user else None
|
||||
|
||||
@action_bp.post("/action-items")
|
||||
@jwt_required()
|
||||
def create_action_item():
|
||||
"""
|
||||
建立單筆代辦(會議詳情頁用)
|
||||
允許欄位:meeting_id(必) / item(或 context) / action(必) / owner_id or owner(使用者名) / due_date or duedate / status
|
||||
"""
|
||||
data = request.get_json(force=True) or {}
|
||||
meeting_id = data.get("meeting_id")
|
||||
if not meeting_id:
|
||||
return jsonify({"error": "meeting_id is required"}), 400
|
||||
meeting = Meeting.query.get(meeting_id)
|
||||
if not meeting:
|
||||
return jsonify({"error": "meeting not found"}), 404
|
||||
|
||||
action_text = (data.get("action") or "").strip()
|
||||
if not action_text:
|
||||
return jsonify({"error": "action is required"}), 400
|
||||
|
||||
item_text = (data.get("item") or data.get("context") or "").strip() or None
|
||||
owner_id = data.get("owner_id")
|
||||
if owner_id is None:
|
||||
owner_id = _resolve_owner_id(data.get("owner"))
|
||||
due = _parse_date(data.get("due_date") or data.get("duedate"))
|
||||
status = (data.get("status") or "pending").strip() or "pending"
|
||||
|
||||
try:
|
||||
ai = ActionItem(
|
||||
meeting_id=meeting_id,
|
||||
item=item_text,
|
||||
action=action_text,
|
||||
owner_id=owner_id,
|
||||
due_date=due,
|
||||
status=status,
|
||||
)
|
||||
db.session.add(ai)
|
||||
db.session.commit()
|
||||
return jsonify(ai.to_dict()), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": f"create failed: {e}"}), 400
|
||||
|
||||
@action_bp.post("/meetings/<int:meeting_id>/action-items/batch")
|
||||
@jwt_required()
|
||||
def batch_create_action_items(meeting_id: int):
|
||||
"""
|
||||
批次建立代辦(AI 預覽 → 一鍵儲存)
|
||||
Request body: { "items": [ {item/context, action*, owner/owner_id, due_date/duedate}, ... ] }
|
||||
"""
|
||||
payload = request.get_json(force=True) or {}
|
||||
items = payload.get("items") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
return jsonify({"error": "items must be a non-empty array"}), 400
|
||||
|
||||
meeting = Meeting.query.get(meeting_id)
|
||||
if not meeting:
|
||||
return jsonify({"error": "meeting not found"}), 404
|
||||
|
||||
created = []
|
||||
try:
|
||||
for r in items:
|
||||
action_text = (r.get("action") or "").strip()
|
||||
if not action_text:
|
||||
continue # 沒有 action 的略過
|
||||
|
||||
item_text = (r.get("item") or r.get("context") or "").strip() or None
|
||||
owner_id = r.get("owner_id")
|
||||
if owner_id is None:
|
||||
owner_id = _resolve_owner_id(r.get("owner"))
|
||||
due = _parse_date(r.get("due_date") or r.get("duedate"))
|
||||
|
||||
ai = ActionItem(
|
||||
meeting_id=meeting_id,
|
||||
item=item_text,
|
||||
action=action_text,
|
||||
owner_id=owner_id,
|
||||
due_date=due,
|
||||
status="pending",
|
||||
)
|
||||
db.session.add(ai)
|
||||
created.append(ai)
|
||||
db.session.commit()
|
||||
return jsonify([c.to_dict() for c in created]), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": f"batch create failed: {e}"}), 400
|
40
ai_routes.py
Normal file
40
ai_routes.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# ai_routes.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from services.dify_client import translate_text as _translate_text, summarize_text as _summarize_text, extract_action_items as _extract_action_items
|
||||
|
||||
ai_bp = Blueprint("ai_bp", __name__, url_prefix="/api")
|
||||
|
||||
@ai_bp.post("/translate/text")
|
||||
@jwt_required()
|
||||
def translate_text_api():
|
||||
data = request.get_json(force=True) or {}
|
||||
text = (data.get("text") or "").strip()
|
||||
target = (data.get("target_lang") or "繁體中文").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
user_id = str(get_jwt_identity() or "user")
|
||||
translated = _translate_text(text, target, user_id=user_id)
|
||||
return jsonify({"translated": translated})
|
||||
|
||||
@ai_bp.post("/summarize/text")
|
||||
@jwt_required()
|
||||
def summarize_text_api():
|
||||
data = request.get_json(force=True) or {}
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
user_id = str(get_jwt_identity() or "user")
|
||||
summary = _summarize_text(text, user_id=user_id)
|
||||
return jsonify({"summary": summary})
|
||||
|
||||
@ai_bp.post("/action-items/preview")
|
||||
@jwt_required()
|
||||
def preview_action_items_api():
|
||||
data = request.get_json(force=True) or {}
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
user_id = str(get_jwt_identity() or "user")
|
||||
items = _extract_action_items(text, user_id=user_id)
|
||||
return jsonify({"items": items})
|
349
api_routes.py
Normal file
349
api_routes.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import os
|
||||
import uuid
|
||||
from flask import request, jsonify, send_from_directory, Blueprint, current_app
|
||||
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity, get_jwt
|
||||
from werkzeug.utils import secure_filename
|
||||
from datetime import datetime
|
||||
|
||||
from models import db, User, Meeting, ActionItem
|
||||
from tasks import (
|
||||
celery,
|
||||
extract_audio_task,
|
||||
transcribe_audio_task,
|
||||
translate_text_task,
|
||||
summarize_text_task
|
||||
)
|
||||
|
||||
api_bp = Blueprint("api_bp", __name__, url_prefix="/api")
|
||||
|
||||
def save_uploaded_file(upload_folder, file_key='file'):
|
||||
if file_key not in request.files:
|
||||
return None, (jsonify({'error': 'Request does not contain a file part'}), 400)
|
||||
file = request.files[file_key]
|
||||
if file.filename == '':
|
||||
return None, (jsonify({'error': 'No file selected'}), 400)
|
||||
if file:
|
||||
original_filename = secure_filename(file.filename)
|
||||
file_extension = os.path.splitext(original_filename)[1]
|
||||
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
file_path = os.path.join(upload_folder, unique_filename)
|
||||
file.save(file_path)
|
||||
return file_path, None
|
||||
return None, (jsonify({'error': 'Unknown file error'}), 500)
|
||||
|
||||
# --- User Authentication & Admin Routes ---
|
||||
@api_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
user = User.query.filter_by(username=data.get('username')).first()
|
||||
if user and user.check_password(data.get('password')):
|
||||
access_token = create_access_token(identity=str(user.id), additional_claims={'role': user.role, 'username': user.username})
|
||||
return jsonify(access_token=access_token)
|
||||
return jsonify({"msg": "Bad username or password"}), 401
|
||||
|
||||
@api_bp.route('/register', methods=['POST'])
|
||||
def register():
|
||||
"""Public endpoint for new user registration."""
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Username and password are required"}), 400
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({"error": "Username already exists"}), 409 # HTTP 409 Conflict
|
||||
|
||||
try:
|
||||
new_user = User(username=username, role='user') # Default role is 'user'
|
||||
new_user.set_password(password)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "User created successfully"}), 201 # HTTP 201 Created
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error creating user: {e}")
|
||||
return jsonify({"error": "An internal error occurred"}), 500
|
||||
|
||||
@api_bp.route('/admin/users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_all_users():
|
||||
if get_jwt().get('role') != 'admin':
|
||||
return jsonify({"msg": "Administration rights required"}), 403
|
||||
users = User.query.all()
|
||||
return jsonify([user.to_dict() for user in users])
|
||||
|
||||
@api_bp.route('/users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_all_users_for_dropdown():
|
||||
"""A public endpoint for all logged-in users to fetch a list of users for UI selectors."""
|
||||
users = User.query.all()
|
||||
return jsonify([user.to_dict() for user in users])
|
||||
|
||||
@api_bp.route('/admin/users', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_user():
|
||||
if get_jwt().get('role') != 'admin':
|
||||
return jsonify({"msg": "Administration rights required"}), 403
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
role = data.get('role', 'user')
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Username and password are required"}), 400
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
|
||||
new_user = User(username=username, role=role)
|
||||
new_user.set_password(password)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
return jsonify(new_user.to_dict()), 201
|
||||
|
||||
@api_bp.route('/admin/users/<int:user_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_user(user_id):
|
||||
if get_jwt().get('role') != 'admin':
|
||||
return jsonify({"msg": "Administration rights required"}), 403
|
||||
|
||||
# Prevent admin from deleting themselves
|
||||
if str(user_id) == get_jwt_identity():
|
||||
return jsonify({"error": "Admin users cannot delete their own account"}), 400
|
||||
|
||||
user_to_delete = User.query.get(user_id)
|
||||
if not user_to_delete:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
# Disassociate meetings created by this user
|
||||
Meeting.query.filter_by(created_by_id=user_id).update({"created_by_id": None})
|
||||
|
||||
# Disassociate action items owned by this user
|
||||
ActionItem.query.filter_by(owner_id=user_id).update({"owner_id": None})
|
||||
|
||||
db.session.delete(user_to_delete)
|
||||
db.session.commit()
|
||||
return jsonify({"msg": f"User {user_to_delete.username} has been deleted."}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": f"An error occurred: {str(e)}"}), 500
|
||||
|
||||
@api_bp.route('/admin/users/<int:user_id>/password', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_user_password(user_id):
|
||||
if get_jwt().get('role') != 'admin':
|
||||
return jsonify({"msg": "Administration rights required"}), 403
|
||||
|
||||
user_to_update = User.query.get(user_id)
|
||||
if not user_to_update:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json()
|
||||
password = data.get('password')
|
||||
if not password:
|
||||
return jsonify({"error": "Password is required"}), 400
|
||||
|
||||
try:
|
||||
user_to_update.set_password(password)
|
||||
db.session.commit()
|
||||
return jsonify({"msg": f"Password for user {user_to_update.username} has been updated."}), 200
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error updating password for user {user_id}: {e}")
|
||||
return jsonify({"error": "An internal error occurred while updating the password"}), 500
|
||||
|
||||
# --- Meeting Management Routes ---
|
||||
@api_bp.route('/meetings', methods=['GET', 'POST'])
|
||||
@jwt_required()
|
||||
def handle_meetings():
|
||||
if request.method == 'POST':
|
||||
data = request.get_json()
|
||||
topic = data.get('topic')
|
||||
meeting_date_str = data.get('meeting_date')
|
||||
if not topic or not meeting_date_str:
|
||||
return jsonify({'error': 'Topic and meeting_date are required'}), 400
|
||||
try:
|
||||
meeting_date = datetime.fromisoformat(meeting_date_str).date()
|
||||
new_meeting = Meeting(
|
||||
topic=topic,
|
||||
meeting_date=meeting_date,
|
||||
created_by_id=get_jwt_identity(),
|
||||
created_at=datetime.utcnow(), # Explicitly set creation time in UTC
|
||||
status='In Progress' # Set default status to 'In Progress'
|
||||
)
|
||||
db.session.add(new_meeting)
|
||||
db.session.commit()
|
||||
return jsonify(new_meeting.to_dict()), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Failed to create meeting: {e}")
|
||||
return jsonify({'error': 'Failed to create meeting due to a database error.'}), 500
|
||||
|
||||
meetings = Meeting.query.order_by(Meeting.meeting_date.desc()).all()
|
||||
return jsonify([meeting.to_dict() for meeting in meetings])
|
||||
|
||||
@api_bp.route('/meetings/<int:meeting_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@jwt_required()
|
||||
def handle_meeting_detail(meeting_id):
|
||||
meeting = Meeting.query.get_or_404(meeting_id)
|
||||
|
||||
if request.method == 'PUT':
|
||||
data = request.get_json()
|
||||
# Only update fields that are present in the request
|
||||
if 'topic' in data:
|
||||
meeting.topic = data.get('topic')
|
||||
if 'status' in data:
|
||||
# Security check: only admin or meeting creator can change the status
|
||||
current_user_id = get_jwt_identity()
|
||||
is_admin = get_jwt().get('role') == 'admin'
|
||||
if not is_admin and str(meeting.created_by_id) != str(current_user_id):
|
||||
return jsonify({"msg": "Only the meeting creator or an admin can change the status."}), 403
|
||||
meeting.status = data.get('status')
|
||||
if 'transcript' in data:
|
||||
meeting.transcript = data.get('transcript')
|
||||
if 'summary' in data:
|
||||
meeting.summary = data.get('summary')
|
||||
if data.get('meeting_date'):
|
||||
meeting.meeting_date = datetime.fromisoformat(data['meeting_date']).date()
|
||||
|
||||
db.session.commit()
|
||||
# Refresh the object to avoid session state issues before serialization
|
||||
db.session.refresh(meeting)
|
||||
return jsonify(meeting.to_dict())
|
||||
|
||||
if request.method == 'DELETE':
|
||||
if get_jwt().get('role') != 'admin':
|
||||
return jsonify({"msg": "Administration rights required"}), 403
|
||||
db.session.delete(meeting)
|
||||
db.session.commit()
|
||||
return jsonify({"msg": "Meeting and associated action items deleted"}), 200
|
||||
|
||||
# GET request
|
||||
return jsonify(meeting.to_dict())
|
||||
|
||||
@api_bp.route('/meetings/<int:meeting_id>/summarize', methods=['POST'])
|
||||
@jwt_required()
|
||||
def summarize_meeting(meeting_id):
|
||||
meeting = Meeting.query.get_or_404(meeting_id)
|
||||
if not meeting.transcript:
|
||||
return jsonify({'error': 'Meeting has no transcript to summarize.'}), 400
|
||||
task = summarize_text_task.delay(meeting_id)
|
||||
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
||||
|
||||
# --- Independent Tool Routes ---
|
||||
@api_bp.route('/tools/extract_audio', methods=['POST'])
|
||||
@jwt_required()
|
||||
def handle_extract_audio():
|
||||
input_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER'])
|
||||
if error: return error
|
||||
output_path = os.path.splitext(input_path)[0] + ".wav"
|
||||
task = extract_audio_task.delay(input_path, output_path)
|
||||
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
||||
|
||||
@api_bp.route('/tools/transcribe_audio', methods=['POST'])
|
||||
@jwt_required()
|
||||
def handle_transcribe_audio():
|
||||
input_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER'])
|
||||
if error: return error
|
||||
# The 'language' parameter is no longer needed for the Dify-based task.
|
||||
task = transcribe_audio_task.delay(input_path)
|
||||
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
||||
|
||||
@api_bp.route('/tools/translate_text', methods=['POST'])
|
||||
@jwt_required()
|
||||
def handle_translate_text():
|
||||
data = request.get_json()
|
||||
text_content = data.get('text')
|
||||
target_language = data.get('target_language', '繁體中文')
|
||||
if not text_content:
|
||||
return jsonify({'error': 'Text content is required'}), 400
|
||||
task = translate_text_task.delay(text_content, target_language)
|
||||
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
||||
|
||||
# --- Action Item & Task Status Routes (largely unchanged) ---
|
||||
@api_bp.route('/meetings/<int:meeting_id>/action_items', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_action_items_for_meeting(meeting_id):
|
||||
action_items = ActionItem.query.filter_by(meeting_id=meeting_id).all()
|
||||
return jsonify([item.to_dict() for item in action_items])
|
||||
|
||||
@api_bp.route('/action_items/<int:item_id>', methods=['PUT', 'DELETE'])
|
||||
@jwt_required()
|
||||
def handle_action_item(item_id):
|
||||
item = ActionItem.query.get_or_404(item_id)
|
||||
current_user_id = get_jwt_identity()
|
||||
current_user_role = get_jwt().get('role')
|
||||
meeting_owner_id = str(item.meeting.created_by_id)
|
||||
|
||||
is_admin = current_user_role == 'admin'
|
||||
is_meeting_owner = str(current_user_id) == meeting_owner_id
|
||||
is_action_owner = str(current_user_id) == str(item.owner_id)
|
||||
|
||||
if request.method == 'PUT':
|
||||
# Edit Permission: Admin, Meeting Owner, or Action Item Owner
|
||||
if not (is_admin or is_meeting_owner or is_action_owner):
|
||||
return jsonify({"msg": "You do not have permission to edit this item."}), 403
|
||||
|
||||
data = request.get_json()
|
||||
item.item = data.get('item', item.item)
|
||||
item.action = data.get('action', item.action)
|
||||
item.status = data.get('status', item.status)
|
||||
# Handle owner_id, allowing it to be set to null
|
||||
if 'owner_id' in data:
|
||||
item.owner_id = data.get('owner_id') if data.get('owner_id') else None
|
||||
if data.get('due_date'):
|
||||
item.due_date = datetime.fromisoformat(data['due_date']).date() if data['due_date'] else None
|
||||
db.session.commit()
|
||||
db.session.refresh(item)
|
||||
return jsonify(item.to_dict())
|
||||
|
||||
elif request.method == 'DELETE':
|
||||
# Delete Permission: Admin or Meeting Owner
|
||||
if not (is_admin or is_meeting_owner):
|
||||
return jsonify({"msg": "You do not have permission to delete this item."}), 403
|
||||
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
return jsonify({'msg': 'Action item deleted'}), 200
|
||||
|
||||
@api_bp.route('/action_items/<int:item_id>/upload', methods=['POST'])
|
||||
@jwt_required()
|
||||
def upload_action_item_attachment(item_id):
|
||||
item = ActionItem.query.get_or_404(item_id)
|
||||
|
||||
# Basic permission check: only meeting creator or action item owner can upload
|
||||
meeting_creator_id = item.meeting.created_by_id
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
if str(current_user_id) != str(meeting_creator_id) and str(current_user_id) != str(item.owner_id):
|
||||
return jsonify({"msg": "Permission denied"}), 403
|
||||
|
||||
file_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER'])
|
||||
if error:
|
||||
return error
|
||||
|
||||
# TODO: Consider deleting the old file if it exists
|
||||
item.attachment_path = os.path.basename(file_path)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'attachment_path': item.attachment_path}), 200
|
||||
|
||||
@api_bp.route('/status/<task_id>')
|
||||
@jwt_required()
|
||||
def get_task_status(task_id):
|
||||
task = celery.AsyncResult(task_id)
|
||||
response_data = {'state': task.state, 'info': task.info if isinstance(task.info, dict) else str(task.info)}
|
||||
if task.state == 'SUCCESS' and isinstance(task.info, dict) and 'result_path' in task.info:
|
||||
response_data['info']['download_filename'] = os.path.basename(task.info['result_path'])
|
||||
return jsonify(response_data)
|
||||
|
||||
@api_bp.route('/task/<task_id>/stop', methods=['POST'])
|
||||
@jwt_required()
|
||||
def stop_task(task_id):
|
||||
celery.control.revoke(task_id, terminate=True)
|
||||
return jsonify({'status': 'revoked'}), 200
|
||||
|
||||
@api_bp.route('/download/<filename>')
|
||||
@jwt_required()
|
||||
def download_file(filename):
|
||||
return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
|
95
app.py
Normal file
95
app.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import click
|
||||
from datetime import timedelta
|
||||
from flask import Flask
|
||||
from dotenv import load_dotenv
|
||||
from flask_migrate import Migrate
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flask_cors import CORS
|
||||
|
||||
from models import db, bcrypt, User
|
||||
from celery_app import celery # Import celery instance
|
||||
|
||||
def create_app():
|
||||
"""Application Factory Pattern"""
|
||||
load_dotenv()
|
||||
app = Flask(__name__)
|
||||
|
||||
# --- Configuration ---
|
||||
app.config.from_mapping(
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL'),
|
||||
JWT_SECRET_KEY=os.environ.get('JWT_SECRET_KEY'),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
JWT_ACCESS_TOKEN_EXPIRES=timedelta(days=3),
|
||||
CELERY_BROKER_URL=os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0'),
|
||||
CELERY_RESULT_BACKEND=os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0'),
|
||||
DIFY_API_BASE_URL=os.environ.get("DIFY_API_BASE_URL"),
|
||||
DIFY_STT_API_KEY=os.environ.get("DIFY_STT_API_KEY"),
|
||||
DIFY_TRANSLATOR_API_KEY=os.environ.get("DIFY_TRANSLATOR_API_KEY"),
|
||||
DIFY_SUMMARIZER_API_KEY=os.environ.get("DIFY_SUMMARIZER_API_KEY"),
|
||||
DIFY_ACTION_EXTRACTOR_API_KEY=os.environ.get("DIFY_ACTION_EXTRACTOR_API_KEY")
|
||||
)
|
||||
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_FOLDER = os.path.join(project_root, 'uploads')
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024 # 1GB upload limit
|
||||
|
||||
# --- Initialize Extensions ---
|
||||
db.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
Migrate(app, db)
|
||||
JWTManager(app)
|
||||
CORS(app)
|
||||
|
||||
# --- Configure Celery ---
|
||||
celery.conf.update(app.config)
|
||||
# This custom Task class ensures tasks run with the Flask app context
|
||||
class ContextTask(celery.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
with app.app_context():
|
||||
return self.run(*args, **kwargs)
|
||||
celery.Task = ContextTask
|
||||
|
||||
# --- Import and Register Blueprints ---
|
||||
from api_routes import api_bp
|
||||
from ai_routes import ai_bp
|
||||
from action_item_routes import action_bp
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(ai_bp)
|
||||
app.register_blueprint(action_bp)
|
||||
|
||||
# --- Root Route ---
|
||||
@app.route('/')
|
||||
def index():
|
||||
return "AI Meeting Assistant Backend is running."
|
||||
|
||||
# --- CLI Commands ---
|
||||
@app.cli.command("create_admin")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
def create_admin(username, password):
|
||||
"""Creates a new admin user."""
|
||||
with app.app_context():
|
||||
try:
|
||||
if User.query.filter_by(username=username).first():
|
||||
print(f"Error: User '{username}' already exists.")
|
||||
return
|
||||
admin_user = User(username=username, role='admin')
|
||||
admin_user.set_password(password)
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
print(f"Admin user '{username}' created successfully.")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get("FLASK_RUN_PORT", 5000))
|
||||
app.run(host='0.0.0.0', port=port, debug=True)
|
13
celery_app.py
Normal file
13
celery_app.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from celery import Celery
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
|
||||
celery = Celery('tasks',
|
||||
broker=CELERY_BROKER_URL,
|
||||
backend=CELERY_RESULT_BACKEND,
|
||||
include=['tasks']) # Point to the tasks module
|
14
celery_worker.py
Normal file
14
celery_worker.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# This monkey-patching is crucial for eventlet/gevent to work correctly.
|
||||
# It must be done at the very top, before any other modules are imported.
|
||||
import eventlet
|
||||
eventlet.monkey_patch()
|
||||
|
||||
from dotenv import load_dotenv
|
||||
# Load environment variables BEFORE creating the app
|
||||
load_dotenv()
|
||||
|
||||
from app import create_app
|
||||
from celery_app import celery
|
||||
|
||||
app = create_app()
|
||||
app.app_context().push()
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meeting Assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
0
frontend/npm
Normal file
0
frontend/npm
Normal file
3805
frontend/package-lock.json
generated
Normal file
3805
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"axios": "^1.11.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
BIN
frontend/public/LOGO.png
Normal file
BIN
frontend/public/LOGO.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
97
frontend/public/static/css/style.css
Normal file
97
frontend/public/static/css/style.css
Normal file
@@ -0,0 +1,97 @@
|
||||
/* Dark Theme Adaptation for tools.html */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #121212; /* Match MUI dark theme background */
|
||||
color: #e0e0e0; /* Light text color for dark background */
|
||||
margin: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #ffffff;
|
||||
border-bottom: 2px solid #424242; /* Darker border */
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #1e1e1e; /* Darker card background */
|
||||
border: 1px solid #424242; /* Subtle border */
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-top: 0;
|
||||
color: #bb86fc; /* A nice accent color for dark theme */
|
||||
}
|
||||
|
||||
input[type="file"], textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #555;
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #3700b3; /* MUI dark theme primary-like color */
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #6200ee;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #444;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-container, .result-container {
|
||||
margin-top: 15px;
|
||||
background-color: #2c2c2c;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
background-color: #444;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-inner {
|
||||
height: 20px;
|
||||
width: 0%;
|
||||
background-color: #03dac6; /* Accent color for progress */
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
color: black;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
#translation-preview {
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #555;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #333;
|
||||
}
|
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
68
frontend/src/App.jsx
Normal file
68
frontend/src/App.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import MeetingDetailPage from './pages/MeetingDetailPage';
|
||||
import ProcessingPage from './pages/ProcessingPage'; // Restored
|
||||
import AdminPage from './pages/AdminPage'; // Added
|
||||
import Layout from './components/Layout';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
},
|
||||
});
|
||||
|
||||
const PrivateRoute = () => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <Layout><Outlet /></Layout> : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
const AdminRoute = () => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return user && user.role === 'admin' ? <Outlet /> : <Navigate to="/" />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<PrivateRoute />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="processing" element={<ProcessingPage />} />
|
||||
<Route path="meeting/:meetingId" element={<MeetingDetailPage />} />
|
||||
|
||||
<Route path="admin" element={<AdminRoute />}>
|
||||
<Route index element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
52
frontend/src/components/Layout.jsx
Normal file
52
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { AppBar, Toolbar, Typography, Button, Container, Box } from '@mui/material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component={Link} to="/" sx={{ flexGrow: 1, color: 'inherit', textDecoration: 'none' }}>
|
||||
AI Meeting Assistant
|
||||
</Typography>
|
||||
{user && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button color="inherit" component={Link} to="/">
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button color="inherit" component={Link} to="/processing">
|
||||
Processing Tools
|
||||
</Button>
|
||||
{user.role === 'admin' && (
|
||||
<Button color="inherit" component={Link} to="/admin">
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
<Typography sx={{ mx: 2 }}>
|
||||
| Welcome, {user.username} ({user.role})
|
||||
</Typography>
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container component="main" maxWidth={false} sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
82
frontend/src/components/NewMeetingDialog.jsx
Normal file
82
frontend/src/components/NewMeetingDialog.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
CircularProgress, Alert
|
||||
} from '@mui/material';
|
||||
|
||||
const NewMeetingDialog = ({ open, onClose, onCreate }) => {
|
||||
const [topic, setTopic] = useState('');
|
||||
const [meetingDate, setMeetingDate] = useState(new Date().toISOString().split('T')[0]); // Defaults to today
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!topic || !meetingDate) {
|
||||
setError('Both topic and date are required.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
// The onCreate prop is expected to be an async function
|
||||
// that returns the newly created meeting.
|
||||
await onCreate(topic, meetingDate);
|
||||
handleClose(); // Close the dialog on success
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create meeting.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (loading) return; // Prevent closing while loading
|
||||
setTopic('');
|
||||
setMeetingDate(new Date().toISOString().split('T')[0]);
|
||||
setError('');
|
||||
onClose(); // Call the parent's onClose handler
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Create a New Meeting</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && <Alert severity="error" sx={{ mb: 2, mt: 1 }}>{error}</Alert>}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="topic"
|
||||
label="Meeting Topic"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
id="meetingDate"
|
||||
label="Meeting Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={meetingDate}
|
||||
onChange={(e) => setMeetingDate(e.target.value)}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>Cancel</Button>
|
||||
<Button onClick={handleCreate} variant="contained" disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMeetingDialog;
|
116
frontend/src/components/TextProcessingTools.jsx
Normal file
116
frontend/src/components/TextProcessingTools.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box, Button, Typography, Paper, TextField, Select, MenuItem, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
||||
} from '@mui/material';
|
||||
import { getMeetings, createMeeting, batchCreateActionItems } from '../services/api'; // Only necessary APIs
|
||||
|
||||
const TextProcessingTools = ({
|
||||
textContent,
|
||||
summary,
|
||||
actionItems,
|
||||
onGenerateSummary,
|
||||
onPreviewActions,
|
||||
onActionItemChange
|
||||
}) => {
|
||||
const [meetings, setMeetings] = useState([]);
|
||||
const [users, setUsers] = useState([]); // Assuming users are needed for dropdown
|
||||
const [isMeetingDialogOpen, setIsMeetingDialogOpen] = useState(false);
|
||||
const [associationType, setAssociationType] = useState('existing');
|
||||
const [selectedMeetingId, setSelectedMeetingId] = useState('');
|
||||
const [newMeetingTopic, setNewMeetingTopic] = useState('');
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDropdownData = async () => {
|
||||
try {
|
||||
const meetingsRes = await getMeetings();
|
||||
setMeetings(meetingsRes.data);
|
||||
if (meetingsRes.data.length > 0) {
|
||||
setSelectedMeetingId(meetingsRes.data[0].id);
|
||||
}
|
||||
} catch (err) { console.error('Could not fetch meetings for dropdown.'); }
|
||||
};
|
||||
fetchDropdownData();
|
||||
}, []);
|
||||
|
||||
const handleInitiateSave = () => {
|
||||
if (!actionItems || !Array.isArray(actionItems) || actionItems.length === 0) {
|
||||
setError('No valid action items to save.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setIsMeetingDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
let meetingIdToSave = selectedMeetingId;
|
||||
if (associationType === 'new') {
|
||||
if (!newMeetingTopic) { setError('New meeting topic is required.'); return; }
|
||||
try {
|
||||
const { data: newMeeting } = await createMeeting(newMeetingTopic, new Date().toISOString());
|
||||
meetingIdToSave = newMeeting.id;
|
||||
} catch (err) { setError('Failed to create new meeting.'); return; }
|
||||
}
|
||||
|
||||
if (!meetingIdToSave) { setError('A meeting must be selected or created.'); return; }
|
||||
|
||||
setSaveLoading(true); setError('');
|
||||
try {
|
||||
const itemsToSave = actionItems.map(({ tempId, owner, duedate, ...rest }) => rest);
|
||||
await batchCreateActionItems(meetingIdToSave, itemsToSave);
|
||||
setIsMeetingDialogOpen(false);
|
||||
alert('Action Items saved successfully!');
|
||||
// Optionally, clear items after save by calling a prop function from parent
|
||||
} catch (err) { setError(err.response?.data?.error || 'Failed to save action items.'); }
|
||||
finally { setSaveLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Box sx={{display: 'flex', gap: 2, mb: 2}}>
|
||||
<Button onClick={onGenerateSummary} disabled={!textContent} variant="outlined">Generate Summary</Button>
|
||||
<Button onClick={onPreviewActions} disabled={!textContent} variant="outlined">Generate Action Items</Button>
|
||||
</Box>
|
||||
|
||||
{summary && <Paper elevation={2} sx={{ p: 2, mb: 2 }}><Typography variant="h6">Summary</Typography><TextField fullWidth multiline rows={6} value={summary} variant="outlined" sx={{mt:1}}/></Paper>}
|
||||
|
||||
{actionItems && actionItems.length > 0 && (
|
||||
<Paper elevation={2} sx={{ p: 2 }}>
|
||||
<Typography variant="h6">Review and Edit Action Items</Typography>
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow><TableCell>Context</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell></TableRow></TableHead>
|
||||
<TableBody>{actionItems.map(item => (
|
||||
<TableRow key={item.tempId}>
|
||||
<TableCell><TextField variant="standard" fullWidth value={item.item || ''} onChange={e => onActionItemChange(item.tempId, 'item', e.target.value)}/></TableCell>
|
||||
<TableCell><TextField variant="standard" fullWidth value={item.action || ''} onChange={e => onActionItemChange(item.tempId, 'action', e.target.value)}/></TableCell>
|
||||
<TableCell><TextField variant="standard" fullWidth value={item.owner || ''} onChange={e => onActionItemChange(item.tempId, 'owner', e.target.value)}/></TableCell>
|
||||
<TableCell><TextField variant="standard" type="date" fullWidth value={item.due_date || ''} onChange={e => onActionItemChange(item.tempId, 'due_date', e.target.value)} InputLabelProps={{ shrink: true }}/></TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box mt={2} display="flex" justifyContent="flex-end">
|
||||
<Button onClick={handleInitiateSave} disabled={saveLoading} variant="contained" color="primary">Save All Action Items</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Dialog open={isMeetingDialogOpen} onClose={() => setIsMeetingDialogOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Associate with a Meeting</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl component="fieldset" sx={{mt:1}}><Select size="small" value={associationType} onChange={e => setAssociationType(e.target.value)}><MenuItem value="existing">Existing Meeting</MenuItem><MenuItem value="new">New Meeting</MenuItem></Select></FormControl>
|
||||
{associationType === 'existing' ? <FormControl fullWidth sx={{mt:2}}><InputLabel>Select Meeting</InputLabel><Select value={selectedMeetingId} label="Select Meeting" onChange={e => setSelectedMeetingId(e.target.value)}>{meetings.map(m => <MenuItem key={m.id} value={m.id}>{m.topic}</MenuItem>)}</Select></FormControl> : <TextField label="New Meeting Topic" fullWidth sx={{mt:2}} value={newMeetingTopic} onChange={e => setNewMeetingTopic(e.target.value)} />}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsMeetingDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleConfirmSave} variant="contained" disabled={saveLoading}>{saveLoading ? <CircularProgress size={24}/> : 'Confirm & Save'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextProcessingTools;
|
88
frontend/src/contexts/AuthContext.jsx
Normal file
88
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const setAuthToken = token => {
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(() => localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwtDecode(token);
|
||||
const currentTime = Date.now() / 1000;
|
||||
if (decoded.exp < currentTime) {
|
||||
console.log("Token expired, logging out.");
|
||||
logout();
|
||||
} else {
|
||||
setUser({
|
||||
id: decoded.sub,
|
||||
role: decoded.role,
|
||||
username: decoded.username
|
||||
});
|
||||
setAuthToken(token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid token on initial load");
|
||||
logout();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [token]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/login', { username, password });
|
||||
const { access_token } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
setToken(access_token);
|
||||
const decoded = jwtDecode(access_token);
|
||||
setUser({
|
||||
id: decoded.sub,
|
||||
role: decoded.role,
|
||||
username: decoded.username
|
||||
});
|
||||
setAuthToken(access_token);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error.response?.data?.msg || error.message);
|
||||
return { success: false, message: error.response?.data?.msg || 'Login failed' };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setAuthToken(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
login,
|
||||
logout
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{!loading && children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
return useContext(AuthContext);
|
||||
};
|
27
frontend/src/index.css
Normal file
27
frontend/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* Reset default styles and ensure full-width/height layout */
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* The rest of the default styles can be kept or removed as needed */
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext.jsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
174
frontend/src/pages/ActionItemPage.jsx
Normal file
174
frontend/src/pages/ActionItemPage.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert,
|
||||
TextField, Button, Select, MenuItem, FormControl, InputLabel, Grid, Link
|
||||
} from '@mui/material';
|
||||
import { getActionItemDetails, updateActionItem, uploadAttachment } from '../services/api';
|
||||
|
||||
const ActionItemPage = () => {
|
||||
const { actionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [actionItem, setActionItem] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActionItem = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getActionItemDetails(actionId);
|
||||
setActionItem(data);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not fetch action item details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchActionItem();
|
||||
}, [actionId]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!actionItem) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Only send fields that are meant to be updated
|
||||
const updateData = {
|
||||
item: actionItem.item,
|
||||
action: actionItem.action,
|
||||
status: actionItem.status,
|
||||
due_date: actionItem.due_date,
|
||||
// owner_id is typically not changed from this screen, but could be added if needed
|
||||
};
|
||||
await updateActionItem(actionId, updateData);
|
||||
|
||||
if (attachment) {
|
||||
// Note: The backend needs an endpoint to handle attachment uploads for an action item.
|
||||
// This is a placeholder for that functionality.
|
||||
// await uploadAttachment(actionId, attachment);
|
||||
console.warn("Attachment upload functionality is not yet implemented on the backend.");
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
// Refresh data after update
|
||||
const data = await getActionItemDetails(actionId);
|
||||
setActionItem(data);
|
||||
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update action item.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
setAttachment(event.target.files[0]);
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
if (!actionItem) return <Alert severity="info">No action item found.</Alert>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Action Item for: {actionItem.meeting?.topic || 'General Task'}
|
||||
</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Context/Item"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={actionItem.item || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, item: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Action"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={actionItem.action || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, action: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth variant={isEditing ? "outlined" : "filled"}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={actionItem.status || 'pending'}
|
||||
onChange={(e) => setActionItem({ ...actionItem, status: e.target.value })}
|
||||
readOnly={!isEditing}
|
||||
>
|
||||
<MenuItem value="pending">Pending</MenuItem>
|
||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Due Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={actionItem.due_date || ''}
|
||||
onChange={(e) => setActionItem({ ...actionItem, due_date: e.target.value })}
|
||||
InputProps={{ readOnly: !isEditing }}
|
||||
variant={isEditing ? "outlined" : "filled"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography>Owner: {actionItem.owner?.username || 'N/A'}</Typography>
|
||||
{actionItem.attachment_path && (
|
||||
<Typography>
|
||||
Attachment: <Link href={`/api/download/${actionItem.attachment_path.split('/').pop()}`} target="_blank" rel="noopener">Download</Link>
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
{isEditing && (
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
>
|
||||
Upload Attachment
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Button>
|
||||
{attachment && <Typography sx={{ display: 'inline', ml: 2 }}>{attachment.name}</Typography>}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} sx={{ mt: 2 }}>
|
||||
{isEditing ? (
|
||||
<Box>
|
||||
<Button onClick={handleUpdate} variant="contained" color="primary" disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
<Button onClick={() => setIsEditing(false)} sx={{ ml: 2 }}>Cancel</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)} variant="contained">Edit</Button>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionItemPage;
|
237
frontend/src/pages/AdminPage.jsx
Normal file
237
frontend/src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
CircularProgress, Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField,
|
||||
Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import LockResetIcon from '@mui/icons-material/LockReset';
|
||||
import { getUsers, createUser, deleteUser, changeUserPassword } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const PasswordChangeDialog = ({ open, onClose, onConfirm, user }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!password) {
|
||||
setError('Password cannot be empty.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
onConfirm(user.id, password);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Change Password for {user?.username}</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && <Alert severity="error" sx={{ mb: 2, mt: 1 }}>{error}</Alert>}
|
||||
<TextField autoFocus margin="dense" name="password" label="New Password" type="password" fullWidth variant="standard" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<TextField margin="dense" name="confirmPassword" label="Confirm New Password" type="password" fullWidth variant="standard" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleConfirm}>Confirm Change</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const AdminPage = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [isUserDialogOpen, setIsUserDialogOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
|
||||
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getUsers();
|
||||
setUsers(data);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.msg || 'Could not fetch users.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleOpenUserDialog = () => setIsUserDialogOpen(true);
|
||||
const handleCloseUserDialog = () => {
|
||||
setIsUserDialogOpen(false);
|
||||
setNewUser({ username: '', password: '', role: 'user' }); // Reset form
|
||||
};
|
||||
|
||||
const handleOpenPasswordDialog = (user) => {
|
||||
setSelectedUser(user);
|
||||
setIsPasswordDialogOpen(true);
|
||||
};
|
||||
const handleClosePasswordDialog = () => {
|
||||
setSelectedUser(null);
|
||||
setIsPasswordDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUser.username || !newUser.password) {
|
||||
setError('Username and password are required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createUser(newUser);
|
||||
handleCloseUserDialog();
|
||||
fetchUsers(); // Refetch the list
|
||||
setSuccess('User created successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create user.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (window.confirm('Are you sure you want to delete this user? This will disassociate them from all meetings and action items.')) {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
fetchUsers(); // Refresh the list
|
||||
setSuccess('User deleted successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete user.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (userId, newPassword) => {
|
||||
try {
|
||||
await changeUserPassword(userId, newPassword);
|
||||
handleClosePasswordDialog();
|
||||
setSuccess('Password updated successfully.');
|
||||
} catch (err) {
|
||||
// The dialog will show specific errors, this is a fallback.
|
||||
setError(err.response?.data?.error || 'Failed to change password.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewUser(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
User Management
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={handleOpenUserDialog}>
|
||||
New User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>{success}</Alert>}
|
||||
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Created At</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>{new Date(user.created_at).toLocaleString()}</TableCell>
|
||||
<TableCell align="center" sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Tooltip title="Change Password">
|
||||
<IconButton color="primary" onClick={() => handleOpenPasswordDialog(user)}>
|
||||
<LockResetIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Prevent admin from deleting themselves, show placeholder otherwise */}
|
||||
{String(currentUser.id) !== String(user.id) ? (
|
||||
<Tooltip title="Delete User">
|
||||
<IconButton color="error" onClick={() => handleDeleteUser(user.id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// Invisible placeholder to maintain alignment. An IconButton is ~40px wide.
|
||||
<Box sx={{ width: 40, height: 40 }} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={isUserDialogOpen} onClose={handleCloseUserDialog}>
|
||||
<DialogTitle>Create a New User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField autoFocus margin="dense" name="username" label="Username" type="text" fullWidth variant="standard" value={newUser.username} onChange={handleInputChange} />
|
||||
<TextField margin="dense" name="password" label="Password" type="password" fullWidth variant="standard" value={newUser.password} onChange={handleInputChange} />
|
||||
<FormControl fullWidth margin="dense" variant="standard">
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select name="role" value={newUser.role} onChange={handleInputChange}>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseUserDialog}>Cancel</Button>
|
||||
<Button onClick={handleCreateUser}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
{selectedUser && (
|
||||
<PasswordChangeDialog
|
||||
open={isPasswordDialogOpen}
|
||||
onClose={handleClosePasswordDialog}
|
||||
onConfirm={handlePasswordChange}
|
||||
user={selectedUser}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
291
frontend/src/pages/DashboardPage.jsx
Normal file
291
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
CircularProgress, Alert, Button, TextField, TableSortLabel, Select, MenuItem, FormControl,
|
||||
InputLabel, Chip, OutlinedInput, IconButton
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { getMeetings, updateMeeting, deleteMeeting, createMeeting } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import NewMeetingDialog from '../components/NewMeetingDialog';
|
||||
|
||||
// Helper function for sorting
|
||||
function descendingComparator(a, b, orderBy) {
|
||||
if (b[orderBy] < a[orderBy]) return -1;
|
||||
if (b[orderBy] > a[orderBy]) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getComparator(order, orderBy) {
|
||||
return order === 'desc'
|
||||
? (a, b) => descendingComparator(a, b, orderBy)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy);
|
||||
}
|
||||
|
||||
const DashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [meetings, setMeetings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [deletingIds, setDeletingIds] = useState(new Set());
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// State for filtering and searching
|
||||
const [topicSearch, setTopicSearch] = useState('');
|
||||
const [ownerSearch, setOwnerSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState([]);
|
||||
|
||||
// State for sorting
|
||||
const [order, setOrder] = useState('asc');
|
||||
const [orderBy, setOrderBy] = useState('meeting_date');
|
||||
|
||||
const fetchMeetings = useCallback(async () => {
|
||||
try {
|
||||
const data = await getMeetings();
|
||||
setMeetings(data);
|
||||
} catch (err) {
|
||||
setError('Could not fetch meetings.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetings();
|
||||
}, [fetchMeetings]);
|
||||
|
||||
const handleCreateMeeting = async (topic, meetingDate) => {
|
||||
try {
|
||||
const newMeeting = await createMeeting(topic, new Date(meetingDate).toISOString());
|
||||
setIsModalOpen(false);
|
||||
navigate(`/meeting/${newMeeting.id}`);
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || "Failed to create meeting.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortRequest = (property) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (meetingId, newStatus) => {
|
||||
try {
|
||||
await updateMeeting(meetingId, { status: newStatus });
|
||||
fetchMeetings();
|
||||
} catch (err) {
|
||||
setError(`Failed to update status for meeting ${meetingId}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (meetingId) => {
|
||||
if (window.confirm('Are you sure you want to delete this meeting?')) {
|
||||
setDeletingIds(prev => new Set(prev).add(meetingId));
|
||||
try {
|
||||
await deleteMeeting(meetingId);
|
||||
fetchMeetings();
|
||||
} catch (err) {
|
||||
setError(`Failed to delete meeting ${meetingId}.`);
|
||||
} finally {
|
||||
setDeletingIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(meetingId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueStatuses = useMemo(() => {
|
||||
const statuses = new Set(meetings.map(m => m.status));
|
||||
return Array.from(statuses);
|
||||
}, [meetings]);
|
||||
|
||||
const filteredAndSortedMeetings = useMemo(() => {
|
||||
let filtered = meetings.filter(meeting => {
|
||||
const topicMatch = meeting.topic.toLowerCase().includes(topicSearch.toLowerCase());
|
||||
const ownerMatch = meeting.owner_name ? meeting.owner_name.toLowerCase().includes(ownerSearch.toLowerCase()) : ownerSearch === '';
|
||||
const statusMatch = statusFilter.length === 0 || statusFilter.includes(meeting.status);
|
||||
return topicMatch && ownerMatch && statusMatch;
|
||||
});
|
||||
|
||||
return filtered.sort(getComparator(order, orderBy));
|
||||
}, [meetings, topicSearch, ownerSearch, statusFilter, order, orderBy]);
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
const headCells = [
|
||||
{ id: 'topic', label: 'Topic' },
|
||||
{ id: 'owner_name', label: 'Owner' },
|
||||
{ id: 'meeting_date', label: 'Meeting Date' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'action_item_count', label: 'Action Items' },
|
||||
{ id: 'created_at', label: 'Created At' },
|
||||
];
|
||||
|
||||
const statusColorMap = {
|
||||
'Completed': 'success',
|
||||
'In Progress': 'info',
|
||||
'To Do': 'warning',
|
||||
'Failed': 'error',
|
||||
};
|
||||
|
||||
const allPossibleStatuses = ['To Do', 'In Progress', 'Completed', 'Failed'];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
||||
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsModalOpen(true)}>
|
||||
New Meeting
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<NewMeetingDialog
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onCreate={handleCreateMeeting}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2, mt: 2 }} onClose={() => setError('')}>{error}</Alert>}
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', md: 'row' } }}>
|
||||
<TextField
|
||||
label="Search by Topic"
|
||||
variant="outlined"
|
||||
value={topicSearch}
|
||||
onChange={(e) => setTopicSearch(e.target.value)}
|
||||
sx={{ flex: '1 1 40%' }}
|
||||
/>
|
||||
<TextField
|
||||
label="Search by Owner"
|
||||
variant="outlined"
|
||||
value={ownerSearch}
|
||||
onChange={(e) => setOwnerSearch(e.target.value)}
|
||||
sx={{ flex: '1 1 30%' }}
|
||||
/>
|
||||
<FormControl sx={{ flex: '1 1 30%' }}>
|
||||
<InputLabel>Filter by Status</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
input={<OutlinedInput label="Filter by Status" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => <Chip key={value} label={value} />)}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{uniqueStatuses.map((status) => (
|
||||
<MenuItem key={status} value={status}>{status}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell key={headCell.id} sortDirection={orderBy === headCell.id ? order : false}>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={() => handleSortRequest(headCell.id)}
|
||||
>
|
||||
{headCell.label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredAndSortedMeetings.map((meeting) => {
|
||||
const isOwnerOrAdmin = user && (user.role === 'admin' || String(user.id) === String(meeting.created_by_id));
|
||||
const isDeleting = deletingIds.has(meeting.id);
|
||||
|
||||
let taipeiTime = 'N/A';
|
||||
if (meeting.created_at) {
|
||||
taipeiTime = new Date(meeting.created_at).toLocaleString('zh-TW', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={meeting.id} hover>
|
||||
<TableCell>{meeting.topic}</TableCell>
|
||||
<TableCell>{meeting.owner_name || 'N/A'}</TableCell>
|
||||
<TableCell>{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString() : 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={meeting.status}
|
||||
onChange={(e) => handleStatusChange(meeting.id, e.target.value)}
|
||||
readOnly={!isOwnerOrAdmin}
|
||||
IconComponent={!isOwnerOrAdmin ? () => null : undefined}
|
||||
renderValue={(selected) => (
|
||||
<Chip
|
||||
label={selected}
|
||||
color={statusColorMap[selected] || 'default'}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{allPossibleStatuses.map(status => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>{meeting.action_item_count}</TableCell>
|
||||
<TableCell>{taipeiTime}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Button size="small" onClick={() => navigate(`/meeting/${meeting.id}`)}>
|
||||
View Details
|
||||
</Button>
|
||||
{isOwnerOrAdmin ? (
|
||||
<IconButton size="small" onClick={() => handleDelete(meeting.id)} color="error" disabled={isDeleting}>
|
||||
{isDeleting ? <CircularProgress size={20} /> : <DeleteIcon />}
|
||||
</IconButton>
|
||||
) : (
|
||||
// Invisible placeholder to maintain alignment
|
||||
<Box sx={{ width: 34, height: 34 }} />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
155
frontend/src/pages/LoginPage.jsx
Normal file
155
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Button, TextField, Container, Typography, Box, Alert, Grid, Link, Avatar, Card, CardContent, CircularProgress
|
||||
} from '@mui/material';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import { register } from '../services/api';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
const { success, message } = await login(username, password);
|
||||
if (success) {
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError(message || 'Failed to log in');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(username, password);
|
||||
setSuccess('Account created successfully! Please log in.');
|
||||
setIsRegister(false); // Switch back to login view
|
||||
setUsername(''); // Clear fields
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create account.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Card sx={{ padding: 2, width: '100%', mt: 3 }}>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src="/LOGO.png"
|
||||
sx={{ m: 1, bgcolor: 'secondary.main', width: 56, height: 56 }}
|
||||
imgProps={{ style: { objectFit: 'contain' } }}
|
||||
>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
AI Meeting Assistant
|
||||
</Typography>
|
||||
<Typography component="h2" variant="subtitle1" sx={{ mt: 1 }}>
|
||||
{isRegister ? 'Create Account' : 'Sign In'}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={isRegister ? handleRegister : handleLogin} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{isRegister && (
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{error && <Alert severity="error" sx={{ width: '100%', mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ width: '100%', mt: 2 }}>{success}</Alert>}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : (isRegister ? 'Create Account' : 'Sign In')}
|
||||
</Button>
|
||||
<Grid container justifyContent="flex-end">
|
||||
<Grid item>
|
||||
<Link href="#" variant="body2" onClick={() => {
|
||||
setIsRegister(!isRegister);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}}>
|
||||
{isRegister ? "Already have an account? Sign In" : "Don't have an account? Sign Up"}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
308
frontend/src/pages/MeetingDetailPage.jsx
Normal file
308
frontend/src/pages/MeetingDetailPage.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Select, MenuItem, FormControl, InputLabel,
|
||||
Grid, Card, CardContent
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PreviewIcon from '@mui/icons-material/Preview';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import {
|
||||
getMeetingDetails, updateMeeting, summarizeMeeting,
|
||||
getActionItemsForMeeting, createActionItem, updateActionItem, deleteActionItem, getAllUsers,
|
||||
previewActionItems, batchSaveActionItems, pollTaskStatus, uploadActionItemAttachment, downloadFile
|
||||
} from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const MeetingDetailPage = () => {
|
||||
const { meetingId } = useParams();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
const [meeting, setMeeting] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isEditingTranscript, setIsEditingTranscript] = useState(false);
|
||||
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [summaryTask, setSummaryTask] = useState(null);
|
||||
|
||||
const [actionItems, setActionItems] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [editingActionItemId, setEditingActionItemId] = useState(null);
|
||||
const [editActionItemData, setEditActionItemData] = useState({});
|
||||
const [attachmentFile, setAttachmentFile] = useState(null);
|
||||
const [isAddActionItemOpen, setIsAddActionItemOpen] = useState(false);
|
||||
const [newActionItem, setNewActionItem] = useState({ action: '', owner_id: '', due_date: '', item: '' });
|
||||
const [previewedItems, setPreviewedItems] = useState([]);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
|
||||
const fetchMeetingData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const meetingRes = await getMeetingDetails(meetingId);
|
||||
setMeeting(meetingRes);
|
||||
setEditData(meetingRes);
|
||||
const itemsRes = await getActionItemsForMeeting(meetingId);
|
||||
setActionItems(itemsRes);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch meeting data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [meetingId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetingData();
|
||||
}, [fetchMeetingData]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const usersRes = await getAllUsers();
|
||||
setUsers(usersRes);
|
||||
} catch (err) { console.warn('Could not fetch user list.'); }
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId = null;
|
||||
if (summaryTask && (summaryTask.state === 'PENDING' || summaryTask.state === 'PROGRESS')) {
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updatedTask = await pollTaskStatus(summaryTask.status_url);
|
||||
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
|
||||
clearInterval(intervalId);
|
||||
setSummaryTask(null);
|
||||
if (updatedTask.state === 'SUCCESS' && updatedTask.info.summary) {
|
||||
// Directly update the summary instead of refetching everything
|
||||
setMeeting(prevMeeting => ({...prevMeeting, summary: updatedTask.info.summary}));
|
||||
setEditData(prevEditData => ({...prevEditData, summary: updatedTask.info.summary}));
|
||||
} else {
|
||||
// Fallback to refetch if something goes wrong or task fails
|
||||
fetchMeetingData();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling failed:', err);
|
||||
clearInterval(intervalId);
|
||||
setSummaryTask(null);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => clearInterval(intervalId);
|
||||
}, [summaryTask, fetchMeetingData]);
|
||||
|
||||
const handleSave = async (field, value) => {
|
||||
try {
|
||||
await updateMeeting(meetingId, { [field]: value });
|
||||
fetchMeetingData();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to save ${field}.`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTranscript = async () => {
|
||||
if (await handleSave('transcript', editData.transcript)) setIsEditingTranscript(false);
|
||||
};
|
||||
|
||||
const handleSaveSummary = async () => {
|
||||
if (await handleSave('summary', editData.summary)) setIsEditingSummary(false);
|
||||
};
|
||||
|
||||
const handleGenerateSummary = async () => {
|
||||
try {
|
||||
const taskInfo = await summarizeMeeting(meetingId);
|
||||
setSummaryTask({ ...taskInfo, state: 'PENDING' });
|
||||
} catch (err) {
|
||||
setError('Failed to start summary generation.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewActionItems = async () => {
|
||||
const textToPreview = meeting?.summary || meeting?.transcript;
|
||||
if (!textToPreview) return;
|
||||
setIsPreviewLoading(true);
|
||||
try {
|
||||
const result = await previewActionItems(textToPreview);
|
||||
setPreviewedItems(result.items || []);
|
||||
} catch (err) {
|
||||
setError('Failed to generate action item preview.');
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSave = async () => {
|
||||
if (previewedItems.length === 0) return;
|
||||
try {
|
||||
await batchSaveActionItems(meetingId, previewedItems);
|
||||
setPreviewedItems([]);
|
||||
fetchMeetingData();
|
||||
} catch (err) {
|
||||
setError('Failed to save action items.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditActionItemClick = (item) => { setEditingActionItemId(item.id); setEditActionItemData({ ...item, due_date: item.due_date || '' }); };
|
||||
const handleCancelActionItemClick = () => { setEditingActionItemId(null); setAttachmentFile(null); };
|
||||
const handleSaveActionItemClick = async (id) => {
|
||||
try {
|
||||
await updateActionItem(id, editActionItemData);
|
||||
if (attachmentFile) await uploadActionItemAttachment(id, attachmentFile);
|
||||
setEditingActionItemId(null);
|
||||
setAttachmentFile(null);
|
||||
fetchMeetingData();
|
||||
} catch (err) {
|
||||
setError('Failed to save action item.');
|
||||
}
|
||||
};
|
||||
const handleDeleteActionItemClick = async (id) => { if (window.confirm('Are you sure?')) { try { await deleteActionItem(id); fetchMeetingData(); } catch (err) { setError('Failed to delete action item.'); }}};
|
||||
const handleAddActionItemSave = async () => { if (!newActionItem.action) { setError('Action is required.'); return; } try { const newItem = await createActionItem({ ...newActionItem, meeting_id: meetingId }); if (attachmentFile) { await uploadActionItemAttachment(newItem.id, attachmentFile); } setIsAddActionItemOpen(false); setNewActionItem({ action: '', owner_id: '', due_date: '', item: '' }); setAttachmentFile(null); fetchMeetingData(); } catch (err) { setError('Failed to create action item.'); }};
|
||||
const handleFileChange = (e) => { if (e.target.files[0]) setAttachmentFile(e.target.files[0]); };
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
|
||||
if (!meeting) return <Alert severity="error">Meeting not found.</Alert>;
|
||||
|
||||
const canManageMeeting = currentUser && meeting && (currentUser.role === 'admin' || String(currentUser.id) === String(meeting.created_by_id));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={3} direction="column">
|
||||
{/* Transcript Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{isEditingTranscript ? (
|
||||
<>
|
||||
<TextField label="Transcript" multiline rows={15} fullWidth value={editData.transcript || ''} onChange={e => setEditData({...editData, transcript: e.target.value})} />
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveTranscript}>Save Transcript</Button><Button variant="outlined" onClick={() => { setIsEditingTranscript(false); setEditData(meeting); }}>Cancel</Button></Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h5">{meeting.topic}</Typography>
|
||||
{canManageMeeting && <Button startIcon={<EditIcon />} onClick={() => setIsEditingTranscript(true)}>Edit Transcript</Button>}
|
||||
</Box>
|
||||
<Typography variant="body1" color="text.secondary">Status: {meeting.status}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>Transcript</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: '300px', maxHeight: 400, overflow: 'auto' }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.transcript || 'No transcript provided. Edit to add one.'}</Typography>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* AI Tools Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h5">AI Tools</Typography>
|
||||
{(canManageMeeting && !isEditingSummary) && <Button startIcon={<EditIcon />} onClick={() => setIsEditingSummary(true)}>Edit Summary</Button>}
|
||||
</Box>
|
||||
|
||||
{canManageMeeting && <Button variant="contained" sx={{ mt: 2 }} onClick={handleGenerateSummary} disabled={!meeting.transcript || summaryTask || isEditingSummary}>{summaryTask ? 'Generating...' : 'Generate Summary'}</Button>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>Summary</Typography>
|
||||
{isEditingSummary ? (
|
||||
<>
|
||||
<TextField label="Summary" multiline rows={8} fullWidth value={editData.summary || ''} onChange={e => setEditData({...editData, summary: e.target.value})} sx={{ mt: 1 }} />
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveSummary}>Save Summary</Button><Button variant="outlined" onClick={() => { setIsEditingSummary(false); setEditData(meeting); }}>Cancel</Button></Box>
|
||||
</>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: 215, overflow: 'auto', position: 'relative' }}>
|
||||
{summaryTask && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<CircularProgress /><Typography sx={{ ml: 2 }}>Generating...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.summary || (summaryTask ? '' : 'No summary generated yet.')}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{canManageMeeting && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button variant="outlined" startIcon={<PreviewIcon />} onClick={handlePreviewActionItems} disabled={isPreviewLoading || isEditingSummary || (!meeting.summary && !meeting.transcript)}>{isPreviewLoading ? <CircularProgress size={24} /> : "Preview Action Items"}</Button>
|
||||
{previewedItems.length > 0 && (
|
||||
<Box>
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}><Table size="small">
|
||||
<TableHead><TableRow><TableCell>Context/Item</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell></TableRow></TableHead>
|
||||
<TableBody>{previewedItems.map((item, index) => (<TableRow key={index}><TableCell>{item.item}</TableCell><TableCell>{item.action}</TableCell><TableCell>{item.owner}</TableCell><TableCell>{item.due_date}</TableCell></TableRow>))}</TableBody>
|
||||
</Table></TableContainer>
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleBatchSave}>Save All to List</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Action Items List Card (Full Width) */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Action Items</Typography>
|
||||
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsAddActionItemOpen(true)}>Add Manually</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead><TableRow><TableCell>Context</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell><TableCell>Status</TableCell><TableCell>Attachment</TableCell><TableCell align="center">Actions</TableCell></TableRow></TableHead>
|
||||
<TableBody>
|
||||
{actionItems.map((item) => {
|
||||
const isEditing = editingActionItemId === item.id;
|
||||
const canEditItem = currentUser && (currentUser.role === 'admin' || String(currentUser.id) === String(item.owner_id));
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{isEditing ? <TextField name="item" defaultValue={item.item} onChange={e => setEditActionItemData({...editActionItemData, item: e.target.value})} fullWidth /> : item.item}</TableCell>
|
||||
<TableCell>{isEditing ? <TextField name="action" defaultValue={item.action} onChange={e => setEditActionItemData({...editActionItemData, action: e.target.value})} fullWidth /> : item.action}</TableCell>
|
||||
<TableCell>{isEditing ? <FormControl fullWidth><Select name="owner_id" value={editActionItemData.owner_id || ''} onChange={e => setEditActionItemData({...editActionItemData, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl> : item.owner_name}</TableCell>
|
||||
<TableCell>{isEditing ? <TextField name="due_date" type="date" defaultValue={editActionItemData.due_date} onChange={e => setEditActionItemData({...editActionItemData, due_date: e.target.value})} InputLabelProps={{ shrink: true }} fullWidth /> : item.due_date}</TableCell>
|
||||
<TableCell>{isEditing ? <Select name="status" value={editActionItemData.status} onChange={e => setEditActionItemData({...editActionItemData, status: e.target.value})} fullWidth><MenuItem value="pending">Pending</MenuItem><MenuItem value="in_progress">In Progress</MenuItem><MenuItem value="completed">Completed</MenuItem></Select> : item.status}</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? <Button component="label" size="small">Upload File<input type="file" hidden onChange={handleFileChange} /></Button> : (item.attachment_path && <IconButton onClick={() => downloadFile(item.attachment_path)}><DownloadIcon /></IconButton>)}
|
||||
{isEditing && attachmentFile && <Typography variant="caption">{attachmentFile.name}</Typography>}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? <Box><IconButton onClick={() => handleSaveActionItemClick(item.id)}><SaveIcon /></IconButton><IconButton onClick={handleCancelActionItemClick}><CancelIcon /></IconButton></Box> : <Box>{canEditItem && <IconButton onClick={() => handleEditActionItemClick(item)}><EditIcon /></IconButton>}{canManageMeeting && <IconButton onClick={() => handleDeleteActionItemClick(item.id)}><DeleteIcon /></IconButton>}</Box>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Dialog open={isAddActionItemOpen} onClose={() => setIsAddActionItemOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Add New Action Item</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Context/Item (Optional)" fullWidth margin="dense" value={newActionItem.item} onChange={e => setNewActionItem({...newActionItem, item: e.target.value})} />
|
||||
<TextField label="Action (Required)" fullWidth margin="dense" required value={newActionItem.action} onChange={e => setNewActionItem({...newActionItem, action: e.target.value})} />
|
||||
<FormControl fullWidth margin="dense"><InputLabel>Owner</InputLabel><Select label="Owner" value={newActionItem.owner_id} onChange={e => setNewActionItem({...newActionItem, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl>
|
||||
<TextField label="Due Date" type="date" fullWidth margin="dense" InputLabelProps={{ shrink: true }} value={newActionItem.due_date} onChange={e => setNewActionItem({...newActionItem, due_date: e.target.value})} />
|
||||
<Button component="label" sx={{ mt: 1 }}>Upload Attachment<input type="file" hidden onChange={handleFileChange} /></Button>
|
||||
{attachmentFile && <Typography variant="caption" sx={{ ml: 1 }}>{attachmentFile.name}</Typography>}
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setIsAddActionItemOpen(false)}>Cancel</Button><Button onClick={handleAddActionItemSave} variant="contained">Save</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingDetailPage;
|
228
frontend/src/pages/ProcessingPage.jsx
Normal file
228
frontend/src/pages/ProcessingPage.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, CircularProgress, Alert, Grid, Card, CardContent, Chip, LinearProgress, TextField, Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import {
|
||||
extractAudio,
|
||||
transcribeAudio,
|
||||
translateText,
|
||||
pollTaskStatus,
|
||||
stopTask,
|
||||
downloadFile
|
||||
} from '../services/api';
|
||||
|
||||
const TaskMonitor = ({ task, onStop, title, children }) => {
|
||||
if (!task) return null;
|
||||
const colorMap = { PENDING: 'default', PROGRESS: 'info', SUCCESS: 'success', FAILURE: 'error', REVOKED: 'warning' };
|
||||
const progress = task.info?.total ? (task.info.current / task.info.total * 100) : null;
|
||||
const isRunning = task.state === 'PENDING' || task.state === 'PROGRESS';
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mt: 2, border: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
{task.state && <Chip label={task.state} color={colorMap[task.state] || 'default'} size="small" />}
|
||||
</Box>
|
||||
{task.info?.status_msg && <Typography variant="caption" sx={{ display: 'block', mt: 1 }}>{task.info.status_msg}</Typography>}
|
||||
{isRunning && !progress && <LinearProgress sx={{ mt: 1 }} />}
|
||||
{progress && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1 }} />}
|
||||
{task.state === 'FAILURE' && <Alert severity="error" sx={{mt:1}}>{task.info?.error || 'Task failed.'}</Alert>}
|
||||
{isRunning &&
|
||||
<Button size="small" color="error" variant="text" onClick={() => onStop(task.task_id)} sx={{mt: 1}}>Stop Task</Button>}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessingPage = () => {
|
||||
const [tasks, setTasks] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
const [copySuccess, setCopySuccess] = useState('');
|
||||
|
||||
const [extractFile, setExtractFile] = useState(null);
|
||||
const [transcribeFile, setTranscribeFile] = useState(null);
|
||||
const [transcribedText, setTranscribedText] = useState('');
|
||||
const [translateFile, setTranslateFile] = useState(null);
|
||||
const [translateTextContent, setTranslateTextContent] = useState('');
|
||||
const [translateLang, setTranslateLang] = useState('繁體中文');
|
||||
const [customLang, setCustomLang] = useState('');
|
||||
const [translatedResult, setTranslatedResult] = useState('');
|
||||
|
||||
const handleCopyToClipboard = (text, type) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopySuccess(type);
|
||||
setTimeout(() => setCopySuccess(''), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTranslateFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setTranslateFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => setTranslateTextContent(evt.target.result);
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
// This function now ONLY updates the main tasks object.
|
||||
const handleTaskUpdate = useCallback((key, updatedTask) => {
|
||||
setTasks(prev => ({ ...prev, [key]: updatedTask }));
|
||||
}, []);
|
||||
|
||||
// This new useEffect handles the side-effects of a task completing.
|
||||
useEffect(() => {
|
||||
const transcribeTask = tasks.transcribe;
|
||||
if (transcribeTask?.state === 'SUCCESS' && transcribeTask.info?.content) {
|
||||
setTranscribedText(transcribeTask.info.content);
|
||||
}
|
||||
|
||||
const translateTask = tasks.translate;
|
||||
if (translateTask?.state === 'SUCCESS' && translateTask.info?.content) {
|
||||
setTranslatedResult(translateTask.info.content);
|
||||
}
|
||||
}, [tasks]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const intervalIds = Object.entries(tasks).map(([key, task]) => {
|
||||
if (task && (task.state === 'PENDING' || task.state === 'PROGRESS')) {
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updatedTask = await pollTaskStatus(task.status_url);
|
||||
// Pass the full task object to avoid stale closures
|
||||
handleTaskUpdate(key, { ...task, ...updatedTask });
|
||||
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
} catch (err) {
|
||||
handleTaskUpdate(key, { ...task, state: 'FAILURE', info: { ...task.info, error: 'Polling failed.' } });
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 2000);
|
||||
return intervalId;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
return () => intervalIds.forEach(clearInterval);
|
||||
}, [tasks, handleTaskUpdate]);
|
||||
|
||||
const handleStartTask = async (key, taskFn, ...args) => {
|
||||
setError('');
|
||||
setTasks(prev => ({ ...prev, [key]: { state: 'PENDING', info: { status_msg: 'Initializing...' } } }));
|
||||
try {
|
||||
const result = await taskFn(...args);
|
||||
setTasks(prev => ({ ...prev, [key]: { ...prev[key], task_id: result.task_id, status_url: result.status_url, state: 'PENDING' } }));
|
||||
} catch (err) {
|
||||
const errorMsg = err.response?.data?.error || `Failed to start ${key} task.`;
|
||||
setError(errorMsg);
|
||||
setTasks(prev => ({ ...prev, [key]: { state: 'FAILURE', info: { error: errorMsg } } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTask = async (taskId) => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await stopTask(taskId);
|
||||
const taskKey = Object.keys(tasks).find(k => tasks[k].task_id === taskId);
|
||||
if (taskKey) setTasks(prev => ({ ...prev, [taskKey]: { ...prev[taskKey], state: 'REVOKED' } }));
|
||||
} catch (err) {
|
||||
setError('Failed to stop the task.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Processing Tools</Typography>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Column: Extract Audio */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">1. Extract Audio</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>Extract audio track from a video file.</Typography>
|
||||
<Button variant="contained" component="label" fullWidth>Upload Video<input type="file" hidden onChange={e => setExtractFile(e.target.files[0])} /></Button>
|
||||
{extractFile && <Typography sx={{ mt: 1, fontStyle: 'italic', textAlign: 'center' }}>{extractFile.name}</Typography>}
|
||||
<Button size="small" variant="outlined" disabled={!extractFile} onClick={() => handleStartTask('extract', extractAudio, extractFile)} sx={{ display: 'block', mt: 2, mx: 'auto' }}>Start Extraction</Button>
|
||||
<TaskMonitor task={tasks.extract} onStop={handleStopTask} title="Extraction Progress">
|
||||
{tasks.extract?.state === 'SUCCESS' && tasks.extract.info.download_filename &&
|
||||
<Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.extract.info.download_filename)}>Download Audio</Button>
|
||||
}
|
||||
</TaskMonitor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column: Transcribe and Translate */}
|
||||
<Grid item xs={12} md={9}>
|
||||
<Grid container spacing={3} direction="column">
|
||||
{/* Top-Right: Transcribe */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">2. Transcribe Audio to Text</Typography>
|
||||
<Button variant="contained" component="label" sx={{ mt: 2 }}>Upload Audio<input type="file" hidden onChange={e => setTranscribeFile(e.target.files[0])} /></Button>
|
||||
{transcribeFile && <Typography sx={{ display: 'inline', ml: 2, fontStyle: 'italic' }}>{transcribeFile.name}</Typography>}
|
||||
<Button size="small" variant="outlined" disabled={!transcribeFile} onClick={() => handleStartTask('transcribe', transcribeAudio, transcribeFile)} sx={{ ml: 2, mt: 2 }}>Start Transcription</Button>
|
||||
<TaskMonitor task={tasks.transcribe} onStop={handleStopTask} title="Transcription Progress" />
|
||||
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
|
||||
{(tasks.transcribe?.state === 'PENDING' || tasks.transcribe?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
|
||||
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{transcribedText || 'Transcription will appear here.'}</Typography>
|
||||
{transcribedText && <Tooltip title={copySuccess === 'transcribed' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(transcribedText, 'transcribed')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Paper>
|
||||
{tasks.transcribe?.state === 'SUCCESS' && tasks.transcribe.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.transcribe.info.result_path)}>Download Transcript (.txt)</Button>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Bottom-Right: Translate */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">3. Translate Text</Typography>
|
||||
<TextField label="Paste Text or Upload .txt" multiline rows={4} fullWidth value={translateTextContent} onChange={e => setTranslateTextContent(e.target.value)} sx={{ mt: 2 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" component="label" size="small">Upload .txt File<input type="file" accept=".txt" hidden onChange={handleTranslateFileUpload} /></Button>
|
||||
{translateFile && <Typography sx={{ ml: 2, fontStyle: 'italic' }}>{translateFile.name}</Typography>}
|
||||
<Box sx={{flexGrow: 1}} />
|
||||
<FormControl size="small" sx={{ minWidth: 150, mr: 1, mt: { xs: 2, sm: 0 } }}>
|
||||
<InputLabel>Target Language</InputLabel>
|
||||
<Select value={translateLang} label="Target Language" onChange={e => setTranslateLang(e.target.value)}>
|
||||
<MenuItem value="繁體中文">繁體中文</MenuItem>
|
||||
<MenuItem value="简体中文">简体中文</MenuItem>
|
||||
<MenuItem value="English">English</MenuItem>
|
||||
<MenuItem value="Japanese">Japanese (日本語)</MenuItem>
|
||||
<MenuItem value="Korean">Korean (한국어)</MenuItem>
|
||||
<MenuItem value="Thai">Thai (ภาษาไทย)</MenuItem>
|
||||
<MenuItem value="Vietnamese">Vietnamese (Tiếng Việt)</MenuItem>
|
||||
<MenuItem value="French">French (Français)</MenuItem>
|
||||
<MenuItem value="German">German (Deutsch)</MenuItem>
|
||||
<MenuItem value="Spanish">Spanish (Español)</MenuItem>
|
||||
<MenuItem value="Russian">Russian (Русский)</MenuItem>
|
||||
<MenuItem value="Other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{translateLang === 'Other' && <TextField label="Specify" size="small" value={customLang} onChange={e => setCustomLang(e.target.value)} sx={{ mt: { xs: 2, sm: 0 } }} />}
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" disabled={!translateTextContent || (translateLang === 'Other' && !customLang)} onClick={() => handleStartTask('translate', translateText, translateTextContent, translateLang === 'Other' ? customLang : translateLang)} sx={{ display: 'block', mt: 2 }}>Start Translation</Button>
|
||||
<TaskMonitor task={tasks.translate} onStop={handleStopTask} title="Translation Progress" />
|
||||
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
|
||||
{(tasks.translate?.state === 'PENDING' || tasks.translate?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
|
||||
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{translatedResult || 'Translation will appear here.'}</Typography>
|
||||
{translatedResult && <Tooltip title={copySuccess === 'translated' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(translatedResult, 'translated')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Paper>
|
||||
{tasks.translate?.state === 'SUCCESS' && tasks.translate.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.translate.info.result_path)}>Download Translation (.txt)</Button>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessingPage;
|
94
frontend/src/services/api.js
Normal file
94
frontend/src/services/api.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const unwrap = (promise) => promise.then((r) => r.data);
|
||||
|
||||
// --- Task Management ---
|
||||
export const pollTaskStatus = (statusUrl) => unwrap(api.get(statusUrl));
|
||||
export const stopTask = (taskId) => unwrap(api.post(`/task/${taskId}/stop`));
|
||||
export const downloadFile = async (filename) => {
|
||||
const res = await api.get(`/download/${filename}`, { responseType: 'blob' });
|
||||
const blob = new Blob([res.data], { type: res.headers['content-type'] });
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
|
||||
// --- Authentication ---
|
||||
export const login = (username, password) =>
|
||||
unwrap(api.post('/login', { username, password }));
|
||||
|
||||
export const register = (username, password) =>
|
||||
unwrap(api.post('/register', { username, password }));
|
||||
|
||||
// --- Admin ---
|
||||
export const getUsers = () => unwrap(api.get('/admin/users')); // For Admin Page
|
||||
export const getAllUsers = () => unwrap(api.get('/users')); // For dropdowns
|
||||
export const createUser = (userData) => unwrap(api.post('/admin/users', userData));
|
||||
export const deleteUser = (userId) => unwrap(api.delete(`/admin/users/${userId}`));
|
||||
export const changeUserPassword = (userId, password) =>
|
||||
unwrap(api.put(`/admin/users/${userId}/password`, { password }));
|
||||
|
||||
// --- Meetings ---
|
||||
export const getMeetings = () => unwrap(api.get('/meetings'));
|
||||
export const createMeeting = (topic, meetingDate) => unwrap(api.post('/meetings', { topic, meeting_date: meetingDate }));
|
||||
export const getMeetingDetails = (meetingId) => unwrap(api.get(`/meetings/${meetingId}`));
|
||||
export const updateMeeting = (meetingId, data) => unwrap(api.put(`/meetings/${meetingId}`, data));
|
||||
export const deleteMeeting = (meetingId) => unwrap(api.delete(`/meetings/${meetingId}`));
|
||||
export const summarizeMeeting = (meetingId) => unwrap(api.post(`/meetings/${meetingId}/summarize`));
|
||||
|
||||
// --- Independent Tools ---
|
||||
const startFileUploadTask = async (endpoint, file, options = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
for (const key in options) {
|
||||
formData.append(key, options[key]);
|
||||
}
|
||||
return unwrap(api.post(endpoint, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}));
|
||||
};
|
||||
|
||||
export const extractAudio = (file) =>
|
||||
startFileUploadTask('/tools/extract_audio', file);
|
||||
|
||||
export const transcribeAudio = (file) =>
|
||||
startFileUploadTask('/tools/transcribe_audio', file);
|
||||
|
||||
export const translateText = (text, target_language) =>
|
||||
unwrap(api.post('/tools/translate_text', { text, target_language }));
|
||||
|
||||
// --- AI Previews (for Meeting Page) ---
|
||||
export const previewActionItems = (text) =>
|
||||
unwrap(api.post('/action-items/preview', { text }));
|
||||
|
||||
// --- Action Items ---
|
||||
export const getActionItemsForMeeting = (meetingId) => unwrap(api.get(`/meetings/${meetingId}/action_items`));
|
||||
export const createActionItem = (payload) => unwrap(api.post('/action-items', payload));
|
||||
export const batchSaveActionItems = (meetingId, items) => unwrap(api.post(`/meetings/${meetingId}/action-items/batch`, { items }));
|
||||
export const updateActionItem = (itemId, updateData) => unwrap(api.put(`/action_items/${itemId}`, updateData));
|
||||
export const deleteActionItem = (itemId) => unwrap(api.delete(`/action_items/${itemId}`));
|
||||
export const uploadActionItemAttachment = (itemId, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return unwrap(api.post(`/action_items/${itemId}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}));
|
||||
};
|
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:12000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
BIN
migrations/__pycache__/env.cpython-312.pyc
Normal file
BIN
migrations/__pycache__/env.cpython-312.pyc
Normal file
Binary file not shown.
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
104
migrations/env.py
Normal file
104
migrations/env.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
# --- NEW --- Function to filter tables by prefix
|
||||
def include_name(name, type_, parent_names):
|
||||
if type_ == 'table':
|
||||
return name.startswith('ms_')
|
||||
else:
|
||||
return True
|
||||
# --- END NEW ---
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
# --- MODIFIED --- Add the include_name filter
|
||||
include_name=include_name,
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
@@ -0,0 +1,34 @@
|
||||
"""Implement user-centric status and transcript field for meetings
|
||||
|
||||
Revision ID: 919aff0aa44b
|
||||
Revises: ac069534da31
|
||||
Create Date: 2025-08-15 09:20:02.369230
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '919aff0aa44b'
|
||||
down_revision = 'ac069534da31'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('ms_meetings', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('transcript', sa.Text(), nullable=True))
|
||||
batch_op.drop_column('transcript_file_path')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('ms_meetings', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('transcript_file_path', mysql.VARCHAR(length=512), nullable=True))
|
||||
batch_op.drop_column('transcript')
|
||||
|
||||
# ### end Alembic commands ###
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
"""Add status and result fields to Meeting
|
||||
|
||||
Revision ID: ac069534da31
|
||||
Revises:
|
||||
Create Date: 2025-08-15 08:38:19.572077
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ac069534da31'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('ms_action_items', schema=None) as batch_op:
|
||||
batch_op.alter_column('owner_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
nullable=True)
|
||||
|
||||
with op.batch_alter_table('ms_meetings', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('status', sa.String(length=50), nullable=False))
|
||||
batch_op.add_column(sa.Column('transcript_file_path', sa.String(length=512), nullable=True))
|
||||
batch_op.add_column(sa.Column('summary', sa.Text(), nullable=True))
|
||||
batch_op.alter_column('created_by_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('ms_meetings', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_by_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
nullable=False)
|
||||
batch_op.drop_column('summary')
|
||||
batch_op.drop_column('transcript_file_path')
|
||||
batch_op.drop_column('status')
|
||||
|
||||
with op.batch_alter_table('ms_action_items', schema=None) as batch_op:
|
||||
batch_op.alter_column('owner_id',
|
||||
existing_type=mysql.INTEGER(),
|
||||
nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
93
models.py
Normal file
93
models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.sql import func
|
||||
from flask_bcrypt import Bcrypt
|
||||
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'ms_users'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default='user') # 'user' or 'admin'
|
||||
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
def check_password(self, password):
|
||||
return bcrypt.check_password_hash(self.password_hash, password)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'role': self.role,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
class Meeting(db.Model):
|
||||
__tablename__ = 'ms_meetings'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
topic = db.Column(db.String(255), nullable=False)
|
||||
meeting_date = db.Column(db.DateTime(timezone=True), nullable=False)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('ms_users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# New user-centric status
|
||||
status = db.Column(db.String(50), nullable=False, default='To Do') # 'To Do', 'In Progress', 'Completed'
|
||||
|
||||
# Field to store the pasted transcript
|
||||
transcript = db.Column(db.Text, nullable=True)
|
||||
|
||||
# AI-generated summary
|
||||
summary = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Removed transcript_file_path as it's no longer needed in this workflow
|
||||
|
||||
creator = db.relationship('User', backref=db.backref('meetings', lazy=True))
|
||||
action_items = db.relationship('ActionItem', backref='meeting', lazy='dynamic', cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'topic': self.topic,
|
||||
'meeting_date': self.meeting_date.isoformat() if self.meeting_date else None,
|
||||
'created_by_id': self.created_by_id,
|
||||
'owner_name': self.creator.username if self.creator else None,
|
||||
'created_at': f"{self.created_at.isoformat()}Z" if self.created_at else None,
|
||||
'action_item_count': self.action_items.count(),
|
||||
'status': self.status,
|
||||
'transcript': self.transcript,
|
||||
'summary': self.summary,
|
||||
}
|
||||
|
||||
class ActionItem(db.Model):
|
||||
__tablename__ = 'ms_action_items'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meeting_id = db.Column(db.Integer, db.ForeignKey('ms_meetings.id'), nullable=False)
|
||||
item = db.Column(db.Text, nullable=True)
|
||||
action = db.Column(db.Text, nullable=False)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('ms_users.id'), nullable=True)
|
||||
due_date = db.Column(db.Date, nullable=True)
|
||||
status = db.Column(db.String(50), nullable=False, default='pending') # e.g., 'pending', 'in_progress', 'completed'
|
||||
attachment_path = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
owner = db.relationship('User', backref=db.backref('action_items', lazy=True))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'meeting_id': self.meeting_id,
|
||||
'meeting_topic': self.meeting.topic if self.meeting else None,
|
||||
'item': self.item,
|
||||
'action': self.action,
|
||||
'owner_id': self.owner_id,
|
||||
'owner_name': self.owner.username if self.owner else None,
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'status': self.status,
|
||||
'attachment_path': self.attachment_path,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
Flask==2.2.5
|
||||
celery==5.3.6
|
||||
redis==4.5.4
|
||||
# For NVIDIA GPU (CUDA 11.8) support, use these lines:
|
||||
torch --extra-index-url https://download.pytorch.org/whl/cu118
|
||||
torchaudio --extra-index-url https://download.pytorch.org/whl/cu118
|
||||
# For CPU-only, comment out the two lines above and uncomment the two lines below:
|
||||
# torch
|
||||
# torchaudio
|
||||
openai-whisper
|
||||
moviepy
|
||||
opencc-python-reimplemented
|
||||
ffmpeg-python
|
||||
python-dotenv
|
||||
gunicorn
|
||||
demucs
|
||||
soundfile
|
||||
gevent # Added for celery on windows
|
||||
|
||||
# New dependencies for User Management and Database
|
||||
Flask-SQLAlchemy
|
||||
Flask-Migrate
|
||||
PyMySQL
|
||||
Flask-JWT-Extended
|
||||
Flask-Bcrypt
|
||||
|
||||
# Dependency for calling external APIs
|
||||
requests
|
BIN
services/__pycache__/dify_client.cpython-312.pyc
Normal file
BIN
services/__pycache__/dify_client.cpython-312.pyc
Normal file
Binary file not shown.
93
services/dify_client.py
Normal file
93
services/dify_client.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# services/dify_client.py
|
||||
import os, json, re, requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DIFY_BASE = os.getenv("DIFY_API_BASE_URL", "https://api.dify.ai/v1")
|
||||
TIMEOUT = 60
|
||||
|
||||
def _post_request(endpoint: str, api_key: str, payload: dict):
|
||||
"""Generic function to post a request to a Dify endpoint."""
|
||||
if not api_key:
|
||||
raise RuntimeError("Dify API key is not set")
|
||||
|
||||
url = f"{DIFY_BASE}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# For debugging the exact payload sent to the API
|
||||
print(f"Sending Dify request to {url}: {json.dumps(payload, indent=2, ensure_ascii=False)}")
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=TIMEOUT)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f"Dify API Error Response: {resp.text}")
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
return data.get("answer") or data
|
||||
|
||||
def translate_text(text: str, target_lang: str, user_id: str = "system") -> str:
|
||||
"""
|
||||
Calls the Dify CHAT API for translation, mimicking a user query.
|
||||
"""
|
||||
api_key = os.getenv("DIFY_TRANSLATOR_API_KEY")
|
||||
# Combine all information into a single query string, as expected by the prompt
|
||||
query = f"目標語言:{target_lang}\n需翻譯內容:\n{text}"
|
||||
|
||||
payload = {
|
||||
"inputs": {}, # Chatbot apps generally don't use inputs here
|
||||
"response_mode": "blocking",
|
||||
"user": user_id,
|
||||
"query": query,
|
||||
# conversation_id is optional for single-turn interactions
|
||||
}
|
||||
return _post_request("/chat-messages", api_key, payload)
|
||||
|
||||
def summarize_text(text: str, user_id: str = "system") -> str:
|
||||
api_key = os.getenv("DIFY_SUMMARIZER_API_KEY")
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"response_mode": "blocking",
|
||||
"user": user_id,
|
||||
"query": text,
|
||||
}
|
||||
return _post_request("/chat-messages", api_key, payload)
|
||||
|
||||
def extract_action_items(text: str, user_id: str = "system") -> list[dict]:
|
||||
api_key = os.getenv("DIFY_ACTION_EXTRACTOR_API_KEY")
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"response_mode": "blocking",
|
||||
"user": user_id,
|
||||
"query": text,
|
||||
}
|
||||
raw = _post_request("/chat-messages", api_key, payload)
|
||||
|
||||
# Fault tolerance for JSON parsing
|
||||
s = str(raw).strip()
|
||||
s = re.sub(r"^```(?:json)?|```$", "", s, flags=re.IGNORECASE|re.MULTILINE).strip()
|
||||
if not (s.startswith("[") and s.endswith("]")):
|
||||
m = re.search(r"[\s\S]*\[[\s\S]*\][\s\S]*", s)
|
||||
if m: s = m.group(0)
|
||||
|
||||
items = json.loads(s)
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("Extractor did not return a list")
|
||||
|
||||
# Normalize keys for database storage
|
||||
normalized = []
|
||||
for i in items:
|
||||
for k in ("item", "action", "owner", "duedate"):
|
||||
if k not in i:
|
||||
raise ValueError(f"Extractor item is missing required key: {k}")
|
||||
normalized.append({
|
||||
"item": i["item"],
|
||||
"action": i["action"],
|
||||
"owner": i["owner"],
|
||||
"due_date": i["duedate"],
|
||||
})
|
||||
return normalized
|
38
static/css/style.css
Normal file
38
static/css/style.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* static/css/style.css */
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.card-header-tabs {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border-color: #dee2e6 #dee2e6 #fff;
|
||||
}
|
||||
|
||||
.result-preview {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
275
static/js/script.js
Normal file
275
static/js/script.js
Normal file
@@ -0,0 +1,275 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Global variables ---
|
||||
let statusInterval;
|
||||
let currentTaskType = '';
|
||||
let summaryConversationId = null;
|
||||
let lastSummaryText = '';
|
||||
|
||||
// --- DOM Elements ---
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const resultContainer = document.getElementById('result-container');
|
||||
const textResultPreview = document.getElementById('text-result-preview');
|
||||
const downloadLink = document.getElementById('download-link');
|
||||
const revisionArea = document.getElementById('revision-area');
|
||||
const allActionButtons = document.querySelectorAll('.action-btn');
|
||||
|
||||
// --- Tab Switching Logic ---
|
||||
const tabButtons = document.querySelectorAll('#myTab button');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('shown.bs.tab', function() {
|
||||
resetUiForNewTask();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Event Listeners for all action buttons ---
|
||||
allActionButtons.forEach(button => {
|
||||
button.addEventListener('click', handleActionClick);
|
||||
});
|
||||
|
||||
function handleActionClick(event) {
|
||||
const button = event.currentTarget;
|
||||
currentTaskType = button.dataset.task;
|
||||
|
||||
resetUiForNewTask();
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 處理中...';
|
||||
progressContainer.style.display = 'block';
|
||||
|
||||
if (currentTaskType === 'summarize_text') {
|
||||
const fileInput = document.getElementById('summary-file-input');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const fileContent = e.target.result;
|
||||
startSummarizeTask(fileContent);
|
||||
};
|
||||
reader.onerror = function() {
|
||||
handleError("讀取檔案時發生錯誤。");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
const textContent = document.getElementById('summary-source-text').value;
|
||||
if (!textContent.trim()) {
|
||||
alert('請貼上文字或選擇檔案!');
|
||||
resetButtons();
|
||||
return;
|
||||
}
|
||||
startSummarizeTask(textContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = '';
|
||||
let formData = new FormData();
|
||||
let body = null;
|
||||
let fileInput;
|
||||
|
||||
switch (currentTaskType) {
|
||||
case 'extract_audio':
|
||||
endpoint = '/extract_audio';
|
||||
fileInput = document.getElementById('video-file');
|
||||
break;
|
||||
|
||||
case 'transcribe_audio':
|
||||
endpoint = '/transcribe_audio';
|
||||
fileInput = document.getElementById('audio-file');
|
||||
formData.append('language', document.getElementById('lang-select').value);
|
||||
if (document.getElementById('use-demucs').checked) {
|
||||
formData.append('use_demucs', 'on');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'translate_text':
|
||||
endpoint = '/translate_text';
|
||||
fileInput = document.getElementById('transcript-file');
|
||||
formData.append('target_language', document.getElementById('translate-lang-select').value);
|
||||
break;
|
||||
|
||||
case 'revise_summary':
|
||||
endpoint = '/summarize_text';
|
||||
const instruction = document.getElementById('revision-instruction').value;
|
||||
if (!lastSummaryText) { alert('請先生成初版結論!'); resetButtons(); return; }
|
||||
if (!instruction.trim()) { alert('請輸入修改指示!'); resetButtons(); return; }
|
||||
body = JSON.stringify({
|
||||
text_content: lastSummaryText,
|
||||
revision_instruction: instruction,
|
||||
target_language: document.getElementById('summary-lang-select').value,
|
||||
conversation_id: summaryConversationId
|
||||
});
|
||||
startFetchTask(endpoint, body, { 'Content-Type': 'application/json' });
|
||||
return;
|
||||
|
||||
default:
|
||||
console.error('Unknown task type:', currentTaskType);
|
||||
resetButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileInput || !fileInput.files[0]) {
|
||||
alert('請選擇一個檔案!');
|
||||
resetButtons();
|
||||
return;
|
||||
}
|
||||
formData.append('file', fileInput.files[0]);
|
||||
body = formData;
|
||||
|
||||
startFetchTask(endpoint, body);
|
||||
}
|
||||
|
||||
function startSummarizeTask(textContent) {
|
||||
summaryConversationId = null;
|
||||
lastSummaryText = textContent;
|
||||
const body = JSON.stringify({
|
||||
text_content: textContent,
|
||||
target_language: document.getElementById('summary-lang-select').value
|
||||
});
|
||||
startFetchTask('/summarize_text', body, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
function startFetchTask(endpoint, body, headers = {}) {
|
||||
updateProgress(0, '準備上傳與處理...');
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: headers
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => { throw new Error(err.error || '伺服器錯誤') });
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.task_id) {
|
||||
statusInterval = setInterval(() => checkTaskStatus(data.status_url), 2000);
|
||||
} else {
|
||||
handleError(data.error || '未能啟動背景任務');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
handleError(error.message || '請求失敗');
|
||||
});
|
||||
}
|
||||
|
||||
function checkTaskStatus(statusUrl) {
|
||||
fetch(statusUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const info = data.info || {};
|
||||
if (data.state === 'PROGRESS') {
|
||||
updateProgress(info.current, info.status, info.total);
|
||||
const previewContent = info.content || info.summary || info.preview;
|
||||
if (previewContent) {
|
||||
resultContainer.style.display = 'block';
|
||||
textResultPreview.textContent = previewContent;
|
||||
textResultPreview.style.display = 'block';
|
||||
}
|
||||
} else if (data.state === 'SUCCESS') {
|
||||
clearInterval(statusInterval);
|
||||
updateProgress(100, info.status || '完成!', 100);
|
||||
displayResult(info);
|
||||
resetButtons();
|
||||
} else if (data.state === 'FAILURE') {
|
||||
clearInterval(statusInterval);
|
||||
handleError(info.exc_message || '任務執行失敗');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
clearInterval(statusInterval);
|
||||
handleError('查詢進度時發生網路錯誤: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress(current, text, total = 100) {
|
||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
progressBar.style.width = percent + '%';
|
||||
progressBar.setAttribute('aria-valuenow', percent);
|
||||
progressBar.textContent = percent + '%';
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
function displayResult(info) {
|
||||
resultContainer.style.display = 'block';
|
||||
|
||||
const content = info.content || info.summary;
|
||||
if (content) {
|
||||
textResultPreview.textContent = content;
|
||||
textResultPreview.style.display = 'block';
|
||||
lastSummaryText = content;
|
||||
} else {
|
||||
textResultPreview.style.display = 'none';
|
||||
}
|
||||
|
||||
if (info.download_url) {
|
||||
downloadLink.href = info.download_url;
|
||||
downloadLink.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
if (currentTaskType === 'summarize_text' || currentTaskType === 'revise_summary') {
|
||||
revisionArea.style.display = 'block';
|
||||
summaryConversationId = info.conversation_id;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(message) {
|
||||
statusText.textContent = `錯誤:${message}`;
|
||||
progressBar.classList.add('bg-danger');
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
function resetUiForNewTask() {
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
|
||||
progressContainer.style.display = 'none';
|
||||
resultContainer.style.display = 'none';
|
||||
textResultPreview.style.display = 'none';
|
||||
textResultPreview.textContent = '';
|
||||
downloadLink.style.display = 'none';
|
||||
revisionArea.style.display = 'none';
|
||||
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.setAttribute('aria-valuenow', 0);
|
||||
progressBar.textContent = '0%';
|
||||
progressBar.classList.remove('bg-danger');
|
||||
statusText.textContent = '';
|
||||
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
function resetButtons() {
|
||||
allActionButtons.forEach(button => {
|
||||
button.disabled = false;
|
||||
const task = button.dataset.task;
|
||||
let iconHtml = '';
|
||||
let text = '';
|
||||
|
||||
switch(task) {
|
||||
case 'extract_audio':
|
||||
iconHtml = '<i class="bi bi-arrow-repeat me-2"></i>';
|
||||
text = '開始轉換';
|
||||
break;
|
||||
case 'transcribe_audio':
|
||||
iconHtml = '<i class="bi bi-mic-fill me-2"></i>';
|
||||
text = '開始轉錄';
|
||||
break;
|
||||
case 'translate_text':
|
||||
iconHtml = '<i class="bi bi-translate me-2"></i>';
|
||||
text = '開始翻譯';
|
||||
break;
|
||||
case 'summarize_text':
|
||||
iconHtml = '<i class="bi bi-card-text me-2"></i>';
|
||||
text = '產生初版結論';
|
||||
break;
|
||||
case 'revise_summary':
|
||||
iconHtml = '<i class="bi bi-pencil-square me-2"></i>';
|
||||
text = '根據指示產生修改版';
|
||||
break;
|
||||
}
|
||||
button.innerHTML = iconHtml + text;
|
||||
});
|
||||
}
|
||||
});
|
431
tasks.py
Normal file
431
tasks.py
Normal file
@@ -0,0 +1,431 @@
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
import io
|
||||
from datetime import timedelta
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from moviepy import VideoFileClip
|
||||
from celery_app import celery
|
||||
from models import db, Meeting
|
||||
import math
|
||||
|
||||
from celery import Task
|
||||
|
||||
class ProgressTask(Task):
|
||||
def update_progress(self, current, total, status_msg, extra_info=None):
|
||||
meta = {'current': current, 'total': total, 'status_msg': status_msg}
|
||||
if extra_info and isinstance(extra_info, dict):
|
||||
meta.update(extra_info)
|
||||
self.update_state(state='PROGRESS', meta=meta)
|
||||
|
||||
# --- Dify Helper Functions ---
|
||||
def ask_dify(api_key: str, query: str, **kwargs):
|
||||
from flask import current_app
|
||||
DIFY_API_BASE_URL = current_app.config.get("DIFY_API_BASE_URL")
|
||||
if not api_key or not DIFY_API_BASE_URL:
|
||||
return {"answer": "Error: DIFY API settings not configured."}
|
||||
|
||||
url = f"{DIFY_API_BASE_URL}/chat-messages"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"inputs": kwargs.get("inputs", {}),
|
||||
"query": query,
|
||||
"user": kwargs.get("user_id", "default-tk-user"),
|
||||
"response_mode": kwargs.get("response_mode", "blocking"),
|
||||
"conversation_id": kwargs.get("conversation_id")
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=kwargs.get("timeout_seconds", 1200))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"answer": f"Dify API request error: {e}"}
|
||||
|
||||
def upload_chunk_to_dify(blob: bytes, filename="chunk.mp4"):
|
||||
from flask import current_app
|
||||
DIFY_API_BASE_URL = current_app.config.get("DIFY_API_BASE_URL")
|
||||
API_KEY = current_app.config.get("DIFY_STT_API_KEY")
|
||||
print(f"DEBUG: In upload_chunk_to_dify, using DIFY_STT_API_KEY: '{API_KEY}'") # FINAL DEBUG
|
||||
|
||||
r = requests.post(
|
||||
f"{DIFY_API_BASE_URL}/files/upload",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
files={"file": (filename, io.BytesIO(blob), "audio/mp4")},
|
||||
data={"user": "ai-meeting-assistant-user"},
|
||||
timeout=300
|
||||
)
|
||||
print("Dify File Upload API Response:", r.status_code, r.text) # DEBUG PRINT
|
||||
r.raise_for_status()
|
||||
return r.json()["id"]
|
||||
|
||||
def run_dify_stt_chat_app(file_id: str) -> str:
|
||||
"""
|
||||
透過呼叫 Dify 的 chat-messages API 來執行語音轉文字。
|
||||
適用於「進階對話型」應用。
|
||||
"""
|
||||
from flask import current_app
|
||||
DIFY_API_BASE_URL = current_app.config.get("DIFY_API_BASE_URL")
|
||||
API_KEY = current_app.config.get("DIFY_STT_API_KEY")
|
||||
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"query": "請將音檔轉換為文字",
|
||||
"user": "ai-meeting-assistant-user",
|
||||
"response_mode": "blocking",
|
||||
"files": [
|
||||
{
|
||||
"type": "audio",
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": file_id
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
r = requests.post(
|
||||
f"{DIFY_API_BASE_URL}/chat-messages",
|
||||
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=600
|
||||
)
|
||||
|
||||
print("Dify STT API Response:", r.status_code, r.text) # DEBUG PRINT
|
||||
r.raise_for_status()
|
||||
|
||||
j = r.json()
|
||||
# 在對話型應用中,答案通常在 'answer' 欄位
|
||||
return j.get("answer", "").strip()
|
||||
|
||||
# --- Timestamp & Audio Chunking Helpers ---
|
||||
def format_timestamp_from_ms(ms: int) -> str:
|
||||
td = timedelta(milliseconds=ms)
|
||||
total_seconds = td.total_seconds()
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
milliseconds = td.microseconds // 1000
|
||||
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{milliseconds:03d}"
|
||||
|
||||
def export_audio_bytes(seg: AudioSegment) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
seg.export(buf, format="mp4", bitrate="64k")
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
def ensure_chunk_under_limits(seg: AudioSegment):
|
||||
MAX_SEG_MS = 20 * 60 * 1000 # Max duration 20 minutes
|
||||
MAX_BYTES = 24 * 1024 * 1024 # Dify file limit is 25MB, use 24MB as a safe buffer
|
||||
|
||||
if len(export_audio_bytes(seg)) < MAX_BYTES:
|
||||
return [seg]
|
||||
|
||||
# If segment is too large, split by duration first
|
||||
parts = []
|
||||
start_ms = 0
|
||||
while start_ms < len(seg):
|
||||
end_ms = min(start_ms + MAX_SEG_MS, len(seg))
|
||||
parts.append(seg[start_ms:end_ms])
|
||||
start_ms = end_ms
|
||||
|
||||
# Then, double-check byte size for each part
|
||||
final_parts = []
|
||||
for p in parts:
|
||||
if len(export_audio_bytes(p)) < MAX_BYTES:
|
||||
final_parts.append(p)
|
||||
else:
|
||||
# Fallback for rare cases: split in half until compliant
|
||||
sub_parts = [p]
|
||||
while sub_parts:
|
||||
current_part = sub_parts.pop(0)
|
||||
if len(export_audio_bytes(current_part)) < MAX_BYTES:
|
||||
final_parts.append(current_part)
|
||||
continue
|
||||
|
||||
mid_point = len(current_part) // 2
|
||||
if mid_point < 1000: # Stop splitting if it's less than a second
|
||||
final_parts.append(current_part)
|
||||
continue
|
||||
|
||||
sub_parts.extend([current_part[:mid_point], current_part[mid_point:]])
|
||||
return final_parts
|
||||
|
||||
# --- Celery Tasks ---
|
||||
@celery.task(base=ProgressTask, bind=True)
|
||||
def extract_audio_task(self, input_path, output_path):
|
||||
try:
|
||||
self.update_progress(0, 100, "Starting audio extraction...")
|
||||
with VideoFileClip(input_path) as video:
|
||||
video.audio.write_audiofile(output_path)
|
||||
self.update_progress(100, 100, "Audio extracted successfully.")
|
||||
return {'status': 'Success', 'result_path': output_path}
|
||||
except Exception as e:
|
||||
self.update_state(state='FAILURE', meta={'exc_type': type(e).__name__, 'exc_message': str(e)})
|
||||
|
||||
@celery.task(base=ProgressTask, bind=True)
|
||||
def transcribe_audio_task(self, audio_path):
|
||||
from app import app
|
||||
with app.app_context():
|
||||
try:
|
||||
self.update_progress(0, 100, "Loading and preparing audio file...")
|
||||
audio = AudioSegment.from_file(audio_path)
|
||||
|
||||
# 1. Split audio by silence
|
||||
self.update_progress(5, 100, "Detecting silence to split audio into chunks...")
|
||||
chunks = split_on_silence(
|
||||
audio,
|
||||
min_silence_len=700,
|
||||
silence_thresh=-40,
|
||||
keep_silence=300
|
||||
)
|
||||
if not chunks: # If no silence is detected, treat the whole audio as one chunk
|
||||
chunks = [audio]
|
||||
|
||||
# 2. Process chunks and ensure they are within API limits
|
||||
final_segments = []
|
||||
cursor_ms = 0
|
||||
for chunk in chunks:
|
||||
start_time = cursor_ms
|
||||
end_time = start_time + len(chunk)
|
||||
|
||||
safe_parts = ensure_chunk_under_limits(chunk)
|
||||
part_start_time = start_time
|
||||
for part in safe_parts:
|
||||
part_end_time = part_start_time + len(part)
|
||||
final_segments.append({
|
||||
"start": part_start_time,
|
||||
"end": part_end_time,
|
||||
"segment": part
|
||||
})
|
||||
part_start_time = part_end_time
|
||||
cursor_ms = end_time
|
||||
|
||||
# 3. Upload chunks to Dify and get transcriptions
|
||||
transcribed_lines = []
|
||||
total_segments = len(final_segments)
|
||||
for i, seg_data in enumerate(final_segments):
|
||||
progress = 10 + int((i / total_segments) * 85)
|
||||
self.update_progress(progress, 100, f"Processing chunk {i+1} of {total_segments}...")
|
||||
|
||||
audio_bytes = export_audio_bytes(seg_data["segment"])
|
||||
file_id = upload_chunk_to_dify(audio_bytes, f"chunk_{i+1}.mp4")
|
||||
transcribed_text = run_dify_stt_chat_app(file_id).strip()
|
||||
|
||||
if transcribed_text:
|
||||
start_ts = format_timestamp_from_ms(seg_data["start"])
|
||||
end_ts = format_timestamp_from_ms(seg_data["end"])
|
||||
transcribed_lines.append(f"[{start_ts} - {end_ts}] {transcribed_text}")
|
||||
|
||||
# 4. Finalize and save the result
|
||||
self.update_progress(98, 100, "Finalizing transcript...")
|
||||
full_content = "\n".join(transcribed_lines)
|
||||
|
||||
transcript_filename = f"transcript_{uuid.uuid4()}.txt"
|
||||
output_txt_path = os.path.join(app.config['UPLOAD_FOLDER'], transcript_filename)
|
||||
with open(output_txt_path, "w", encoding="utf-8") as f:
|
||||
f.write(full_content)
|
||||
|
||||
self.update_progress(100, 100, "Transcription complete.")
|
||||
return {'status': 'Success', 'content': full_content, 'result_path': transcript_filename}
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"An error occurred: {str(e)}"
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={'exc_type': type(e).__name__, 'exc_message': error_message}
|
||||
)
|
||||
return {'status': 'Error', 'error': error_message}
|
||||
|
||||
@celery.task(base=ProgressTask, bind=True)
|
||||
def translate_text_task(self, text_content, target_language):
|
||||
from app import app
|
||||
with app.app_context():
|
||||
from services.dify_client import translate_text as dify_translate
|
||||
try:
|
||||
self.update_progress(0, 100, f"Starting translation to {target_language}...")
|
||||
if isinstance(text_content, dict):
|
||||
text_content = text_content.get('content', '')
|
||||
|
||||
if not text_content or not isinstance(text_content, str):
|
||||
self.update_progress(100, 100, "Translation skipped due to empty input.")
|
||||
return {'status': 'Success', 'content': '', 'result_path': None, 'message': 'Input was empty.'}
|
||||
|
||||
lines = text_content.strip().split('\n')
|
||||
final_content = ""
|
||||
|
||||
timestamp_pattern = re.compile(r'^(\s*\[\d{2}:\d{2}:\d{2}\.\d{3}\s-\s\d{2}:\d{2}:\d{2}\.\d{3}\])\s*(.*)')
|
||||
|
||||
is_timestamped_input = False
|
||||
if lines:
|
||||
if timestamp_pattern.match(lines[0]):
|
||||
is_timestamped_input = True
|
||||
|
||||
if is_timestamped_input:
|
||||
translated_lines = []
|
||||
total_lines = len(lines)
|
||||
for i, line in enumerate(lines):
|
||||
match = timestamp_pattern.match(line)
|
||||
if not match:
|
||||
if line.strip(): # Keep non-matching, non-empty lines
|
||||
translated_lines.append(line)
|
||||
continue
|
||||
|
||||
timestamp = match.group(1)
|
||||
original_text = match.group(2)
|
||||
|
||||
# Add original line
|
||||
translated_lines.append(line)
|
||||
|
||||
if not original_text.strip():
|
||||
continue
|
||||
|
||||
translated_text = dify_translate(text=original_text, target_lang=target_language)
|
||||
# Add translated line, preserving the timestamp
|
||||
translated_lines.append(f"{timestamp} {translated_text}")
|
||||
|
||||
progress = int(((i + 1) / total_lines) * 100)
|
||||
self.update_progress(progress, 100, f"Translating line {i+1}/{total_lines}...")
|
||||
|
||||
final_content = "\n".join(translated_lines)
|
||||
else:
|
||||
# Handle non-timestamped text line by line for bilingual output
|
||||
translated_lines = []
|
||||
total_lines = len(lines)
|
||||
for i, line in enumerate(lines):
|
||||
progress = int(((i + 1) / total_lines) * 98) # Leave some room for finalization
|
||||
self.update_progress(progress, 100, f"Translating line {i+1}/{total_lines}...")
|
||||
|
||||
original_line = line.strip()
|
||||
if not original_line:
|
||||
continue
|
||||
|
||||
translated_text = dify_translate(text=original_line, target_lang=target_language)
|
||||
translated_lines.append(original_line)
|
||||
translated_lines.append(translated_text)
|
||||
translated_lines.append("") # Add a blank line for readability
|
||||
|
||||
final_content = "\n".join(translated_lines)
|
||||
|
||||
translated_filename = f"translated_{uuid.uuid4()}.txt"
|
||||
upload_folder = app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||
output_txt_path = os.path.join(upload_folder, translated_filename)
|
||||
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
with open(output_txt_path, "w", encoding="utf-8") as f:
|
||||
f.write(final_content)
|
||||
|
||||
self.update_progress(100, 100, "Translation complete.")
|
||||
return {'status': 'Success', 'content': final_content, 'result_path': translated_filename}
|
||||
except Exception as e:
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
||||
)
|
||||
|
||||
@celery.task(bind=True)
|
||||
def summarize_text_task(self, meeting_id):
|
||||
from app import app
|
||||
with app.app_context():
|
||||
try:
|
||||
meeting = Meeting.query.get(meeting_id)
|
||||
if not meeting or not meeting.transcript:
|
||||
self.update_state(state='FAILURE', meta={'error': 'Meeting or transcript not found'})
|
||||
return {'status': 'Error', 'error': 'Meeting or transcript not found'}
|
||||
|
||||
api_key = app.config.get("DIFY_SUMMARIZER_API_KEY")
|
||||
plain_transcript = re.sub(r'^(\s*\[.*?\])\s*', '', meeting.transcript, flags=re.MULTILINE)
|
||||
|
||||
if not plain_transcript.strip():
|
||||
self.update_state(state='FAILURE', meta={'error': 'Transcript is empty.'})
|
||||
return {'status': 'Error', 'error': 'Transcript is empty'}
|
||||
|
||||
response = ask_dify(api_key, plain_transcript)
|
||||
summary = response.get("answer")
|
||||
|
||||
if not summary:
|
||||
self.update_state(state='FAILURE', meta={'error': 'Dify returned empty summary.'})
|
||||
return {'status': 'Error', 'error': 'Dify returned empty summary.'}
|
||||
|
||||
meeting.summary = summary
|
||||
db.session.commit()
|
||||
|
||||
return {'status': 'Success', 'summary': summary}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
||||
)
|
||||
return {'status': 'Error', 'error': str(e)}
|
||||
|
||||
@celery.task(base=ProgressTask, bind=True)
|
||||
def preview_action_items_task(self, text_content):
|
||||
from app import app
|
||||
with app.app_context():
|
||||
try:
|
||||
self.update_progress(10, 100, "Requesting Dify for action items...")
|
||||
api_key = app.config.get("DIFY_ACTION_EXTRACTOR_API_KEY")
|
||||
plain_text = re.sub(r'^(\s*\[.*?\])\s*', '', text_content, flags=re.MULTILINE)
|
||||
response = ask_dify(api_key, plain_text)
|
||||
answer_text = response.get("answer", "")
|
||||
self.update_progress(80, 100, "Parsing response...")
|
||||
parsed_items = []
|
||||
try:
|
||||
match = re.search(r'\[.*\]', answer_text, re.DOTALL)
|
||||
if match:
|
||||
json_str = match.group(0)
|
||||
parsed_items = json.loads(json_str)
|
||||
if not isinstance(parsed_items, list):
|
||||
parsed_items = []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed_items = []
|
||||
self.update_progress(100, 100, "Action item preview generated.")
|
||||
return {'status': 'Success', 'parsed_items': parsed_items}
|
||||
except Exception as e:
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
||||
)
|
||||
|
||||
@celery.task(bind=True)
|
||||
def process_meeting_flow(self, meeting_id, target_language=None):
|
||||
from app import app
|
||||
with app.app_context():
|
||||
meeting = Meeting.query.get(meeting_id)
|
||||
if not meeting:
|
||||
return {'status': 'Error', 'error': 'Meeting not found'}
|
||||
try:
|
||||
meeting.status = 'processing'
|
||||
db.session.commit()
|
||||
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
# Assuming filename exists on the meeting object
|
||||
audio_path = os.path.join(upload_folder, meeting.filename.replace('.mp4', '.wav'))
|
||||
|
||||
# The call to transcribe_audio_task needs to be updated as it no longer takes 'language'
|
||||
transcript_result = transcribe_audio_task.apply(args=[audio_path]).get()
|
||||
if transcript_result.get('status') != 'Success':
|
||||
raise Exception(f"Transcription failed: {transcript_result.get('error', 'Unknown error')}")
|
||||
|
||||
meeting.transcript = transcript_result['content']
|
||||
db.session.commit()
|
||||
|
||||
if target_language:
|
||||
translation_result = translate_text_task.apply(args=[meeting.transcript, target_language]).get()
|
||||
if translation_result.get('status') != 'Success':
|
||||
raise Exception(f"Translation failed: {translation_result.get('error', 'Unknown error')}")
|
||||
meeting.translated_transcript = translation_result['content']
|
||||
db.session.commit()
|
||||
|
||||
summary_result = summarize_text_task.apply(args=[meeting.id]).get()
|
||||
if summary_result.get('status') != 'Success':
|
||||
raise Exception(f"Summarization failed: {summary_result.get('error', 'Unknown error')}")
|
||||
|
||||
meeting.summary = summary_result['summary']
|
||||
meeting.status = 'completed'
|
||||
db.session.commit()
|
||||
return {'status': 'Success', 'meeting_id': meeting.id}
|
||||
except Exception as e:
|
||||
meeting.status = 'failed'
|
||||
db.session.commit()
|
||||
return {'status': 'Error', 'error': str(e)}
|
168
templates/index.html
Normal file
168
templates/index.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Meeting Assistant</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4 mb-4">
|
||||
<header class="text-center mb-4">
|
||||
<h1 class="display-5"><i class="bi bi-robot me-2"></i>AI 會議助手</h1>
|
||||
<p class="lead text-muted">一個強大的工具,用於轉錄、翻譯和總結您的會議內容。</p>
|
||||
</header>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab1-tab" data-bs-toggle="tab" data-bs-target="#tab1" type="button" role="tab" aria-controls="tab1" aria-selected="true"><i class="bi bi-film me-1"></i> 1. 影片轉音訊</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab2-tab" data-bs-toggle="tab" data-bs-target="#tab2" type="button" role="tab" aria-controls="tab2" aria-selected="false"><i class="bi bi-mic-fill me-1"></i> 2. 音訊轉文字</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab3-tab" data-bs-toggle="tab" data-bs-target="#tab3" type="button" role="tab" aria-controls="tab3" aria-selected="false"><i class="bi bi-translate me-1"></i> 3. 逐段翻譯</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab4-tab" data-bs-toggle="tab" data-bs-target="#tab4" type="button" role="tab" aria-controls="tab4" aria-selected="false"><i class="bi bi-card-text me-1"></i> 4. 會議結論</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<!-- Tab 1: Video to Audio -->
|
||||
<div class="tab-pane fade show active" id="tab1" role="tabpanel" aria-labelledby="tab1-tab">
|
||||
<h5 class="card-title">影片轉音訊 (.wav)</h5>
|
||||
<p class="card-text">從影片檔案中提取音軌,以便進行後續處理。</p>
|
||||
<div class="mb-3">
|
||||
<label for="video-file" class="form-label">選擇影片檔案 (.mp4, .mov, .avi):</label>
|
||||
<input class="form-control" type="file" id="video-file" accept=".mp4,.mov,.avi">
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 action-btn" data-task="extract_audio"><i class="bi bi-arrow-repeat me-2"></i>開始轉換</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Audio to Text -->
|
||||
<div class="tab-pane fade" id="tab2" role="tabpanel" aria-labelledby="tab2-tab">
|
||||
<h5 class="card-title">音訊轉文字 (Whisper)</h5>
|
||||
<p class="card-text">將音訊檔案轉錄成帶有時間戳的逐字稿。</p>
|
||||
<div class="mb-3">
|
||||
<label for="audio-file" class="form-label">選擇音訊檔案 (.mp3, .wav, .m4a):</label>
|
||||
<input class="form-control" type="file" id="audio-file" accept=".mp3,.wav,.m4a">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="lang-select" class="form-label">音訊語言:</label>
|
||||
<select id="lang-select" class="form-select">
|
||||
<option value="auto">自動偵測</option>
|
||||
<option value="zh">中文</option><option value="en">英文</option>
|
||||
<option value="ja">日文</option><option value="ko">韓文</option>
|
||||
<option value="vi">越南文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="use-demucs">
|
||||
<label class="form-check-label" for="use-demucs">啟用 AI 人聲分離 (適用於嘈雜音訊)</label>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 action-btn" data-task="transcribe_audio"><i class="bi bi-mic-fill me-2"></i>開始轉錄</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Translate Text -->
|
||||
<div class="tab-pane fade" id="tab3" role="tabpanel" aria-labelledby="tab3-tab">
|
||||
<h5 class="card-title">逐段翻譯 (Dify)</h5>
|
||||
<p class="card-text">將逐字稿檔案進行逐段對照翻譯。</p>
|
||||
<div class="mb-3">
|
||||
<label for="transcript-file" class="form-label">選擇逐字稿檔案 (.txt):</label>
|
||||
<input class="form-control" type="file" id="transcript-file" accept=".txt">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="translate-lang-select" class="form-label">目標翻譯語言:</label>
|
||||
<select id="translate-lang-select" class="form-select">
|
||||
<option value="Traditional Chinese (繁體中文)">繁體中文</option>
|
||||
<option value="Simplified Chinese (简体中文)">簡體中文</option>
|
||||
<option value="English">English</option>
|
||||
<option value="Japanese (日本語)">日本語</option>
|
||||
<option value="Korean (한국어)">한국어</option>
|
||||
<option value="Vietnamese (Tiếng Việt)">Tiếng Việt (越南文)</option>
|
||||
<option value="French (Français)">Français (法文)</option>
|
||||
<option value="German (Deutsch)">Deutsch (德文)</option>
|
||||
<option value="Spanish (Español)">Español (西班牙文)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 action-btn" data-task="translate_text"><i class="bi bi-translate me-2"></i>開始翻譯</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Summarize Text -->
|
||||
<div class="tab-pane fade" id="tab4" role="tabpanel" aria-labelledby="tab4-tab">
|
||||
<h5 class="card-title">會議結論整理 (Dify)</h5>
|
||||
<p class="card-text">從逐字稿或貼上的文字中生成會議摘要。</p>
|
||||
<div class="mb-3">
|
||||
<label for="summary-file-input" class="form-label">上傳文稿檔案 (.txt):</label>
|
||||
<input class="form-control" type="file" id="summary-file-input" accept=".txt">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="summary-source-text" class="form-label">或直接貼上文稿:</label>
|
||||
<textarea id="summary-source-text" class="form-control" rows="6" placeholder="請在此處貼上要總結的完整文字... (如果已選擇檔案,將優先使用檔案內容)"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="summary-lang-select" class="form-label">結論生成語言:</label>
|
||||
<select id="summary-lang-select" class="form-select">
|
||||
<option value="Traditional Chinese (繁體中文)">繁體中文</option>
|
||||
<option value="Simplified Chinese (简体中文)">簡體中文</option>
|
||||
<option value="English">English</option>
|
||||
<option value="Japanese (日本語)">日本語</option>
|
||||
<option value="Korean (한국어)">한국어</option>
|
||||
<option value="Vietnamese (Tiếng Việt)">Tiếng Việt (越南文)</option>
|
||||
<option value="French (Français)">Français (法文)</option>
|
||||
<option value="German (Deutsch)">Deutsch (德文)</option>
|
||||
<option value="Spanish (Español)">Español (西班牙文)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 action-btn" data-task="summarize_text"><i class="bi bi-card-text me-2"></i>產生初版結論</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress and Result Section -->
|
||||
<div id="progress-container" class="mt-4" style="display: none;">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">處理進度</h5>
|
||||
<p id="status-text" class="text-muted mb-2">等待中...</p>
|
||||
<div class="progress" style="height: 25px;">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result-container" class="mt-4" style="display: none;">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">處理結果</h5>
|
||||
<div id="text-result-preview" class="result-preview bg-light p-3 rounded border" style="display: none;"></div>
|
||||
<a id="download-link" href="#" class="btn btn-success mt-3" style="display: none;"><i class="bi bi-download me-2"></i>下載結果檔案</a>
|
||||
|
||||
<!-- Revision Area -->
|
||||
<div id="revision-area" class="mt-4 pt-3 border-top" style="display:none;">
|
||||
<h6 class="card-subtitle mb-2 text-muted">不滿意結果嗎?</h6>
|
||||
<div class="mb-3">
|
||||
<label for="revision-instruction" class="form-label"><strong>修改指示:</strong></label>
|
||||
<textarea id="revision-instruction" class="form-control" rows="3" placeholder="請輸入修改要求,例如:請將結論縮減到 3 個重點。"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-100 action-btn" data-task="revise_summary"><i class="bi bi-pencil-square me-2"></i>根據指示產生修改版</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user