This commit is contained in:
beabigegg
2025-08-17 15:26:44 +08:00
commit 0fee703b84
60 changed files with 8042 additions and 0 deletions

19
.env Normal file
View 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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

112
action_item_routes.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View 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
View 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
View 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
View 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;

View 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

View 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;

View 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;

View 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;

View 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
View 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
View 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>,
)

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

Binary file not shown.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View File

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

View File

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

Binary file not shown.

93
services/dify_client.py Normal file
View 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
View 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
View 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
View 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
View 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>