fix2
This commit is contained in:
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -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/
|
106
README.md
Normal file
106
README.md
Normal file
@@ -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 <your-repository-url>
|
||||||
|
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.
|
60
USER_MANUAL.md
Normal file
60
USER_MANUAL.md
Normal file
@@ -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.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
ai_routes.py
11
ai_routes.py
@@ -28,13 +28,4 @@ def summarize_text_api():
|
|||||||
summary = _summarize_text(text, user_id=user_id)
|
summary = _summarize_text(text, user_id=user_id)
|
||||||
return jsonify({"summary": summary})
|
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})
|
|
||||||
|
@@ -11,7 +11,8 @@ from tasks import (
|
|||||||
extract_audio_task,
|
extract_audio_task,
|
||||||
transcribe_audio_task,
|
transcribe_audio_task,
|
||||||
translate_text_task,
|
translate_text_task,
|
||||||
summarize_text_task
|
summarize_text_task,
|
||||||
|
preview_action_items_task
|
||||||
)
|
)
|
||||||
|
|
||||||
api_bp = Blueprint("api_bp", __name__, url_prefix="/api")
|
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)
|
task = summarize_text_task.delay(meeting_id)
|
||||||
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202
|
||||||
|
|
||||||
|
@api_bp.route('/meetings/<int:meeting_id>/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 ---
|
# --- Independent Tool Routes ---
|
||||||
@api_bp.route('/tools/extract_audio', methods=['POST'])
|
@api_bp.route('/tools/extract_audio', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@@ -1,42 +1 @@
|
|||||||
#root {
|
/* App-specific styles can be added here in the future. */
|
||||||
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;
|
|
||||||
}
|
|
@@ -30,6 +30,7 @@ const MeetingDetailPage = () => {
|
|||||||
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
const [isEditingSummary, setIsEditingSummary] = useState(false);
|
||||||
const [editData, setEditData] = useState({});
|
const [editData, setEditData] = useState({});
|
||||||
const [summaryTask, setSummaryTask] = useState(null);
|
const [summaryTask, setSummaryTask] = useState(null);
|
||||||
|
const [previewTask, setPreviewTask] = useState(null); // State for the preview task
|
||||||
|
|
||||||
const [actionItems, setActionItems] = useState([]);
|
const [actionItems, setActionItems] = useState([]);
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@@ -39,7 +40,6 @@ const MeetingDetailPage = () => {
|
|||||||
const [isAddActionItemOpen, setIsAddActionItemOpen] = useState(false);
|
const [isAddActionItemOpen, setIsAddActionItemOpen] = useState(false);
|
||||||
const [newActionItem, setNewActionItem] = useState({ action: '', owner_id: '', due_date: '', item: '' });
|
const [newActionItem, setNewActionItem] = useState({ action: '', owner_id: '', due_date: '', item: '' });
|
||||||
const [previewedItems, setPreviewedItems] = useState([]);
|
const [previewedItems, setPreviewedItems] = useState([]);
|
||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchMeetingData = useCallback(async () => {
|
const fetchMeetingData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -70,33 +70,44 @@ const MeetingDetailPage = () => {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Combined polling effect for both summary and preview tasks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let intervalId = null;
|
const task = summaryTask || previewTask;
|
||||||
if (summaryTask && (summaryTask.state === 'PENDING' || summaryTask.state === 'PROGRESS')) {
|
if (!task || !['PENDING', 'PROGRESS'].includes(task.state)) return;
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
try {
|
const intervalId = setInterval(async () => {
|
||||||
const updatedTask = await pollTaskStatus(summaryTask.status_url);
|
try {
|
||||||
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
|
const updatedTask = await pollTaskStatus(task.status_url);
|
||||||
clearInterval(intervalId);
|
|
||||||
|
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);
|
setSummaryTask(null);
|
||||||
if (updatedTask.state === 'SUCCESS' && updatedTask.info.summary) {
|
if (updatedTask.state === 'SUCCESS' && updatedTask.info.summary) {
|
||||||
// Directly update the summary instead of refetching everything
|
setMeeting(prev => ({...prev, summary: updatedTask.info.summary}));
|
||||||
setMeeting(prevMeeting => ({...prevMeeting, summary: updatedTask.info.summary}));
|
setEditData(prev => ({...prev, summary: updatedTask.info.summary}));
|
||||||
setEditData(prevEditData => ({...prevEditData, summary: updatedTask.info.summary}));
|
}
|
||||||
} else {
|
} else if (previewTask) { // Handle preview success
|
||||||
// Fallback to refetch if something goes wrong or task fails
|
setPreviewTask(null);
|
||||||
fetchMeetingData();
|
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);
|
return () => clearInterval(intervalId);
|
||||||
}, [summaryTask, fetchMeetingData]);
|
}, [summaryTask, previewTask, fetchMeetingData]);
|
||||||
|
|
||||||
|
|
||||||
const handleSave = async (field, value) => {
|
const handleSave = async (field, value) => {
|
||||||
try {
|
try {
|
||||||
@@ -118,29 +129,25 @@ const MeetingDetailPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateSummary = async () => {
|
const handleGenerateSummary = async () => {
|
||||||
// FIX 3: Set loading state immediately to give instant feedback
|
|
||||||
setSummaryTask({ state: 'PENDING', info: 'Initializing summary task...' });
|
setSummaryTask({ state: 'PENDING', info: 'Initializing summary task...' });
|
||||||
try {
|
try {
|
||||||
const taskInfo = await summarizeMeeting(meetingId);
|
const taskInfo = await summarizeMeeting(meetingId);
|
||||||
setSummaryTask({ ...taskInfo, state: 'PENDING' });
|
setSummaryTask({ ...taskInfo, state: 'PENDING' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to start summary generation.');
|
setError('Failed to start summary generation.');
|
||||||
// Clear the temporary loading state on error
|
|
||||||
setSummaryTask(null);
|
setSummaryTask(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewActionItems = async () => {
|
const handlePreviewActionItems = async () => {
|
||||||
const textToPreview = meeting?.summary || meeting?.transcript;
|
setPreviewTask({ state: 'PENDING', info: 'Initializing preview task...' });
|
||||||
if (!textToPreview) return;
|
|
||||||
setIsPreviewLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const result = await previewActionItems(textToPreview);
|
// This now calls the async task endpoint
|
||||||
setPreviewedItems(result.items || []);
|
const taskInfo = await previewActionItems(meetingId);
|
||||||
|
setPreviewTask({ ...taskInfo, state: 'PENDING' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to generate action item preview.');
|
setError('Failed to start action item preview.');
|
||||||
} finally {
|
setPreviewTask(null);
|
||||||
setIsPreviewLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +156,7 @@ const MeetingDetailPage = () => {
|
|||||||
try {
|
try {
|
||||||
await batchSaveActionItems(meetingId, previewedItems);
|
await batchSaveActionItems(meetingId, previewedItems);
|
||||||
setPreviewedItems([]);
|
setPreviewedItems([]);
|
||||||
fetchMeetingData();
|
fetchMeetingData(); // Refetch to update the main action items list
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to save action items.');
|
setError('Failed to save action items.');
|
||||||
}
|
}
|
||||||
@@ -235,20 +242,18 @@ const MeetingDetailPage = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageMeeting && (
|
{canManageMeeting && (<Box sx={{ mt: 3 }}>
|
||||||
<Box sx={{ mt: 3 }}>
|
<Button variant="outlined" startIcon={<PreviewIcon />} onClick={handlePreviewActionItems} disabled={previewTask || summaryTask || isEditingSummary || (!meeting.summary && !meeting.transcript)}>
|
||||||
<Button variant="outlined" startIcon={<PreviewIcon />} onClick={handlePreviewActionItems} disabled={isPreviewLoading || isEditingSummary || (!meeting.summary && !meeting.transcript)}>{isPreviewLoading ? <CircularProgress size={24} /> : "Preview Action Items"}</Button>
|
{previewTask ? <CircularProgress size={24} /> : "Preview Action Items"}
|
||||||
{previewedItems.length > 0 && (
|
</Button>
|
||||||
<Box>
|
{previewedItems.length > 0 && (<Box>
|
||||||
<TableContainer component={Paper} sx={{ mt: 2 }}><Table size="small">
|
<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>
|
<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>
|
<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>
|
</Table></TableContainer>
|
||||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleBatchSave}>Save All to List</Button>
|
<Button variant="contained" sx={{ mt: 2 }} onClick={handleBatchSave}>Save All to List</Button>
|
||||||
</Box>
|
</Box>)}
|
||||||
)}
|
</Box>)}
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@@ -76,8 +76,8 @@ export const translateText = (text, target_language) =>
|
|||||||
unwrap(api.post('/tools/translate_text', { text, target_language }));
|
unwrap(api.post('/tools/translate_text', { text, target_language }));
|
||||||
|
|
||||||
// --- AI Previews (for Meeting Page) ---
|
// --- AI Previews (for Meeting Page) ---
|
||||||
export const previewActionItems = (text) =>
|
export const previewActionItems = (meetingId) =>
|
||||||
unwrap(api.post('/action-items/preview', { text }));
|
unwrap(api.post(`/meetings/${meetingId}/preview-actions`));
|
||||||
|
|
||||||
// --- Action Items ---
|
// --- Action Items ---
|
||||||
export const getActionItemsForMeeting = (meetingId) => unwrap(api.get(`/meetings/${meetingId}/action_items`));
|
export const getActionItemsForMeeting = (meetingId) => unwrap(api.get(`/meetings/${meetingId}/action_items`));
|
||||||
|
@@ -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;
|
|
||||||
}
|
|
@@ -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 = '<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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
14
tasks.py
14
tasks.py
@@ -366,26 +366,38 @@ def preview_action_items_task(self, text_content):
|
|||||||
self.update_progress(10, 100, "Requesting Dify for action items...")
|
self.update_progress(10, 100, "Requesting Dify for action items...")
|
||||||
api_key = app.config.get("DIFY_ACTION_EXTRACTOR_API_KEY")
|
api_key = app.config.get("DIFY_ACTION_EXTRACTOR_API_KEY")
|
||||||
plain_text = re.sub(r'^(\s*\[.*?\])\s*', '', text_content, flags=re.MULTILINE)
|
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)
|
response = ask_dify(api_key, plain_text)
|
||||||
answer_text = response.get("answer", "")
|
answer_text = response.get("answer", "")
|
||||||
self.update_progress(80, 100, "Parsing response...")
|
self.update_progress(80, 100, "Parsing response...")
|
||||||
|
|
||||||
parsed_items = []
|
parsed_items = []
|
||||||
try:
|
try:
|
||||||
|
# Find the JSON array within the response string
|
||||||
match = re.search(r'\[.*\]', answer_text, re.DOTALL)
|
match = re.search(r'\[.*\]', answer_text, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
json_str = match.group(0)
|
json_str = match.group(0)
|
||||||
parsed_items = json.loads(json_str)
|
parsed_items = json.loads(json_str)
|
||||||
|
|
||||||
|
# Ensure it's a list, otherwise reset to empty
|
||||||
if not isinstance(parsed_items, list):
|
if not isinstance(parsed_items, list):
|
||||||
parsed_items = []
|
parsed_items = []
|
||||||
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
# If parsing fails, leave it as an empty list
|
||||||
parsed_items = []
|
parsed_items = []
|
||||||
|
|
||||||
self.update_progress(100, 100, "Action item preview generated.")
|
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:
|
except Exception as e:
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state='FAILURE',
|
state='FAILURE',
|
||||||
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
meta={'exc_type': type(e).__name__, 'exc_message': str(e)}
|
||||||
)
|
)
|
||||||
|
return {'status': 'Error', 'error': str(e)}
|
||||||
|
|
||||||
@celery.task(bind=True)
|
@celery.task(bind=True)
|
||||||
def process_meeting_flow(self, meeting_id, target_language=None):
|
def process_meeting_flow(self, meeting_id, target_language=None):
|
||||||
|
@@ -1,168 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-TW">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>AI Meeting Assistant</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container mt-4 mb-4">
|
|
||||||
<header class="text-center mb-4">
|
|
||||||
<h1 class="display-5"><i class="bi bi-robot me-2"></i>AI 會議助手</h1>
|
|
||||||
<p class="lead text-muted">一個強大的工具,用於轉錄、翻譯和總結您的會議內容。</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header">
|
|
||||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active" id="tab1-tab" data-bs-toggle="tab" data-bs-target="#tab1" type="button" role="tab" aria-controls="tab1" aria-selected="true"><i class="bi bi-film me-1"></i> 1. 影片轉音訊</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="tab2-tab" data-bs-toggle="tab" data-bs-target="#tab2" type="button" role="tab" aria-controls="tab2" aria-selected="false"><i class="bi bi-mic-fill me-1"></i> 2. 音訊轉文字</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="tab3-tab" data-bs-toggle="tab" data-bs-target="#tab3" type="button" role="tab" aria-controls="tab3" aria-selected="false"><i class="bi bi-translate me-1"></i> 3. 逐段翻譯</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="tab4-tab" data-bs-toggle="tab" data-bs-target="#tab4" type="button" role="tab" aria-controls="tab4" aria-selected="false"><i class="bi bi-card-text me-1"></i> 4. 會議結論</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
<!-- Tab 1: Video to Audio -->
|
|
||||||
<div class="tab-pane fade show active" id="tab1" role="tabpanel" aria-labelledby="tab1-tab">
|
|
||||||
<h5 class="card-title">影片轉音訊 (.wav)</h5>
|
|
||||||
<p class="card-text">從影片檔案中提取音軌,以便進行後續處理。</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="video-file" class="form-label">選擇影片檔案 (.mp4, .mov, .avi):</label>
|
|
||||||
<input class="form-control" type="file" id="video-file" accept=".mp4,.mov,.avi">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary w-100 action-btn" data-task="extract_audio"><i class="bi bi-arrow-repeat me-2"></i>開始轉換</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab 2: Audio to Text -->
|
|
||||||
<div class="tab-pane fade" id="tab2" role="tabpanel" aria-labelledby="tab2-tab">
|
|
||||||
<h5 class="card-title">音訊轉文字 (Whisper)</h5>
|
|
||||||
<p class="card-text">將音訊檔案轉錄成帶有時間戳的逐字稿。</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="audio-file" class="form-label">選擇音訊檔案 (.mp3, .wav, .m4a):</label>
|
|
||||||
<input class="form-control" type="file" id="audio-file" accept=".mp3,.wav,.m4a">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="lang-select" class="form-label">音訊語言:</label>
|
|
||||||
<select id="lang-select" class="form-select">
|
|
||||||
<option value="auto">自動偵測</option>
|
|
||||||
<option value="zh">中文</option><option value="en">英文</option>
|
|
||||||
<option value="ja">日文</option><option value="ko">韓文</option>
|
|
||||||
<option value="vi">越南文</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="use-demucs">
|
|
||||||
<label class="form-check-label" for="use-demucs">啟用 AI 人聲分離 (適用於嘈雜音訊)</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary w-100 action-btn" data-task="transcribe_audio"><i class="bi bi-mic-fill me-2"></i>開始轉錄</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab 3: Translate Text -->
|
|
||||||
<div class="tab-pane fade" id="tab3" role="tabpanel" aria-labelledby="tab3-tab">
|
|
||||||
<h5 class="card-title">逐段翻譯 (Dify)</h5>
|
|
||||||
<p class="card-text">將逐字稿檔案進行逐段對照翻譯。</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="transcript-file" class="form-label">選擇逐字稿檔案 (.txt):</label>
|
|
||||||
<input class="form-control" type="file" id="transcript-file" accept=".txt">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="translate-lang-select" class="form-label">目標翻譯語言:</label>
|
|
||||||
<select id="translate-lang-select" class="form-select">
|
|
||||||
<option value="Traditional Chinese (繁體中文)">繁體中文</option>
|
|
||||||
<option value="Simplified Chinese (简体中文)">簡體中文</option>
|
|
||||||
<option value="English">English</option>
|
|
||||||
<option value="Japanese (日本語)">日本語</option>
|
|
||||||
<option value="Korean (한국어)">한국어</option>
|
|
||||||
<option value="Vietnamese (Tiếng Việt)">Tiếng Việt (越南文)</option>
|
|
||||||
<option value="French (Français)">Français (法文)</option>
|
|
||||||
<option value="German (Deutsch)">Deutsch (德文)</option>
|
|
||||||
<option value="Spanish (Español)">Español (西班牙文)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary w-100 action-btn" data-task="translate_text"><i class="bi bi-translate me-2"></i>開始翻譯</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab 4: Summarize Text -->
|
|
||||||
<div class="tab-pane fade" id="tab4" role="tabpanel" aria-labelledby="tab4-tab">
|
|
||||||
<h5 class="card-title">會議結論整理 (Dify)</h5>
|
|
||||||
<p class="card-text">從逐字稿或貼上的文字中生成會議摘要。</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="summary-file-input" class="form-label">上傳文稿檔案 (.txt):</label>
|
|
||||||
<input class="form-control" type="file" id="summary-file-input" accept=".txt">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="summary-source-text" class="form-label">或直接貼上文稿:</label>
|
|
||||||
<textarea id="summary-source-text" class="form-control" rows="6" placeholder="請在此處貼上要總結的完整文字... (如果已選擇檔案,將優先使用檔案內容)"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="summary-lang-select" class="form-label">結論生成語言:</label>
|
|
||||||
<select id="summary-lang-select" class="form-select">
|
|
||||||
<option value="Traditional Chinese (繁體中文)">繁體中文</option>
|
|
||||||
<option value="Simplified Chinese (简体中文)">簡體中文</option>
|
|
||||||
<option value="English">English</option>
|
|
||||||
<option value="Japanese (日本語)">日本語</option>
|
|
||||||
<option value="Korean (한국어)">한국어</option>
|
|
||||||
<option value="Vietnamese (Tiếng Việt)">Tiếng Việt (越南文)</option>
|
|
||||||
<option value="French (Français)">Français (法文)</option>
|
|
||||||
<option value="German (Deutsch)">Deutsch (德文)</option>
|
|
||||||
<option value="Spanish (Español)">Español (西班牙文)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary w-100 action-btn" data-task="summarize_text"><i class="bi bi-card-text me-2"></i>產生初版結論</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress and Result Section -->
|
|
||||||
<div id="progress-container" class="mt-4" style="display: none;">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">處理進度</h5>
|
|
||||||
<p id="status-text" class="text-muted mb-2">等待中...</p>
|
|
||||||
<div class="progress" style="height: 25px;">
|
|
||||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="result-container" class="mt-4" style="display: none;">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">處理結果</h5>
|
|
||||||
<div id="text-result-preview" class="result-preview bg-light p-3 rounded border" style="display: none;"></div>
|
|
||||||
<a id="download-link" href="#" class="btn btn-success mt-3" style="display: none;"><i class="bi bi-download me-2"></i>下載結果檔案</a>
|
|
||||||
|
|
||||||
<!-- Revision Area -->
|
|
||||||
<div id="revision-area" class="mt-4 pt-3 border-top" style="display:none;">
|
|
||||||
<h6 class="card-subtitle mb-2 text-muted">不滿意結果嗎?</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="revision-instruction" class="form-label"><strong>修改指示:</strong></label>
|
|
||||||
<textarea id="revision-instruction" class="form-control" rows="3" placeholder="請輸入修改要求,例如:請將結論縮減到 3 個重點。"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary w-100 action-btn" data-task="revise_summary"><i class="bi bi-pencil-square me-2"></i>根據指示產生修改版</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Reference in New Issue
Block a user