Add Video Learning Agent for short video platforms
Features: - VideoLearningAgent for automated video watching on Douyin/Kuaishou/TikTok - Web dashboard UI for video learning sessions - Real-time progress tracking with screenshot capture - App detection using get_current_app() for accurate recording - Session management with pause/resume/stop controls Technical improvements: - Simplified video detection logic using direct app detection - Full base64 hash for sensitive screenshot change detection - Immediate stop when target video count is reached - Fixed circular import issues with ModelConfig Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,11 @@ API endpoints for the dashboard.
|
||||
from dashboard.api.devices import router as devices_router
|
||||
from dashboard.api.tasks import router as tasks_router
|
||||
from dashboard.api.websocket import router as websocket_router
|
||||
from dashboard.api.video_learning import router as video_learning_router
|
||||
|
||||
__all__ = [
|
||||
"devices_router",
|
||||
"tasks_router",
|
||||
"websocket_router",
|
||||
"video_learning_router",
|
||||
]
|
||||
|
||||
328
dashboard/api/video_learning.py
Normal file
328
dashboard/api/video_learning.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Video Learning API endpoints for the dashboard.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from dashboard.config import config
|
||||
from dashboard.dependencies import get_device_manager
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from phone_agent import VideoLearningAgent
|
||||
from phone_agent.model.client import ModelConfig
|
||||
|
||||
router = APIRouter(prefix="/api/video-learning", tags=["video-learning"])
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
"""Request to create a new learning session."""
|
||||
|
||||
device_id: str = Field(..., description="Target device ID")
|
||||
platform: str = Field("douyin", description="Platform name (douyin, kuaishou, tiktok)")
|
||||
target_count: int = Field(10, description="Number of videos to watch", ge=1, le=100)
|
||||
category: Optional[str] = Field(None, description="Target category filter")
|
||||
watch_duration: float = Field(3.0, description="Watch duration per video (seconds)", ge=1.0, le=30.0)
|
||||
|
||||
|
||||
class SessionControlRequest(BaseModel):
|
||||
"""Request to control a session."""
|
||||
|
||||
action: str = Field(..., description="Action: pause, resume, stop")
|
||||
|
||||
|
||||
class SessionStatus(BaseModel):
|
||||
"""Session status response."""
|
||||
|
||||
session_id: str
|
||||
platform: str
|
||||
target_count: int
|
||||
watched_count: int
|
||||
progress_percent: float
|
||||
is_active: bool
|
||||
is_paused: bool
|
||||
total_duration: float
|
||||
current_video: Optional[Dict] = None
|
||||
|
||||
|
||||
class VideoInfo(BaseModel):
|
||||
"""Information about a watched video."""
|
||||
|
||||
sequence_id: int
|
||||
timestamp: str
|
||||
screenshot_path: Optional[str] = None
|
||||
watch_duration: float
|
||||
description: Optional[str] = None
|
||||
likes: Optional[int] = None
|
||||
comments: Optional[int] = None
|
||||
tags: List[str] = []
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
# Global session storage (in production, use database)
|
||||
_active_sessions: Dict[str, VideoLearningAgent] = {}
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=Dict[str, str])
|
||||
async def create_session(
|
||||
request: SessionCreateRequest,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
) -> Dict[str, str]:
|
||||
"""Create a new video learning session."""
|
||||
# Check device availability
|
||||
device = await device_manager.get_device(request.device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
if not device.is_connected:
|
||||
raise HTTPException(status_code=400, detail="Device not connected")
|
||||
|
||||
if device.status == "busy":
|
||||
raise HTTPException(status_code=409, detail="Device is busy")
|
||||
|
||||
# Create model config from environment
|
||||
model_config = ModelConfig(
|
||||
base_url=config.MODEL_BASE_URL,
|
||||
model_name=config.MODEL_NAME,
|
||||
api_key=config.MODEL_API_KEY,
|
||||
max_tokens=config.MAX_TOKENS,
|
||||
temperature=config.TEMPERATURE,
|
||||
top_p=config.TOP_P,
|
||||
frequency_penalty=config.FREQUENCY_PENALTY,
|
||||
lang="cn",
|
||||
)
|
||||
|
||||
# Create video learning agent
|
||||
agent = VideoLearningAgent(
|
||||
model_config=model_config,
|
||||
platform=request.platform,
|
||||
output_dir=config.VIDEO_LEARNING_OUTPUT_DIR,
|
||||
)
|
||||
|
||||
# Setup callbacks for real-time updates
|
||||
session_id = None
|
||||
|
||||
def on_video_watched(record):
|
||||
"""Callback when a video is watched."""
|
||||
# Broadcast via WebSocket
|
||||
if session_id:
|
||||
# This would be integrated with WebSocket manager
|
||||
pass
|
||||
|
||||
def on_progress_update(current, total):
|
||||
"""Callback for progress updates."""
|
||||
if session_id:
|
||||
# Broadcast progress
|
||||
pass
|
||||
|
||||
def on_session_complete(session):
|
||||
"""Callback when session completes."""
|
||||
if session_id and session_id in _active_sessions:
|
||||
del _active_sessions[session_id]
|
||||
|
||||
agent.on_video_watched = on_video_watched
|
||||
agent.on_progress_update = on_progress_update
|
||||
agent.on_session_complete = on_session_complete
|
||||
|
||||
# Start session
|
||||
session_id = agent.start_session(
|
||||
device_id=request.device_id,
|
||||
target_count=request.target_count,
|
||||
category=request.category,
|
||||
watch_duration=request.watch_duration,
|
||||
max_steps=500,
|
||||
)
|
||||
|
||||
# Store session
|
||||
_active_sessions[session_id] = agent
|
||||
|
||||
return {"session_id": session_id, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/start", response_model=Dict[str, str])
|
||||
async def start_session(session_id: str) -> Dict[str, str]:
|
||||
"""Start executing a learning session."""
|
||||
if session_id not in _active_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
agent = _active_sessions[session_id]
|
||||
|
||||
# Build task based on session parameters
|
||||
session = agent.current_session
|
||||
if not session:
|
||||
raise HTTPException(status_code=400, detail="Session not initialized")
|
||||
|
||||
category = session.target_category
|
||||
target_count = session.target_count
|
||||
watch_duration = agent._watch_duration
|
||||
platform = agent.platform
|
||||
|
||||
# Platform-specific app name and package
|
||||
platform_info = {
|
||||
"douyin": {
|
||||
"name": "抖音",
|
||||
"package": "com.ss.android.ugc.aweme",
|
||||
},
|
||||
"kuaishou": {
|
||||
"name": "快手",
|
||||
"package": "com.smile.gifmaker",
|
||||
},
|
||||
"tiktok": {
|
||||
"name": "TikTok",
|
||||
"package": "com.zhiliaoapp.musically",
|
||||
},
|
||||
}
|
||||
|
||||
info = platform_info.get(platform, platform_info["douyin"])
|
||||
app_name = info["name"]
|
||||
|
||||
# Build clear task instructions
|
||||
if category:
|
||||
task = f"""你是一个视频学习助手。请严格按照以下步骤执行:
|
||||
|
||||
步骤1:启动应用
|
||||
- 回到主屏幕
|
||||
- 打开{app_name}应用
|
||||
|
||||
步骤2:搜索内容
|
||||
- 在{app_name}中搜索"{category}"
|
||||
- 点击第一个搜索结果或进入相关页面
|
||||
|
||||
步骤3:观看视频
|
||||
- 观看视频,每个视频停留约{watch_duration}秒
|
||||
- 记录视频的描述、点赞数、评论数
|
||||
- 向上滑动切换到下一个视频
|
||||
- 重复观看和记录,直到完成{target_count}个视频
|
||||
|
||||
步骤4:完成任务
|
||||
- 完成观看{target_count}个视频后,总结所有视频信息
|
||||
|
||||
请现在开始执行。"""
|
||||
else:
|
||||
task = f"""你是一个视频学习助手。请严格按照以下步骤执行:
|
||||
|
||||
步骤1:启动应用
|
||||
- 回到主屏幕
|
||||
- 打开{app_name}应用
|
||||
|
||||
步骤2:观看推荐视频
|
||||
- 进入{app_name}的推荐页面
|
||||
- 观看推荐视频,每个视频停留约{watch_duration}秒
|
||||
- 记录视频的描述、点赞数、评论数
|
||||
- 向上滑动切换到下一个视频
|
||||
- 重复观看和记录,直到完成{target_count}个视频
|
||||
|
||||
步骤3:完成任务
|
||||
- 完成观看{target_count}个视频后,总结所有视频信息
|
||||
|
||||
请现在开始执行。"""
|
||||
|
||||
# Run in background
|
||||
asyncio.create_task(asyncio.to_thread(agent.run_learning_task, task))
|
||||
|
||||
return {"session_id": session_id, "status": "started"}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/control", response_model=Dict[str, str])
|
||||
async def control_session(
|
||||
session_id: str, request: SessionControlRequest
|
||||
) -> Dict[str, str]:
|
||||
"""Control a learning session (pause/resume/stop)."""
|
||||
if session_id not in _active_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
agent = _active_sessions[session_id]
|
||||
|
||||
if request.action == "pause":
|
||||
agent.pause_session()
|
||||
return {"session_id": session_id, "status": "paused"}
|
||||
elif request.action == "resume":
|
||||
agent.resume_session()
|
||||
return {"session_id": session_id, "status": "resumed"}
|
||||
elif request.action == "stop":
|
||||
agent.stop_session()
|
||||
# Remove from active sessions
|
||||
del _active_sessions[session_id]
|
||||
return {"session_id": session_id, "status": "stopped"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid action: {request.action}")
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/status", response_model=SessionStatus)
|
||||
async def get_session_status(session_id: str) -> SessionStatus:
|
||||
"""Get session status."""
|
||||
if session_id not in _active_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
agent = _active_sessions[session_id]
|
||||
progress = agent.get_session_progress()
|
||||
|
||||
# Get current video info if available
|
||||
current_video = None
|
||||
if agent.current_session and agent.current_session.records:
|
||||
latest = agent.current_session.records[-1]
|
||||
current_video = {
|
||||
"sequence_id": latest.sequence_id,
|
||||
"timestamp": latest.timestamp,
|
||||
"screenshot_path": latest.screenshot_path,
|
||||
"description": latest.description,
|
||||
"likes": latest.likes,
|
||||
"comments": latest.comments,
|
||||
}
|
||||
|
||||
return SessionStatus(
|
||||
session_id=progress["session_id"],
|
||||
platform=progress["platform"],
|
||||
target_count=progress["target_count"],
|
||||
watched_count=progress["watched_count"],
|
||||
progress_percent=progress["progress_percent"],
|
||||
is_active=progress["is_active"],
|
||||
is_paused=progress["is_paused"],
|
||||
total_duration=progress["total_duration"],
|
||||
current_video=current_video,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/videos", response_model=List[VideoInfo])
|
||||
async def get_session_videos(session_id: str) -> List[VideoInfo]:
|
||||
"""Get all videos from a session."""
|
||||
if session_id not in _active_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
agent = _active_sessions[session_id]
|
||||
if not agent.current_session:
|
||||
return []
|
||||
|
||||
return [
|
||||
VideoInfo(
|
||||
sequence_id=r.sequence_id,
|
||||
timestamp=r.timestamp,
|
||||
screenshot_path=r.screenshot_path,
|
||||
watch_duration=r.watch_duration,
|
||||
description=r.description,
|
||||
likes=r.likes,
|
||||
comments=r.comments,
|
||||
tags=r.tags,
|
||||
category=r.category,
|
||||
)
|
||||
for r in agent.current_session.records
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=List[str])
|
||||
async def list_sessions() -> List[str]:
|
||||
"""List all active session IDs."""
|
||||
return list(_active_sessions.keys())
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", response_model=Dict[str, str])
|
||||
async def delete_session(session_id: str) -> Dict[str, str]:
|
||||
"""Delete a session."""
|
||||
if session_id not in _active_sessions:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
del _active_sessions[session_id]
|
||||
return {"session_id": session_id, "status": "deleted"}
|
||||
@@ -39,6 +39,13 @@ class DashboardConfig:
|
||||
MODEL_BASE_URL: str = os.getenv("PHONE_AGENT_BASE_URL", "http://localhost:8000/v1")
|
||||
MODEL_NAME: str = os.getenv("PHONE_AGENT_MODEL", "autoglm-phone-9b")
|
||||
MODEL_API_KEY: str = os.getenv("PHONE_AGENT_API_KEY", "EMPTY")
|
||||
MAX_TOKENS: int = int(os.getenv("PHONE_AGENT_MAX_TOKENS", "3000"))
|
||||
TEMPERATURE: float = float(os.getenv("PHONE_AGENT_TEMPERATURE", "0.0"))
|
||||
TOP_P: float = float(os.getenv("PHONE_AGENT_TOP_P", "0.85"))
|
||||
FREQUENCY_PENALTY: float = float(os.getenv("PHONE_AGENT_FREQUENCY_PENALTY", "0.2"))
|
||||
|
||||
# Video learning settings
|
||||
VIDEO_LEARNING_OUTPUT_DIR: str = os.getenv("VIDEO_LEARNING_OUTPUT_DIR", "./video_learning_data")
|
||||
|
||||
# Task history
|
||||
MAX_TASK_HISTORY: int = int(os.getenv("MAX_TASK_HISTORY", "100"))
|
||||
|
||||
@@ -16,7 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from dashboard.api import devices_router, tasks_router, websocket_router
|
||||
from dashboard.api import devices_router, tasks_router, websocket_router, video_learning_router
|
||||
from dashboard.config import config
|
||||
from dashboard.dependencies import (
|
||||
get_device_manager,
|
||||
@@ -104,6 +104,7 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
app.include_router(devices_router, prefix="/api")
|
||||
app.include_router(tasks_router, prefix="/api")
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(video_learning_router)
|
||||
|
||||
|
||||
# Health check
|
||||
@@ -163,6 +164,12 @@ if static_path.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
||||
|
||||
|
||||
# Mount static files for video learning screenshots
|
||||
video_learning_data_path = Path(config.VIDEO_LEARNING_OUTPUT_DIR)
|
||||
if video_learning_data_path.exists():
|
||||
app.mount("/video-learning-data", StaticFiles(directory=str(video_learning_data_path)), name="video-learning-data")
|
||||
|
||||
|
||||
# Run script entry point
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
283
dashboard/static/css/video-learning.css
Normal file
283
dashboard/static/css/video-learning.css
Normal file
@@ -0,0 +1,283 @@
|
||||
/* Video Learning Module Styles */
|
||||
|
||||
/* Header modifications */
|
||||
.header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Configuration Section */
|
||||
.config-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group input {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group select:focus,
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group select:disabled,
|
||||
.form-group input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Session Section */
|
||||
.session-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.session-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Progress Section */
|
||||
.progress-section {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-bar-large {
|
||||
height: 8px;
|
||||
background-color: rgba(99, 102, 241, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Current Video */
|
||||
.current-video {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.current-video h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Video Cards */
|
||||
.video-card {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.video-screenshot {
|
||||
width: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-screenshot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.video-id {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-stats span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.video-stats svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Session Complete */
|
||||
.session-complete {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.complete-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.session-complete h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-complete p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Video Grid */
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.video-grid .video-card {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.video-grid .video-screenshot,
|
||||
.video-grid .video-placeholder {
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
/* History Section */
|
||||
.history-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.history-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/static/video-learning.html" class="btn btn-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
Video Learning
|
||||
</a>
|
||||
<button @click="refreshDevices" class="btn btn-secondary" :disabled="refreshing">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: refreshing }">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
|
||||
200
dashboard/static/js/video-learning.js
Normal file
200
dashboard/static/js/video-learning.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Video Learning Module for AutoGLM Dashboard
|
||||
*
|
||||
* This module provides UI and functionality for the Video Learning Agent,
|
||||
* allowing users to watch and learn from short video platforms.
|
||||
*/
|
||||
|
||||
const VideoLearningModule = {
|
||||
// Current session state
|
||||
currentSessionId: null,
|
||||
currentSessionStatus: null,
|
||||
videos: [],
|
||||
isPolling: false,
|
||||
|
||||
// Create a new learning session
|
||||
async createSession(deviceId, options = {}) {
|
||||
const {
|
||||
platform = 'douyin',
|
||||
targetCount = 10,
|
||||
category = null,
|
||||
watchDuration = 3.0,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/video-learning/sessions', {
|
||||
device_id: deviceId,
|
||||
platform: platform,
|
||||
target_count: targetCount,
|
||||
category: category,
|
||||
watch_duration: watchDuration,
|
||||
});
|
||||
|
||||
this.currentSessionId = response.data.session_id;
|
||||
this.startPolling();
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Start a session
|
||||
async startSession(sessionId) {
|
||||
try {
|
||||
const response = await axios.post(`/api/video-learning/sessions/${sessionId}/start`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error starting session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Control a session (pause/resume/stop)
|
||||
async controlSession(sessionId, action) {
|
||||
try {
|
||||
const response = await axios.post(`/api/video-learning/sessions/${sessionId}/control`, {
|
||||
action: action,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error controlling session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get session status
|
||||
async getSessionStatus(sessionId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/video-learning/sessions/${sessionId}/status`);
|
||||
this.currentSessionStatus = response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session status:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get session videos
|
||||
async getSessionVideos(sessionId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/video-learning/sessions/${sessionId}/videos`);
|
||||
this.videos = response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session videos:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// List all active sessions
|
||||
async listSessions() {
|
||||
try {
|
||||
const response = await axios.get('/api/video-learning/sessions');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error listing sessions:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a session
|
||||
async deleteSession(sessionId) {
|
||||
try {
|
||||
const response = await axios.delete(`/api/video-learning/sessions/${sessionId}`);
|
||||
if (this.currentSessionId === sessionId) {
|
||||
this.currentSessionId = null;
|
||||
this.currentSessionStatus = null;
|
||||
this.stopPolling();
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Start polling for session updates
|
||||
startPolling(intervalMs = 1000) {
|
||||
if (this.isPolling) return;
|
||||
|
||||
this.isPolling = true;
|
||||
this.pollInterval = setInterval(async () => {
|
||||
if (this.currentSessionId) {
|
||||
try {
|
||||
await this.getSessionStatus(this.currentSessionId);
|
||||
await this.getSessionVideos(this.currentSessionId);
|
||||
|
||||
// Trigger custom event for UI updates
|
||||
window.dispatchEvent(new CustomEvent('videoLearningUpdate', {
|
||||
detail: {
|
||||
sessionId: this.currentSessionId,
|
||||
status: this.currentSessionStatus,
|
||||
videos: this.videos,
|
||||
}
|
||||
}));
|
||||
|
||||
// Stop polling if session is complete, but do one final update
|
||||
if (this.currentSessionStatus && !this.currentSessionStatus.is_active) {
|
||||
console.log('[VideoLearning] Session completed, doing final update...');
|
||||
// Do one final update to ensure we have the latest data
|
||||
await this.getSessionStatus(this.currentSessionId);
|
||||
await this.getSessionVideos(this.currentSessionId);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('videoLearningUpdate', {
|
||||
detail: {
|
||||
sessionId: this.currentSessionId,
|
||||
status: this.currentSessionStatus,
|
||||
videos: this.videos,
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('[VideoLearning] Final update complete, stopping poll');
|
||||
this.stopPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling session status:', error);
|
||||
// Don't stop polling on error, just log it
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
console.log(`[VideoLearning] Started polling with ${intervalMs}ms interval`);
|
||||
},
|
||||
|
||||
// Stop polling
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
console.log('[VideoLearning] Stopped polling');
|
||||
}
|
||||
this.isPolling = false;
|
||||
},
|
||||
|
||||
// Format duration
|
||||
formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
||||
},
|
||||
|
||||
// Format number with K/M suffix
|
||||
formatNumber(num) {
|
||||
if (num === null || num === undefined) return 'N/A';
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
} else if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
},
|
||||
};
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = VideoLearningModule;
|
||||
}
|
||||
412
dashboard/static/video-learning.html
Normal file
412
dashboard/static/video-learning.html
Normal file
@@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Video Learning - AutoGLM Dashboard</title>
|
||||
<!-- Vue.js 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- Axios for API requests -->
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/static/css/video-learning.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
Video Learning Agent
|
||||
</h1>
|
||||
<div class="stats">
|
||||
<span class="stat" title="Session Status">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{{ sessionStatus ? sessionStatus.status : 'No Session' }}
|
||||
</span>
|
||||
<span class="stat" v-if="sessionStatus" title="Progress">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
{{ sessionStatus.watched_count }} / {{ sessionStatus.target_count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button @click="goBack" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Configuration Section -->
|
||||
<section class="config-section" v-if="!currentSessionId">
|
||||
<h2>Create Learning Session</h2>
|
||||
<div class="config-form">
|
||||
<div class="form-group">
|
||||
<label>Device</label>
|
||||
<select v-model="config.deviceId" :disabled="loading">
|
||||
<option value="">Select a device...</option>
|
||||
<option v-for="device in devices" :key="device.device_id" :value="device.device_id"
|
||||
:disabled="!device.is_connected || device.status === 'busy'">
|
||||
{{ device.device_id }}
|
||||
{{ !device.is_connected ? '(Disconnected)' : '' }}
|
||||
{{ device.status === 'busy' ? '(Busy)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Platform</label>
|
||||
<select v-model="config.platform" :disabled="loading">
|
||||
<option value="douyin">Douyin (抖音)</option>
|
||||
<option value="kuaishou">Kuaishou (快手)</option>
|
||||
<option value="tiktok">TikTok</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Target Videos</label>
|
||||
<input type="number" v-model.number="config.targetCount" min="1" max="100" :disabled="loading">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Watch Duration (s)</label>
|
||||
<input type="number" v-model.number="config.watchDuration" min="1" max="30" step="0.5" :disabled="loading">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Category (Optional)</label>
|
||||
<input type="text" v-model="config.category" placeholder="e.g., 美食, 旅行, 搞笑" :disabled="loading">
|
||||
<small>Leave empty to watch recommended videos</small>
|
||||
</div>
|
||||
|
||||
<button @click="createAndStartSession" class="btn btn-primary" :disabled="loading || !config.deviceId">
|
||||
<svg v-if="loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Creating...' : 'Start Learning' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Session Control Section -->
|
||||
<section class="session-section" v-if="currentSessionId && sessionStatus">
|
||||
<div class="session-header">
|
||||
<h2>Session: {{ currentSessionId }}</h2>
|
||||
<div class="session-controls">
|
||||
<button v-if="sessionStatus.is_paused" @click="resumeSession" class="btn btn-primary btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
Resume
|
||||
</button>
|
||||
<button v-else-if="sessionStatus.is_active" @click="pauseSession" class="btn btn-secondary btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="4" width="4" height="16"></rect>
|
||||
<rect x="14" y="4" width="4" height="16"></rect>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
<button v-if="sessionStatus.is_active" @click="stopSession" class="btn btn-danger btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="6" width="12" height="12"></rect>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section" v-if="sessionStatus.is_active || sessionStatus.is_paused">
|
||||
<div class="progress-info">
|
||||
<span>Progress: {{ sessionStatus.watched_count }} / {{ sessionStatus.target_count }}</span>
|
||||
<span>{{ Math.round(sessionStatus.progress_percent) }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-large">
|
||||
<div class="progress-fill" :style="{ width: sessionStatus.progress_percent + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-stats">
|
||||
<span>Total Duration: {{ formatDuration(sessionStatus.total_duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Video -->
|
||||
<div class="current-video" v-if="sessionStatus.current_video">
|
||||
<h3>Current Video</h3>
|
||||
<div class="video-card">
|
||||
<div class="video-screenshot" v-if="sessionStatus.current_video.screenshot_path">
|
||||
<img :src="sessionStatus.current_video.screenshot_path" alt="Current video">
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-id">#{{ sessionStatus.current_video.sequence_id }}</div>
|
||||
<div class="video-description" v-if="sessionStatus.current_video.description">
|
||||
{{ sessionStatus.current_video.description }}
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span v-if="sessionStatus.current_video.likes">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(sessionStatus.current_video.likes) }}
|
||||
</span>
|
||||
<span v-if="sessionStatus.current_video.comments">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(sessionStatus.current_video.comments) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Complete -->
|
||||
<div class="session-complete" v-if="!sessionStatus.is_active && currentSessionId">
|
||||
<div class="complete-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Session Complete!</h3>
|
||||
<p>Watched {{ sessionStatus.watched_count }} videos in {{ formatDuration(sessionStatus.total_duration) }}</p>
|
||||
<button @click="resetSession" class="btn btn-primary">Start New Session</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Video History -->
|
||||
<section class="history-section" v-if="videos.length > 0">
|
||||
<h2>Watched Videos</h2>
|
||||
<div class="video-grid">
|
||||
<div v-for="video in videos" :key="video.sequence_id" class="video-card">
|
||||
<div class="video-screenshot" v-if="video.screenshot_path">
|
||||
<img :src="video.screenshot_path" :alt="'Video ' + video.sequence_id">
|
||||
</div>
|
||||
<div class="video-placeholder" v-else>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="2" y1="7" x2="7" y2="7"></line>
|
||||
<line x1="2" y1="17" x2="7" y2="17"></line>
|
||||
<line x1="17" y1="17" x2="22" y2="17"></line>
|
||||
<line x1="17" y1="7" x2="22" y2="7"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-id">#{{ video.sequence_id }}</div>
|
||||
<div class="video-description" v-if="video.description">{{ video.description }}</div>
|
||||
<div class="video-stats">
|
||||
<span v-if="video.likes">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(video.likes) }}
|
||||
</span>
|
||||
<span v-if="video.comments">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(video.comments) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container">
|
||||
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.type">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/video-learning.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
devices: [],
|
||||
currentSessionId: null,
|
||||
sessionStatus: null,
|
||||
videos: [],
|
||||
loading: false,
|
||||
toasts: [],
|
||||
toastIdCounter: 0,
|
||||
|
||||
config: {
|
||||
deviceId: '',
|
||||
platform: 'douyin',
|
||||
targetCount: 10,
|
||||
category: '',
|
||||
watchDuration: 3.0,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadDevices();
|
||||
this.setupVideoLearningEvents();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadDevices() {
|
||||
try {
|
||||
const response = await axios.get('/api/devices');
|
||||
this.devices = response.data;
|
||||
} catch (error) {
|
||||
this.showToast('Failed to load devices', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createAndStartSession() {
|
||||
if (!this.config.deviceId) {
|
||||
this.showToast('Please select a device', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// Create session
|
||||
const createResult = await VideoLearningModule.createSession(
|
||||
this.config.deviceId,
|
||||
{
|
||||
platform: this.config.platform,
|
||||
targetCount: this.config.targetCount,
|
||||
category: this.config.category || null,
|
||||
watchDuration: this.config.watchDuration,
|
||||
}
|
||||
);
|
||||
|
||||
this.currentSessionId = createResult.session_id;
|
||||
this.showToast('Session created! Starting...', 'success');
|
||||
|
||||
// Start session
|
||||
await VideoLearningModule.startSession(this.currentSessionId);
|
||||
this.showToast('Learning session started!', 'success');
|
||||
|
||||
// Initial status update
|
||||
await this.updateSessionStatus();
|
||||
} catch (error) {
|
||||
this.showToast('Failed to create session: ' + error.message, 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async pauseSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'pause');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session paused', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to pause session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async resumeSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'resume');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session resumed', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to resume session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async stopSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to stop this session?')) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'stop');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session stopped', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to stop session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async updateSessionStatus() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
this.sessionStatus = await VideoLearningModule.getSessionStatus(this.currentSessionId);
|
||||
this.videos = await VideoLearningModule.getSessionVideos(this.currentSessionId);
|
||||
} catch (error) {
|
||||
console.error('Error updating session status:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setupVideoLearningEvents() {
|
||||
window.addEventListener('videoLearningUpdate', (event) => {
|
||||
const { status, videos } = event.detail;
|
||||
this.sessionStatus = status;
|
||||
this.videos = videos;
|
||||
});
|
||||
},
|
||||
|
||||
resetSession() {
|
||||
this.currentSessionId = null;
|
||||
this.sessionStatus = null;
|
||||
this.videos = [];
|
||||
VideoLearningModule.stopPolling();
|
||||
},
|
||||
|
||||
goBack() {
|
||||
window.location.href = '/';
|
||||
},
|
||||
|
||||
formatDuration(seconds) {
|
||||
return VideoLearningModule.formatDuration(seconds);
|
||||
},
|
||||
|
||||
formatNumber(num) {
|
||||
return VideoLearningModule.formatNumber(num);
|
||||
},
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const id = this.toastIdCounter++;
|
||||
this.toasts.push({ id, message, type });
|
||||
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 3000);
|
||||
},
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
VideoLearningModule.stopPolling();
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user