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:
let5sne.win10
2026-01-09 22:54:57 +08:00
parent 3552df23d6
commit 5b3f214e20
15 changed files with 2317 additions and 1 deletions

View File

@@ -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",
]

View 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"}

View File

@@ -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"))

View File

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

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

View File

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

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

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