diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab244ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +pip-wheel-metadata/ +.installed.cfg +*.egg-info/ +pip-selfcheck.json + +# Node.js +node_modules/ +dist/ +.npm/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json + +# Environment variables +.env +.env.* +!.env.example + +# IDE / Editor specific +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS specific +.DS_Store +Thumbs.db + +# Uploads +# Comment out the line below if you want to track uploaded files in git +# Keep it uncommented to prevent tracking user-uploaded content +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4583764 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# AI Meeting Assistant + +An intelligent meeting assistant designed to streamline your workflow by transcribing, summarizing, and managing action items from your meetings. This full-stack application leverages a Python Flask backend for robust API services and a React frontend for a dynamic user experience. + +## Key Features + +- **User Authentication**: Secure login and registration system with role-based access control (Admin, User). +- **Meeting Management**: Create, view, and manage meetings. Upload transcripts or generate them from audio. +- **AI-Powered Summary**: Automatically generate concise summaries from lengthy meeting transcripts. +- **Action Item Tracking**: Automatically preview and batch-create action items from summaries. Manually add, edit, and delete action items with assigned owners and due dates. +- **Permission Control**: Granular permissions for editing and deleting meetings and action items based on user roles (Admin, Meeting Owner, Action Item Owner). +- **File Processing Tools**: Independent tools for audio extraction, transcription, and text translation. + +## Tech Stack + +**Backend:** +- **Framework**: Flask +- **Database**: SQLAlchemy with Flask-Migrate for schema migrations. +- **Authentication**: Flask-JWT-Extended for token-based security. +- **Async Tasks**: Celery with Redis/RabbitMQ for handling long-running AI tasks. +- **API**: RESTful API design. + +**Frontend:** +- **Framework**: React.js +- **UI Library**: Material-UI (MUI) +- **Tooling**: Vite +- **API Communication**: Axios + +## Prerequisites + +- Python 3.10+ +- Node.js 20.x+ +- A message broker for Celery (e.g., Redis or RabbitMQ) + +## Installation & Setup + +### 1. Backend Setup + +Clone the repository: +```bash +git clone +cd AI_meeting_assistant_-_V2.1 +``` + +Create a virtual environment and install dependencies: +```bash +# For Windows +python -m venv venv +venv\Scripts\activate + +# For macOS/Linux +python3 -m venv venv +source venv/bin/activate + +pip install -r requirements.txt +``` + +Create a `.env` file by copying `.env.example` (if provided) or creating a new one. Configure the following: +``` +FLASK_APP=app.py +SECRET_KEY=your_super_secret_key +SQLALCHEMY_DATABASE_URI=sqlite:///meetings.db # Or your preferred database connection string +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 +``` + +Initialize and apply database migrations: +```bash +flask db init # Only if you don't have a 'migrations' folder +flask db migrate -m "Initial migration" +flask db upgrade +``` + +### 2. Frontend Setup + +Navigate to the frontend directory and install dependencies: +```bash +cd frontend +npm install +``` + +## Running the Application + +The application requires three separate processes to be running: the Flask server, the Celery worker, and the Vite frontend server. + +**1. Start the Flask Backend Server:** +```bash +# From the project root directory +flask run +``` +The API server will be running on `http://127.0.0.1:5000`. + +**2. Start the Celery Worker:** +Open a new terminal, activate the virtual environment, and run: +```bash +# From the project root directory +celery -A celery_worker.celery worker --loglevel=info +``` + +**3. Start the React Frontend Server:** +Open a third terminal and run: +```bash +# From the 'frontend' directory +npm run dev +``` +The frontend application will be available at `http://localhost:5173`. Open this URL in your browser. diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..9a9c51d --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,60 @@ +# AI Meeting Assistant - User Manual + +Welcome to the AI Meeting Assistant! This guide will walk you through the main features of the application and how to use them effectively. + +## 1. Getting Started: Login and Registration + +- **Registration**: If you are a new user, click on the "Register" link on the login page. You will need to provide a unique username and a password to create your account. +- **Login**: Once you have an account, enter your username and password on the login page to access the application. + +## 2. The Dashboard + +After logging in, you will land on the **Dashboard**. This is your main hub for all meetings. + +- **Meeting List**: The dashboard displays a table of all meetings in the system. You can see the meeting's **Topic**, **Owner**, **Meeting Date**, **Status**, and the number of **Action Items**. +- **Sorting**: Click on the column headers (e.g., "Topic", "Meeting Date") to sort the list. +- **Filtering and Searching**: Use the search boxes at the top to find meetings by topic or owner, or filter the list by status. +- **Create a New Meeting**: Click the "New Meeting" button to open a dialog where you can enter a topic and date for a new meeting. Upon creation, you will be taken directly to the Meeting Detail page. +- **View Details**: Click the "View Details" button on any meeting row to navigate to its detail page. +- **Delete a Meeting**: If you are the meeting's creator or an administrator, you will see a delete icon (trash can) to permanently remove the meeting and all its associated data. + +## 3. Meeting Detail Page + +This page is where you'll do most of your work. It's divided into three main sections: Transcript, AI Tools, and Action Items. + +### 3.1. Transcript + +- **View**: This section shows the full transcript of the meeting. +- **Edit**: If you are the meeting owner or an admin, you can click "Edit Transcript" to add, paste, or modify the text content. Click "Save Transcript" to save your changes. + +### 3.2. AI Tools + +This section allows you to leverage AI to process your transcript. + +- **Generate Summary**: + 1. Ensure a transcript has been added. + 2. Click the **"Generate Summary"** button. + 3. A "Generating..." message will appear. The process may take some time depending on the length of the text. + 4. Once complete, a concise summary will appear in the "Summary" box. +- **Edit Summary**: You can also manually edit the generated summary by clicking the "Edit Summary" button. +- **Preview Action Items**: + 1. After a summary or transcript is available, click the **"Preview Action Items"** button. + 2. The AI will analyze the text and display a list of suggested action items in a table. + 3. Review the items. If they are accurate, click **"Save All to List"** to add them to the official "Action Items" list below. + +### 3.3. Action Items + +This is the final list of tasks and to-dos from the meeting. + +- **Add Manually**: Click the "Add Manually" button to open a form where you can create a new action item, assign an owner, and set a due date. +- **Edit an Item**: If you are an Admin, the Meeting Owner, or the assigned owner of an action item, an edit icon (pencil) will appear. Click it to modify the item's details in-line. Click the save icon to confirm. +- **Delete an Item**: If you are an Admin or the Meeting Owner, a delete icon (trash can) will appear, allowing you to remove the action item. +- **Attachments**: You can upload a file attachment when creating or editing an action item. A download icon will appear if an attachment exists. + +## 4. Processing Tools Page + +Accessible from the main navigation, this page provides standalone utilities for file processing. + +1. **Extract Audio**: Upload a video file (e.g., MP4) to extract its audio track into a WAV file, which you can then download. +2. **Transcribe Audio**: Upload an audio file (e.g., WAV, MP3) to generate a text transcript. You can copy the text or download it as a `.txt` file. +3. **Translate Text**: Paste text or upload a `.txt` file, select a target language, and the tool will provide a translation. diff --git a/__pycache__/ai_routes.cpython-312.pyc b/__pycache__/ai_routes.cpython-312.pyc index acc5295..d7ed3c8 100644 Binary files a/__pycache__/ai_routes.cpython-312.pyc and b/__pycache__/ai_routes.cpython-312.pyc differ diff --git a/__pycache__/api_routes.cpython-312.pyc b/__pycache__/api_routes.cpython-312.pyc index 4906f3e..ea9fd4c 100644 Binary files a/__pycache__/api_routes.cpython-312.pyc and b/__pycache__/api_routes.cpython-312.pyc differ diff --git a/__pycache__/tasks.cpython-312.pyc b/__pycache__/tasks.cpython-312.pyc index fe3da5c..6dfca82 100644 Binary files a/__pycache__/tasks.cpython-312.pyc and b/__pycache__/tasks.cpython-312.pyc differ diff --git a/ai_routes.py b/ai_routes.py index 392575c..792f060 100644 --- a/ai_routes.py +++ b/ai_routes.py @@ -28,13 +28,4 @@ def summarize_text_api(): 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}) + diff --git a/api_routes.py b/api_routes.py index 63bfc5b..4c5aacf 100644 --- a/api_routes.py +++ b/api_routes.py @@ -11,7 +11,8 @@ from tasks import ( extract_audio_task, transcribe_audio_task, translate_text_task, - summarize_text_task + summarize_text_task, + preview_action_items_task ) api_bp = Blueprint("api_bp", __name__, url_prefix="/api") @@ -230,6 +231,17 @@ def summarize_meeting(meeting_id): task = summarize_text_task.delay(meeting_id) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 +@api_bp.route('/meetings//preview-actions', methods=['POST']) +@jwt_required() +def preview_actions(meeting_id): + meeting = Meeting.query.get_or_404(meeting_id) + text_content = meeting.summary or meeting.transcript + if not text_content: + return jsonify({'error': 'Meeting has no summary or transcript to analyze.'}), 400 + + task = preview_action_items_task.delay(text_content) + 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() diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..41d5e8d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1 @@ -#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; -} +/* App-specific styles can be added here in the future. */ \ No newline at end of file diff --git a/frontend/src/pages/MeetingDetailPage.jsx b/frontend/src/pages/MeetingDetailPage.jsx index afbd2f7..8f088ff 100644 --- a/frontend/src/pages/MeetingDetailPage.jsx +++ b/frontend/src/pages/MeetingDetailPage.jsx @@ -30,6 +30,7 @@ const MeetingDetailPage = () => { const [isEditingSummary, setIsEditingSummary] = useState(false); const [editData, setEditData] = useState({}); const [summaryTask, setSummaryTask] = useState(null); + const [previewTask, setPreviewTask] = useState(null); // State for the preview task const [actionItems, setActionItems] = useState([]); const [users, setUsers] = useState([]); @@ -39,7 +40,6 @@ const MeetingDetailPage = () => { 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 { @@ -70,33 +70,44 @@ const MeetingDetailPage = () => { fetchUsers(); }, []); + // Combined polling effect for both summary and preview tasks 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); + const task = summaryTask || previewTask; + if (!task || !['PENDING', 'PROGRESS'].includes(task.state)) return; + + const intervalId = setInterval(async () => { + try { + const updatedTask = await pollTaskStatus(task.status_url); + + if (task === summaryTask) setSummaryTask(prev => ({...prev, ...updatedTask})); + if (task === previewTask) setPreviewTask(prev => ({...prev, ...updatedTask})); + + if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) { + clearInterval(intervalId); + if (summaryTask) { // Handle summary success 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(); + setMeeting(prev => ({...prev, summary: updatedTask.info.summary})); + setEditData(prev => ({...prev, summary: updatedTask.info.summary})); + } + } else if (previewTask) { // Handle preview success + setPreviewTask(null); + if (updatedTask.state === 'SUCCESS' && updatedTask.info.items) { + setPreviewedItems(updatedTask.info.items); } } - } catch (err) { - console.error('Polling failed:', err); - clearInterval(intervalId); - setSummaryTask(null); } - }, 2000); - } + } catch (err) { + console.error('Polling failed:', err); + clearInterval(intervalId); + if (summaryTask) setSummaryTask(null); + if (previewTask) setPreviewTask(null); + } + }, 2000); + return () => clearInterval(intervalId); - }, [summaryTask, fetchMeetingData]); + }, [summaryTask, previewTask, fetchMeetingData]); + const handleSave = async (field, value) => { try { @@ -118,29 +129,25 @@ const MeetingDetailPage = () => { }; const handleGenerateSummary = async () => { - // FIX 3: Set loading state immediately to give instant feedback setSummaryTask({ state: 'PENDING', info: 'Initializing summary task...' }); try { const taskInfo = await summarizeMeeting(meetingId); setSummaryTask({ ...taskInfo, state: 'PENDING' }); } catch (err) { setError('Failed to start summary generation.'); - // Clear the temporary loading state on error setSummaryTask(null); } }; const handlePreviewActionItems = async () => { - const textToPreview = meeting?.summary || meeting?.transcript; - if (!textToPreview) return; - setIsPreviewLoading(true); + setPreviewTask({ state: 'PENDING', info: 'Initializing preview task...' }); try { - const result = await previewActionItems(textToPreview); - setPreviewedItems(result.items || []); + // This now calls the async task endpoint + const taskInfo = await previewActionItems(meetingId); + setPreviewTask({ ...taskInfo, state: 'PENDING' }); } catch (err) { - setError('Failed to generate action item preview.'); - } finally { - setIsPreviewLoading(false); + setError('Failed to start action item preview.'); + setPreviewTask(null); } }; @@ -149,7 +156,7 @@ const MeetingDetailPage = () => { try { await batchSaveActionItems(meetingId, previewedItems); setPreviewedItems([]); - fetchMeetingData(); + fetchMeetingData(); // Refetch to update the main action items list } catch (err) { setError('Failed to save action items.'); } @@ -235,20 +242,18 @@ const MeetingDetailPage = () => { )} - {canManageMeeting && ( - - - {previewedItems.length > 0 && ( - - - Context/ItemActionOwnerDue Date - {previewedItems.map((item, index) => ({item.item}{item.action}{item.owner}{item.due_date}))} -
- -
- )} -
- )} + {canManageMeeting && ( + + {previewedItems.length > 0 && ( + + Context/ItemActionOwnerDue Date + {previewedItems.map((item, index) => ({item.item}{item.action}{item.owner}{item.due_date}))} +
+ +
)} +
)} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index cfee167..6b4fea4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -76,8 +76,8 @@ 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 })); +export const previewActionItems = (meetingId) => + unwrap(api.post(`/meetings/${meetingId}/preview-actions`)); // --- Action Items --- export const getActionItemsForMeeting = (meetingId) => unwrap(api.get(`/meetings/${meetingId}/action_items`)); diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100644 index 9971d18..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,38 +0,0 @@ -/* 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; -} diff --git a/static/js/script.js b/static/js/script.js deleted file mode 100644 index b00f9b0..0000000 --- a/static/js/script.js +++ /dev/null @@ -1,275 +0,0 @@ -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 = ' 處理中...'; - 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 = ''; - text = '開始轉換'; - break; - case 'transcribe_audio': - iconHtml = ''; - text = '開始轉錄'; - break; - case 'translate_text': - iconHtml = ''; - text = '開始翻譯'; - break; - case 'summarize_text': - iconHtml = ''; - text = '產生初版結論'; - break; - case 'revise_summary': - iconHtml = ''; - text = '根據指示產生修改版'; - break; - } - button.innerHTML = iconHtml + text; - }); - } -}); diff --git a/tasks.py b/tasks.py index a806ecd..5b7e311 100644 --- a/tasks.py +++ b/tasks.py @@ -366,26 +366,38 @@ def preview_action_items_task(self, text_content): 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) + + if not plain_text.strip(): + return {'status': 'Success', 'items': []} + response = ask_dify(api_key, plain_text) answer_text = response.get("answer", "") self.update_progress(80, 100, "Parsing response...") + parsed_items = [] try: + # Find the JSON array within the response string match = re.search(r'\[.*\]', answer_text, re.DOTALL) if match: json_str = match.group(0) parsed_items = json.loads(json_str) + + # Ensure it's a list, otherwise reset to empty if not isinstance(parsed_items, list): parsed_items = [] + except (json.JSONDecodeError, TypeError): + # If parsing fails, leave it as an empty list parsed_items = [] + self.update_progress(100, 100, "Action item preview generated.") - return {'status': 'Success', 'parsed_items': parsed_items} + return {'status': 'Success', 'items': parsed_items} except Exception as e: self.update_state( state='FAILURE', meta={'exc_type': type(e).__name__, 'exc_message': str(e)} ) + return {'status': 'Error', 'error': str(e)} @celery.task(bind=True) def process_meeting_flow(self, meeting_id, target_language=None): diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index b37dd5d..0000000 --- a/templates/index.html +++ /dev/null @@ -1,168 +0,0 @@ - - - - - - AI Meeting Assistant - - - - - -
-
-

AI 會議助手

-

一個強大的工具,用於轉錄、翻譯和總結您的會議內容。

-
- -
-
- -
-
-
- -
-
影片轉音訊 (.wav)
-

從影片檔案中提取音軌,以便進行後續處理。

-
- - -
- -
- - -
-
音訊轉文字 (Whisper)
-

將音訊檔案轉錄成帶有時間戳的逐字稿。

-
- - -
-
-
- - -
-
-
- - -
- -
- - -
-
逐段翻譯 (Dify)
-

將逐字稿檔案進行逐段對照翻譯。

-
- - -
-
- - -
- -
- - -
-
會議結論整理 (Dify)
-

從逐字稿或貼上的文字中生成會議摘要。

-
- - -
-
- - -
-
- - -
- -
-
-
-
- - - - - -
- - - - -