Spaces:
Runtime error
Runtime error
chih.yikuan commited on
Commit ·
5ee5085
1
Parent(s): e5c2788
email-done
Browse files- .env +50 -0
- chatkit/backend/app/config.py +1 -1
- chatkit/backend/app/main.py +41 -1
- chatkit/backend/app/oauth.py +24 -4
- chatkit/backend/app/server.py +171 -24
- chatkit/frontend/index.html +1 -1
- chatkit/frontend/src/App.tsx +5 -5
- chatkit/frontend/src/components/AuthStatus.tsx +15 -15
- chatkit/frontend/src/components/ExamAnalyzer.tsx +80 -25
- chatkit/frontend/src/components/Features.tsx +15 -16
- chatkit/frontend/src/components/Hero.tsx +10 -11
- chatkit/frontend/src/index.css +14 -0
- chatkit/run-local.sh +76 -0
.env
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ExamInsight Environment Variables
|
| 2 |
+
# Copy to .env for local development
|
| 3 |
+
# For HF Spaces, add these as Secrets in the Space settings
|
| 4 |
+
|
| 5 |
+
# =============================================================================
|
| 6 |
+
# REQUIRED
|
| 7 |
+
# =============================================================================
|
| 8 |
+
|
| 9 |
+
# OpenAI API Key (required for ChatKit)
|
| 10 |
+
OPENAI_API_KEY=sk-your-openai-api-key
|
| 11 |
+
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# GOOGLE OAUTH (optional - for private Google Sheets)
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
# Get these from Google Cloud Console > APIs & Services > Credentials
|
| 17 |
+
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
| 18 |
+
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
|
| 19 |
+
|
| 20 |
+
# Redirect URI - update for production
|
| 21 |
+
# Local: http://localhost:8000/auth/callback
|
| 22 |
+
# HF Spaces: https://taboola-cz-examinsight.hf.space/auth/callback
|
| 23 |
+
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback
|
| 24 |
+
|
| 25 |
+
# =============================================================================
|
| 26 |
+
# EMAIL (optional - for sending reports)
|
| 27 |
+
# =============================================================================
|
| 28 |
+
|
| 29 |
+
# Option 1: Gmail SMTP (easier setup)
|
| 30 |
+
GMAIL_USER=your-email@gmail.com
|
| 31 |
+
GMAIL_APP_PASSWORD=your-16-char-app-password
|
| 32 |
+
|
| 33 |
+
# Option 2: SendGrid API
|
| 34 |
+
SENDGRID_API_KEY=SG.your-sendgrid-api-key
|
| 35 |
+
SENDGRID_FROM_EMAIL=examinsight@yourdomain.com
|
| 36 |
+
|
| 37 |
+
# =============================================================================
|
| 38 |
+
# SECURITY
|
| 39 |
+
# =============================================================================
|
| 40 |
+
|
| 41 |
+
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
| 42 |
+
ENCRYPTION_KEY=your-fernet-encryption-key
|
| 43 |
+
|
| 44 |
+
# =============================================================================
|
| 45 |
+
# FRONTEND (for HF Spaces domain key)
|
| 46 |
+
# =============================================================================
|
| 47 |
+
|
| 48 |
+
# Register your HF Space domain at:
|
| 49 |
+
# https://platform.openai.com/settings/organization/security/domain-allowlist
|
| 50 |
+
VITE_CHATKIT_API_DOMAIN_KEY=domain_pk_your-production-key
|
chatkit/backend/app/config.py
CHANGED
|
@@ -59,9 +59,9 @@ def get_settings() -> Settings:
|
|
| 59 |
|
| 60 |
|
| 61 |
# Google OAuth scopes required for the application
|
|
|
|
| 62 |
GOOGLE_SCOPES = [
|
| 63 |
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
| 64 |
-
"https://www.googleapis.com/auth/drive.readonly",
|
| 65 |
"https://www.googleapis.com/auth/userinfo.email",
|
| 66 |
"openid",
|
| 67 |
]
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
# Google OAuth scopes required for the application
|
| 62 |
+
# Note: drive.readonly is sensitive scope; spreadsheets.readonly is less restricted
|
| 63 |
GOOGLE_SCOPES = [
|
| 64 |
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
|
|
|
| 65 |
"https://www.googleapis.com/auth/userinfo.email",
|
| 66 |
"openid",
|
| 67 |
]
|
chatkit/backend/app/main.py
CHANGED
|
@@ -60,11 +60,51 @@ chatkit_server = ClassLensChatServer()
|
|
| 60 |
# ChatKit Endpoint
|
| 61 |
# =============================================================================
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
@app.post("/chatkit")
|
| 64 |
async def chatkit_endpoint(request: Request) -> Response:
|
| 65 |
"""Proxy the ChatKit web component payload to the server implementation."""
|
| 66 |
payload = await request.body()
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
if isinstance(result, StreamingResult):
|
| 70 |
return StreamingResponse(result, media_type="text/event-stream")
|
|
|
|
| 60 |
# ChatKit Endpoint
|
| 61 |
# =============================================================================
|
| 62 |
|
| 63 |
+
@app.get("/api/models")
|
| 64 |
+
async def get_available_models():
|
| 65 |
+
"""Get list of available AI models."""
|
| 66 |
+
from .server import AVAILABLE_MODELS, DEFAULT_MODEL
|
| 67 |
+
return {
|
| 68 |
+
"models": AVAILABLE_MODELS,
|
| 69 |
+
"default": DEFAULT_MODEL,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
@app.post("/chatkit")
|
| 74 |
async def chatkit_endpoint(request: Request) -> Response:
|
| 75 |
"""Proxy the ChatKit web component payload to the server implementation."""
|
| 76 |
payload = await request.body()
|
| 77 |
+
|
| 78 |
+
# Try to extract model from multiple sources
|
| 79 |
+
model = (
|
| 80 |
+
request.headers.get("X-Model") or
|
| 81 |
+
request.query_params.get("model") or
|
| 82 |
+
# Check if model is in cookies (set by frontend)
|
| 83 |
+
request.cookies.get("selected_model")
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
context = {
|
| 87 |
+
"request": request,
|
| 88 |
+
"model": model, # Pass model in context
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
# Also try to extract from payload if it's JSON
|
| 92 |
+
try:
|
| 93 |
+
import json
|
| 94 |
+
payload_json = json.loads(payload)
|
| 95 |
+
if isinstance(payload_json, dict):
|
| 96 |
+
# Check various possible locations for model
|
| 97 |
+
payload_model = (
|
| 98 |
+
payload_json.get("metadata", {}).get("model") or
|
| 99 |
+
payload_json.get("model") or
|
| 100 |
+
payload_json.get("config", {}).get("model")
|
| 101 |
+
)
|
| 102 |
+
if payload_model:
|
| 103 |
+
context["model"] = payload_model
|
| 104 |
+
except:
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
result = await chatkit_server.process(payload, context)
|
| 108 |
|
| 109 |
if isinstance(result, StreamingResult):
|
| 110 |
return StreamingResponse(result, media_type="text/event-stream")
|
chatkit/backend/app/oauth.py
CHANGED
|
@@ -30,6 +30,10 @@ def get_redirect_uri(request: Request, settings) -> str:
|
|
| 30 |
forwarded_proto = request.headers.get("x-forwarded-proto", "http")
|
| 31 |
forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost:8000")
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
# Build the redirect URI
|
| 34 |
return f"{forwarded_proto}://{forwarded_host}/auth/callback"
|
| 35 |
|
|
@@ -61,19 +65,28 @@ async def start_auth(teacher_email: str, request: Request):
|
|
| 61 |
_state_store[state] = {"email": teacher_email, "redirect_uri": redirect_uri}
|
| 62 |
|
| 63 |
# Build authorization URL
|
|
|
|
|
|
|
| 64 |
auth_params = {
|
| 65 |
"client_id": settings.google_client_id,
|
| 66 |
"redirect_uri": redirect_uri,
|
| 67 |
"response_type": "code",
|
| 68 |
"scope": " ".join(GOOGLE_SCOPES),
|
| 69 |
"access_type": "offline", # Get refresh token
|
| 70 |
-
"prompt": "consent", #
|
| 71 |
"state": state,
|
| 72 |
-
|
|
|
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
print(f"[OAuth] Starting auth for {teacher_email}")
|
| 76 |
print(f"[OAuth] Redirect URI: {redirect_uri}")
|
|
|
|
|
|
|
| 77 |
|
| 78 |
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}"
|
| 79 |
return RedirectResponse(url=auth_url)
|
|
@@ -92,7 +105,7 @@ def get_frontend_url(request: Request, settings) -> str:
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.get("/callback")
|
| 95 |
-
async def auth_callback(request: Request, code: str = None, state: str = None, error: str = None):
|
| 96 |
"""
|
| 97 |
Handle OAuth callback from Google.
|
| 98 |
|
|
@@ -100,13 +113,20 @@ async def auth_callback(request: Request, code: str = None, state: str = None, e
|
|
| 100 |
code: Authorization code from Google
|
| 101 |
state: State token for CSRF verification
|
| 102 |
error: Error message if authorization failed
|
|
|
|
| 103 |
"""
|
| 104 |
settings = get_settings()
|
| 105 |
frontend_url = get_frontend_url(request, settings)
|
| 106 |
|
| 107 |
if error:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return RedirectResponse(
|
| 109 |
-
url=f"{frontend_url}?auth_error={
|
| 110 |
)
|
| 111 |
|
| 112 |
if not code or not state:
|
|
|
|
| 30 |
forwarded_proto = request.headers.get("x-forwarded-proto", "http")
|
| 31 |
forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost:8000")
|
| 32 |
|
| 33 |
+
# Normalize 127.0.0.1 to localhost for local development (Google OAuth requires exact match)
|
| 34 |
+
if forwarded_host.startswith("127.0.0.1"):
|
| 35 |
+
forwarded_host = forwarded_host.replace("127.0.0.1", "localhost")
|
| 36 |
+
|
| 37 |
# Build the redirect URI
|
| 38 |
return f"{forwarded_proto}://{forwarded_host}/auth/callback"
|
| 39 |
|
|
|
|
| 65 |
_state_store[state] = {"email": teacher_email, "redirect_uri": redirect_uri}
|
| 66 |
|
| 67 |
# Build authorization URL
|
| 68 |
+
# Use "select_account consent" to allow account selection first, then show consent
|
| 69 |
+
# This is more flexible and works better when user isn't logged in
|
| 70 |
auth_params = {
|
| 71 |
"client_id": settings.google_client_id,
|
| 72 |
"redirect_uri": redirect_uri,
|
| 73 |
"response_type": "code",
|
| 74 |
"scope": " ".join(GOOGLE_SCOPES),
|
| 75 |
"access_type": "offline", # Get refresh token
|
| 76 |
+
"prompt": "select_account consent", # Allow account selection, then show consent
|
| 77 |
"state": state,
|
| 78 |
+
# Only add login_hint if email is provided and valid
|
| 79 |
+
# This helps pre-fill but doesn't break if user isn't logged in
|
| 80 |
}
|
| 81 |
|
| 82 |
+
# Add login_hint only if email looks valid (helps pre-fill but not required)
|
| 83 |
+
if teacher_email and "@" in teacher_email:
|
| 84 |
+
auth_params["login_hint"] = teacher_email
|
| 85 |
+
|
| 86 |
print(f"[OAuth] Starting auth for {teacher_email}")
|
| 87 |
print(f"[OAuth] Redirect URI: {redirect_uri}")
|
| 88 |
+
print(f"[OAuth] Scopes: {GOOGLE_SCOPES}")
|
| 89 |
+
print(f"[OAuth] Full auth URL: https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}")
|
| 90 |
|
| 91 |
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(auth_params)}"
|
| 92 |
return RedirectResponse(url=auth_url)
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
@router.get("/callback")
|
| 108 |
+
async def auth_callback(request: Request, code: str = None, state: str = None, error: str = None, error_description: str = None):
|
| 109 |
"""
|
| 110 |
Handle OAuth callback from Google.
|
| 111 |
|
|
|
|
| 113 |
code: Authorization code from Google
|
| 114 |
state: State token for CSRF verification
|
| 115 |
error: Error message if authorization failed
|
| 116 |
+
error_description: Detailed error description from Google
|
| 117 |
"""
|
| 118 |
settings = get_settings()
|
| 119 |
frontend_url = get_frontend_url(request, settings)
|
| 120 |
|
| 121 |
if error:
|
| 122 |
+
# Log the full error details
|
| 123 |
+
error_msg = error
|
| 124 |
+
if error_description:
|
| 125 |
+
error_msg = f"{error}: {error_description}"
|
| 126 |
+
print(f"[OAuth] Error callback: {error_msg}")
|
| 127 |
+
print(f"[OAuth] Full query params: {dict(request.query_params)}")
|
| 128 |
return RedirectResponse(
|
| 129 |
+
url=f"{frontend_url}?auth_error={error_msg}"
|
| 130 |
)
|
| 131 |
|
| 132 |
if not code or not state:
|
chatkit/backend/app/server.py
CHANGED
|
@@ -18,7 +18,46 @@ from .status_tracker import update_status, add_reasoning_step, WorkflowStep, res
|
|
| 18 |
|
| 19 |
|
| 20 |
MAX_RECENT_ITEMS = 50
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Current session ID (simplified - in production use proper session management)
|
| 24 |
_current_session_id = "default"
|
|
@@ -29,6 +68,39 @@ def set_session_id(session_id: str):
|
|
| 29 |
_current_session_id = session_id
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# =============================================================================
|
| 33 |
# Tool Definitions with Status Tracking
|
| 34 |
# =============================================================================
|
|
@@ -199,6 +271,12 @@ async def log_reasoning(thought: str) -> str:
|
|
| 199 |
|
| 200 |
EXAMINSIGHT_INSTRUCTIONS = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports for teachers.
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
## Your Core Mission
|
| 203 |
|
| 204 |
Transform raw Google Form quiz responses into professional, teacher-ready HTML reports with:
|
|
@@ -343,15 +421,62 @@ new Chart(document.getElementById('questionChart').getContext('2d'), {
|
|
| 343 |
});
|
| 344 |
```
|
| 345 |
|
| 346 |
-
### Step 5:
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
|
| 349 |
## Output Format
|
| 350 |
|
| 351 |
-
When
|
| 352 |
-
1. First display a
|
| 353 |
-
2.
|
| 354 |
-
3.
|
|
|
|
|
|
|
| 355 |
|
| 356 |
## Design Principles
|
| 357 |
|
|
@@ -375,24 +500,40 @@ When generating the report:
|
|
| 375 |
- Never expose full student identifiers
|
| 376 |
- Group low performers sensitively
|
| 377 |
|
| 378 |
-
|
| 379 |
-
1. Google Form/Sheet URL
|
| 380 |
-
2. Their email (for sending the report)
|
| 381 |
-
3. Optionally: correct answers if not embedded in the form"""
|
| 382 |
|
|
|
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
|
| 398 |
# =============================================================================
|
|
@@ -416,6 +557,12 @@ class ClassLensChatServer(ChatKitServer[dict[str, Any]]):
|
|
| 416 |
reset_status(thread.id)
|
| 417 |
set_session_id(thread.id)
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
items_page = await self.store.load_thread_items(
|
| 420 |
thread.id,
|
| 421 |
after=None,
|
|
@@ -433,7 +580,7 @@ class ClassLensChatServer(ChatKitServer[dict[str, Any]]):
|
|
| 433 |
)
|
| 434 |
|
| 435 |
result = Runner.run_streamed(
|
| 436 |
-
|
| 437 |
agent_input,
|
| 438 |
context=agent_context,
|
| 439 |
)
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
MAX_RECENT_ITEMS = 50
|
| 21 |
+
DEFAULT_MODEL = "gpt-4.1-mini" # Default model for cost efficiency (~10x cheaper)
|
| 22 |
+
|
| 23 |
+
# Available models
|
| 24 |
+
AVAILABLE_MODELS = {
|
| 25 |
+
"gpt-4.1-mini": {
|
| 26 |
+
"name": "GPT-4.1-mini",
|
| 27 |
+
"description": "快速且經濟實惠(默認)",
|
| 28 |
+
"cost": "低",
|
| 29 |
+
},
|
| 30 |
+
"gpt-4o": {
|
| 31 |
+
"name": "GPT-4o",
|
| 32 |
+
"description": "高性能,適合複雜分析",
|
| 33 |
+
"cost": "中",
|
| 34 |
+
},
|
| 35 |
+
"gpt-4o-mini": {
|
| 36 |
+
"name": "GPT-4o-mini",
|
| 37 |
+
"description": "平衡性能與成本",
|
| 38 |
+
"cost": "低",
|
| 39 |
+
},
|
| 40 |
+
"gpt-5-pro": {
|
| 41 |
+
"name": "GPT-5 Pro",
|
| 42 |
+
"description": "最新旗艦模型,具備強大推理能力",
|
| 43 |
+
"cost": "高",
|
| 44 |
+
},
|
| 45 |
+
"o3-pro": {
|
| 46 |
+
"name": "O3 Pro",
|
| 47 |
+
"description": "深度推理模型,適合複雜問題分析",
|
| 48 |
+
"cost": "高",
|
| 49 |
+
},
|
| 50 |
+
"o1-preview": {
|
| 51 |
+
"name": "O1 Preview",
|
| 52 |
+
"description": "具備深度推理能力",
|
| 53 |
+
"cost": "高",
|
| 54 |
+
},
|
| 55 |
+
"o1-mini": {
|
| 56 |
+
"name": "O1 Mini",
|
| 57 |
+
"description": "具備推理能力,經濟實惠",
|
| 58 |
+
"cost": "中",
|
| 59 |
+
},
|
| 60 |
+
}
|
| 61 |
|
| 62 |
# Current session ID (simplified - in production use proper session management)
|
| 63 |
_current_session_id = "default"
|
|
|
|
| 68 |
_current_session_id = session_id
|
| 69 |
|
| 70 |
|
| 71 |
+
def get_model_from_context(context: dict[str, Any]) -> str:
|
| 72 |
+
"""Get model from context, default to DEFAULT_MODEL if not specified."""
|
| 73 |
+
# Try multiple ways to get model from context
|
| 74 |
+
model = (
|
| 75 |
+
context.get("model") or
|
| 76 |
+
context.get("request", {}).get("model") or
|
| 77 |
+
# Check query params
|
| 78 |
+
(context.get("request", {}).get("query_params", {}).get("model") if hasattr(context.get("request", {}), "query_params") else None) or
|
| 79 |
+
# Check headers
|
| 80 |
+
(context.get("request", {}).headers.get("x-model") if hasattr(context.get("request", {}), "headers") else None)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
if model and model in AVAILABLE_MODELS:
|
| 84 |
+
return model
|
| 85 |
+
return DEFAULT_MODEL
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def create_agent(model: str) -> Agent[AgentContext[dict[str, Any]]]:
|
| 89 |
+
"""Create an agent with the specified model."""
|
| 90 |
+
return Agent[AgentContext[dict[str, Any]]](
|
| 91 |
+
model=model,
|
| 92 |
+
name="ClassLens",
|
| 93 |
+
instructions=EXAMINSIGHT_INSTRUCTIONS,
|
| 94 |
+
tools=[
|
| 95 |
+
fetch_responses,
|
| 96 |
+
parse_csv_data,
|
| 97 |
+
send_report_email,
|
| 98 |
+
save_analysis_report,
|
| 99 |
+
log_reasoning,
|
| 100 |
+
],
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
# =============================================================================
|
| 105 |
# Tool Definitions with Status Tracking
|
| 106 |
# =============================================================================
|
|
|
|
| 271 |
|
| 272 |
EXAMINSIGHT_INSTRUCTIONS = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports for teachers.
|
| 273 |
|
| 274 |
+
## Language & Communication
|
| 275 |
+
- Always communicate in Traditional Chinese (繁體中文, zh-TW)
|
| 276 |
+
- Use polite and professional language
|
| 277 |
+
- When greeting users, say: "您好!我是 ClassLens 助手,今天能為您做些什麼?"
|
| 278 |
+
- All responses, explanations, and reports should be in Traditional Chinese unless the user specifically requests English
|
| 279 |
+
|
| 280 |
## Your Core Mission
|
| 281 |
|
| 282 |
Transform raw Google Form quiz responses into professional, teacher-ready HTML reports with:
|
|
|
|
| 421 |
});
|
| 422 |
```
|
| 423 |
|
| 424 |
+
### Step 5: Summary and Email Report
|
| 425 |
+
|
| 426 |
+
**CRITICAL: After analyzing the exam data, follow these steps:**
|
| 427 |
+
|
| 428 |
+
1. **Display a bullet-point summary** in chat (in Traditional Chinese):
|
| 429 |
+
- 總學生數、平均分數、最高分、最低分
|
| 430 |
+
- 各題正確率(例如:Q1: 85%, Q2: 60%, Q3: 90%)
|
| 431 |
+
- 主要發現(例如:多數學生在 Q2 答錯,可能對某概念理解不足)
|
| 432 |
+
- 需要關注的學生(低分學生)
|
| 433 |
+
- 表現優秀的學生(可作為同儕導師)
|
| 434 |
+
|
| 435 |
+
Format example:
|
| 436 |
+
```
|
| 437 |
+
📊 考試分析摘要:
|
| 438 |
+
|
| 439 |
+
• 總學生數:25 人
|
| 440 |
+
• 平均分數:72 分(滿分 100)
|
| 441 |
+
• 最高分:95 分,最低分:45 分
|
| 442 |
+
|
| 443 |
+
📈 各題正確率:
|
| 444 |
+
• Q1:85% 正確
|
| 445 |
+
• Q2:60% 正確 ⚠️(需要加強)
|
| 446 |
+
• Q3:90% 正確
|
| 447 |
+
|
| 448 |
+
🔍 主要發現:
|
| 449 |
+
• 多數學生在 Q2 答錯,可能對「加速度」概念理解不足
|
| 450 |
+
• 約 30% 學生在開放式問題中表達不清楚
|
| 451 |
+
|
| 452 |
+
👥 需要關注的學生:3 人(分數低於 50 分)
|
| 453 |
+
⭐ 表現優秀的學生:5 人(分數高於 90 分,可作為同儕導師)
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
2. **Ask for confirmation**: After showing the summary, ask in Traditional Chinese:
|
| 457 |
+
"以上是分析摘要。您希望我生成完整的 HTML 詳細��告並發送到您的電子郵件嗎?"
|
| 458 |
+
|
| 459 |
+
3. **Wait for user confirmation** before generating and sending the HTML report
|
| 460 |
+
- Only proceed when the teacher explicitly confirms (says "yes", "send", "發送", "好的", "是", "要", "生成", "寄給我", etc.)
|
| 461 |
+
|
| 462 |
+
4. **After confirmation**:
|
| 463 |
+
- Generate the complete HTML report with all sections (Q&A Analysis, Statistics with charts, Teacher Insights)
|
| 464 |
+
- Create an appropriate email subject line (e.g., "考試分析報告 - [Quiz Title] - [Date]" or "ClassLens 考試分析報告")
|
| 465 |
+
- Call `send_report_email` with:
|
| 466 |
+
* email: teacher's email address
|
| 467 |
+
* subject: the email subject line you created
|
| 468 |
+
* body_markdown: the complete HTML report content
|
| 469 |
+
- After successfully sending, confirm with: "報告已生成並發送到您的電子郵件「[subject]」。報告包含詳細的題目分析、統計圖表和教學建議。請檢查您的收件匣。"
|
| 470 |
+
- Make sure to include the actual subject line in the confirmation message (replace [subject] with the actual subject you used)
|
| 471 |
|
| 472 |
## Output Format
|
| 473 |
|
| 474 |
+
When analyzing exam data:
|
| 475 |
+
1. First display a bullet-point summary in chat (in Traditional Chinese)
|
| 476 |
+
2. Ask: "以上是分析摘要。您希望我生成完整的 HTML 詳細報告並發送到您的電子郵件嗎?"
|
| 477 |
+
3. Wait for user confirmation
|
| 478 |
+
4. Only after confirmation: Generate complete HTML report and send via email
|
| 479 |
+
5. Do NOT automatically generate or send the HTML report - always show summary first and ask for confirmation
|
| 480 |
|
| 481 |
## Design Principles
|
| 482 |
|
|
|
|
| 500 |
- Never expose full student identifiers
|
| 501 |
- Group low performers sensitively
|
| 502 |
|
| 503 |
+
## Initial Conversation Flow
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
+
When starting a new conversation, follow this sequence:
|
| 506 |
|
| 507 |
+
1. **Greet the teacher** in Traditional Chinese: "您好!我是 ClassLens 助手,今天能為您做些什麼?"
|
| 508 |
+
|
| 509 |
+
2. **Ask for Google Form/Sheet URL**: "請提供您的 Google 表單或試算表網址。"
|
| 510 |
+
|
| 511 |
+
3. **Ask about answer key** (標準答案):
|
| 512 |
+
- "請問您是否方便提供本次考試的正確答案(標準答案)?"
|
| 513 |
+
- "如果您有標準答案,請提供給我,這樣我可以更準確地評分和分析。"
|
| 514 |
+
- "如果您沒有標準答案,我可以嘗試根據學生的回答模式自動推斷標準答案。您希望我為您自動生成標準答案嗎?"
|
| 515 |
+
|
| 516 |
+
4. **Ask for email**: "請提供您的電子郵件地址,以便我將分析報告發送給您。"
|
| 517 |
+
|
| 518 |
+
5. **Wait for all information** before starting analysis:
|
| 519 |
+
- Google Form/Sheet URL (required)
|
| 520 |
+
- Answer key (optional, but recommended for accuracy)
|
| 521 |
+
- Teacher email (required for sending report)
|
| 522 |
+
|
| 523 |
+
## Answer Key Handling
|
| 524 |
+
|
| 525 |
+
- **If teacher provides answer key**: Use it directly for accurate grading
|
| 526 |
+
- **If teacher doesn't have answer key but wants auto-generation**:
|
| 527 |
+
- Analyze student responses to infer the most likely correct answers
|
| 528 |
+
- Show the inferred answers to the teacher for confirmation
|
| 529 |
+
- Ask: "根據學生回答模式,我推斷的標準答案如下:[列出答案]。請確認這些答案是否正確,或提供修正。"
|
| 530 |
+
- **If teacher doesn't provide and doesn't want auto-generation**:
|
| 531 |
+
- Proceed with analysis but note that grading accuracy may be limited
|
| 532 |
+
- Focus on response patterns and common mistakes rather than absolute correctness"""
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
# Default agent (will be overridden by dynamic agent creation in respond method)
|
| 536 |
+
classlens_agent = create_agent(DEFAULT_MODEL)
|
| 537 |
|
| 538 |
|
| 539 |
# =============================================================================
|
|
|
|
| 557 |
reset_status(thread.id)
|
| 558 |
set_session_id(thread.id)
|
| 559 |
|
| 560 |
+
# Get model from context (user selection from frontend)
|
| 561 |
+
selected_model = get_model_from_context(context)
|
| 562 |
+
|
| 563 |
+
# Create agent with selected model
|
| 564 |
+
agent = create_agent(selected_model)
|
| 565 |
+
|
| 566 |
items_page = await self.store.load_thread_items(
|
| 567 |
thread.id,
|
| 568 |
after=None,
|
|
|
|
| 580 |
)
|
| 581 |
|
| 582 |
result = Runner.run_streamed(
|
| 583 |
+
agent,
|
| 584 |
agent_input,
|
| 585 |
context=agent_context,
|
| 586 |
)
|
chatkit/frontend/index.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
chatkit/frontend/src/App.tsx
CHANGED
|
@@ -50,7 +50,7 @@ function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
|
|
| 50 |
ClassLens
|
| 51 |
</h1>
|
| 52 |
<p className="text-[var(--color-text-muted)] mt-2">
|
| 53 |
-
AI
|
| 54 |
</p>
|
| 55 |
</div>
|
| 56 |
|
|
@@ -61,7 +61,7 @@ function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
|
|
| 61 |
htmlFor="invite-code"
|
| 62 |
className="block text-sm font-medium text-[var(--color-text-muted)] mb-2"
|
| 63 |
>
|
| 64 |
-
|
| 65 |
</label>
|
| 66 |
<input
|
| 67 |
id="invite-code"
|
|
@@ -71,7 +71,7 @@ function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
|
|
| 71 |
setCode(e.target.value);
|
| 72 |
setError(false);
|
| 73 |
}}
|
| 74 |
-
placeholder="
|
| 75 |
className={`w-full px-4 py-3 bg-[var(--color-background)] border rounded-xl text-[var(--color-text)] placeholder-[var(--color-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] transition-all ${
|
| 76 |
error
|
| 77 |
? "border-red-500 focus:ring-red-500"
|
|
@@ -84,7 +84,7 @@ function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
|
|
| 84 |
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 85 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 86 |
</svg>
|
| 87 |
-
|
| 88 |
</p>
|
| 89 |
)}
|
| 90 |
</div>
|
|
@@ -93,7 +93,7 @@ function InviteCodeGate({ onSuccess }: { onSuccess: () => void }) {
|
|
| 93 |
type="submit"
|
| 94 |
className="w-full py-3 px-4 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-accent)] text-white font-semibold rounded-xl hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)]"
|
| 95 |
>
|
| 96 |
-
|
| 97 |
</button>
|
| 98 |
</form>
|
| 99 |
|
|
|
|
| 50 |
ClassLens
|
| 51 |
</h1>
|
| 52 |
<p className="text-[var(--color-text-muted)] mt-2">
|
| 53 |
+
AI 驅動的考試分析
|
| 54 |
</p>
|
| 55 |
</div>
|
| 56 |
|
|
|
|
| 61 |
htmlFor="invite-code"
|
| 62 |
className="block text-sm font-medium text-[var(--color-text-muted)] mb-2"
|
| 63 |
>
|
| 64 |
+
輸入邀請碼
|
| 65 |
</label>
|
| 66 |
<input
|
| 67 |
id="invite-code"
|
|
|
|
| 71 |
setCode(e.target.value);
|
| 72 |
setError(false);
|
| 73 |
}}
|
| 74 |
+
placeholder="請輸入您的邀請碼..."
|
| 75 |
className={`w-full px-4 py-3 bg-[var(--color-background)] border rounded-xl text-[var(--color-text)] placeholder-[var(--color-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] transition-all ${
|
| 76 |
error
|
| 77 |
? "border-red-500 focus:ring-red-500"
|
|
|
|
| 84 |
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 85 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 86 |
</svg>
|
| 87 |
+
邀請碼無效,請重試。
|
| 88 |
</p>
|
| 89 |
)}
|
| 90 |
</div>
|
|
|
|
| 93 |
type="submit"
|
| 94 |
className="w-full py-3 px-4 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-accent)] text-white font-semibold rounded-xl hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)]"
|
| 95 |
>
|
| 96 |
+
進入應用程式 →
|
| 97 |
</button>
|
| 98 |
</form>
|
| 99 |
|
chatkit/frontend/src/components/AuthStatus.tsx
CHANGED
|
@@ -45,7 +45,7 @@ export function AuthStatus({
|
|
| 45 |
setError(null);
|
| 46 |
|
| 47 |
if (!emailInput.trim()) {
|
| 48 |
-
setError("
|
| 49 |
return;
|
| 50 |
}
|
| 51 |
|
|
@@ -69,7 +69,7 @@ export function AuthStatus({
|
|
| 69 |
const data = await response.json();
|
| 70 |
if (data.detail?.includes('not configured')) {
|
| 71 |
setAuthMode('csv-only');
|
| 72 |
-
setError("Google OAuth
|
| 73 |
// Set as "connected" with just email for CSV fallback
|
| 74 |
setTeacherEmail(emailInput);
|
| 75 |
setIsAuthenticated(true);
|
|
@@ -110,12 +110,12 @@ export function AuthStatus({
|
|
| 110 |
</svg>
|
| 111 |
</div>
|
| 112 |
<h2 className="font-display text-2xl font-bold text-[var(--color-text)] mb-2">
|
| 113 |
-
{isAuthenticated ? "
|
| 114 |
</h2>
|
| 115 |
<p className="text-[var(--color-text-muted)]">
|
| 116 |
{isAuthenticated
|
| 117 |
-
? "
|
| 118 |
-
: "
|
| 119 |
</p>
|
| 120 |
</div>
|
| 121 |
|
|
@@ -130,14 +130,14 @@ export function AuthStatus({
|
|
| 130 |
<div className="text-left">
|
| 131 |
<div className="font-medium text-[var(--color-text)]">{teacherEmail}</div>
|
| 132 |
<div className="text-sm text-[var(--color-text-muted)]">
|
| 133 |
-
{authMode === 'csv-only' ? 'CSV
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
{authMode === 'csv-only' && (
|
| 139 |
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm">
|
| 140 |
-
<strong>
|
| 141 |
</div>
|
| 142 |
)}
|
| 143 |
|
|
@@ -145,14 +145,14 @@ export function AuthStatus({
|
|
| 145 |
onClick={handleDisconnect}
|
| 146 |
className="w-full btn btn-outline text-red-500 border-red-200 hover:border-red-500 hover:text-red-600"
|
| 147 |
>
|
| 148 |
-
|
| 149 |
</button>
|
| 150 |
</div>
|
| 151 |
) : (
|
| 152 |
<form onSubmit={handleConnect} className="space-y-4">
|
| 153 |
<div>
|
| 154 |
<label htmlFor="email" className="block text-sm font-medium text-[var(--color-text)] mb-2">
|
| 155 |
-
|
| 156 |
</label>
|
| 157 |
<input
|
| 158 |
type="email"
|
|
@@ -182,7 +182,7 @@ export function AuthStatus({
|
|
| 182 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 183 |
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 184 |
</svg>
|
| 185 |
-
|
| 186 |
</>
|
| 187 |
) : (
|
| 188 |
<>
|
|
@@ -192,16 +192,16 @@ export function AuthStatus({
|
|
| 192 |
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 193 |
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 194 |
</svg>
|
| 195 |
-
|
| 196 |
</>
|
| 197 |
)}
|
| 198 |
</button>
|
| 199 |
|
| 200 |
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
| 201 |
-
|
| 202 |
-
<a href="#" className="underline hover:text-[var(--color-primary)]">
|
| 203 |
-
{" "}
|
| 204 |
-
<a href="#" className="underline hover:text-[var(--color-primary)]">
|
| 205 |
</p>
|
| 206 |
</form>
|
| 207 |
)}
|
|
|
|
| 45 |
setError(null);
|
| 46 |
|
| 47 |
if (!emailInput.trim()) {
|
| 48 |
+
setError("請輸入您的電子郵件地址");
|
| 49 |
return;
|
| 50 |
}
|
| 51 |
|
|
|
|
| 69 |
const data = await response.json();
|
| 70 |
if (data.detail?.includes('not configured')) {
|
| 71 |
setAuthMode('csv-only');
|
| 72 |
+
setError("Google OAuth 未設定。您仍可在聊天中直接上傳 CSV 檔案使用應用程式!");
|
| 73 |
// Set as "connected" with just email for CSV fallback
|
| 74 |
setTeacherEmail(emailInput);
|
| 75 |
setIsAuthenticated(true);
|
|
|
|
| 110 |
</svg>
|
| 111 |
</div>
|
| 112 |
<h2 className="font-display text-2xl font-bold text-[var(--color-text)] mb-2">
|
| 113 |
+
{isAuthenticated ? "已連接!" : "連接您的 Google 帳號"}
|
| 114 |
</h2>
|
| 115 |
<p className="text-[var(--color-text-muted)]">
|
| 116 |
{isAuthenticated
|
| 117 |
+
? "您的 Google 帳號已連結。現在可以分析考試答案了。"
|
| 118 |
+
: "我們只會存取您的 Google 表單回應試算表。不會在我們的伺服器上儲存任何資料。"}
|
| 119 |
</p>
|
| 120 |
</div>
|
| 121 |
|
|
|
|
| 130 |
<div className="text-left">
|
| 131 |
<div className="font-medium text-[var(--color-text)]">{teacherEmail}</div>
|
| 132 |
<div className="text-sm text-[var(--color-text-muted)]">
|
| 133 |
+
{authMode === 'csv-only' ? 'CSV 上傳模式(Google OAuth 未設定)' : 'Google 帳號已連接'}
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
{authMode === 'csv-only' && (
|
| 139 |
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm">
|
| 140 |
+
<strong>注意:</strong>您可以在聊天中直接貼上 CSV 資料,或設定 Google OAuth 從 Google 表單取得資料。
|
| 141 |
</div>
|
| 142 |
)}
|
| 143 |
|
|
|
|
| 145 |
onClick={handleDisconnect}
|
| 146 |
className="w-full btn btn-outline text-red-500 border-red-200 hover:border-red-500 hover:text-red-600"
|
| 147 |
>
|
| 148 |
+
斷開帳號
|
| 149 |
</button>
|
| 150 |
</div>
|
| 151 |
) : (
|
| 152 |
<form onSubmit={handleConnect} className="space-y-4">
|
| 153 |
<div>
|
| 154 |
<label htmlFor="email" className="block text-sm font-medium text-[var(--color-text)] mb-2">
|
| 155 |
+
您的學校電子郵件
|
| 156 |
</label>
|
| 157 |
<input
|
| 158 |
type="email"
|
|
|
|
| 182 |
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 183 |
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 184 |
</svg>
|
| 185 |
+
檢查中...
|
| 186 |
</>
|
| 187 |
) : (
|
| 188 |
<>
|
|
|
|
| 192 |
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 193 |
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 194 |
</svg>
|
| 195 |
+
使用 Google 繼續
|
| 196 |
</>
|
| 197 |
)}
|
| 198 |
</button>
|
| 199 |
|
| 200 |
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
| 201 |
+
連接即表示您同意我們的{" "}
|
| 202 |
+
<a href="#" className="underline hover:text-[var(--color-primary)]">服務條款</a>
|
| 203 |
+
{" "}和{" "}
|
| 204 |
+
<a href="#" className="underline hover:text-[var(--color-primary)]">隱私政策</a>
|
| 205 |
</p>
|
| 206 |
</form>
|
| 207 |
)}
|
chatkit/frontend/src/components/ExamAnalyzer.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
import { ChatKit, useChatKit } from "@openai/chatkit-react";
|
| 3 |
import { CHATKIT_API_DOMAIN_KEY, CHATKIT_API_URL } from "../lib/config";
|
| 4 |
import { ReasoningPanel } from "./ReasoningPanel";
|
|
@@ -10,9 +10,50 @@ interface ExamAnalyzerProps {
|
|
| 10 |
|
| 11 |
export function ExamAnalyzer({ teacherEmail, onBack }: ExamAnalyzerProps) {
|
| 12 |
const [showCopiedToast, setShowCopiedToast] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const chatkit = useChatKit({
|
| 15 |
-
api: {
|
|
|
|
|
|
|
|
|
|
| 16 |
composer: {
|
| 17 |
attachments: { enabled: false },
|
| 18 |
},
|
|
@@ -26,19 +67,19 @@ export function ExamAnalyzer({ teacherEmail, onBack }: ExamAnalyzerProps) {
|
|
| 26 |
|
| 27 |
const examplePrompts = [
|
| 28 |
{
|
| 29 |
-
title: "📊
|
| 30 |
-
prompt: `
|
| 31 |
https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit
|
| 32 |
|
| 33 |
-
|
| 34 |
- Q1: 4
|
| 35 |
-
- Q2:
|
| 36 |
|
| 37 |
-
|
| 38 |
},
|
| 39 |
{
|
| 40 |
-
title: "📝
|
| 41 |
-
prompt: `
|
| 42 |
|
| 43 |
Timestamp,Email,Student Name,Q1 (2+2),Q2 (Explain acceleration)
|
| 44 |
2026-01-26 08:00:00,alice@student.edu,Alice,4,Acceleration is the rate of change of velocity
|
|
@@ -46,15 +87,15 @@ Timestamp,Email,Student Name,Q1 (2+2),Q2 (Explain acceleration)
|
|
| 46 |
2026-01-26 08:02:00,carol@student.edu,Carol,4,velocity change over time
|
| 47 |
2026-01-26 08:03:00,david@student.edu,David,5,speed
|
| 48 |
|
| 49 |
-
|
| 50 |
- Q1: 4
|
| 51 |
- Q2: Acceleration is the rate of change of velocity over time
|
| 52 |
|
| 53 |
-
|
| 54 |
},
|
| 55 |
{
|
| 56 |
-
title: "⚡
|
| 57 |
-
prompt: `
|
| 58 |
},
|
| 59 |
];
|
| 60 |
|
|
@@ -63,7 +104,7 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 63 |
{/* Copied toast */}
|
| 64 |
{showCopiedToast && (
|
| 65 |
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg">
|
| 66 |
-
✓
|
| 67 |
</div>
|
| 68 |
)}
|
| 69 |
|
|
@@ -77,14 +118,28 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 77 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 78 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
| 79 |
</svg>
|
| 80 |
-
|
| 81 |
</button>
|
| 82 |
|
| 83 |
<div className="flex items-center gap-3">
|
| 84 |
-
<span className="text-sm text-gray-500">
|
| 85 |
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
| 86 |
{teacherEmail}
|
| 87 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
|
|
@@ -97,7 +152,7 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 97 |
{/* Example Prompts */}
|
| 98 |
<div className="bg-white rounded-xl shadow-sm border p-4">
|
| 99 |
<h3 className="text-sm font-semibold text-gray-800 mb-3">
|
| 100 |
-
|
| 101 |
</h3>
|
| 102 |
<div className="space-y-2">
|
| 103 |
{examplePrompts.map((example, i) => (
|
|
@@ -110,7 +165,7 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 110 |
{example.title}
|
| 111 |
</div>
|
| 112 |
<div className="text-xs text-gray-500">
|
| 113 |
-
|
| 114 |
</div>
|
| 115 |
</button>
|
| 116 |
))}
|
|
@@ -120,20 +175,20 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 120 |
{/* Tips */}
|
| 121 |
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-4 border border-blue-100">
|
| 122 |
<h3 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
| 123 |
-
💡
|
| 124 |
</h3>
|
| 125 |
<ul className="space-y-1.5 text-xs text-gray-600">
|
| 126 |
<li className="flex items-start gap-1.5">
|
| 127 |
<span className="text-green-500">✓</span>
|
| 128 |
-
|
| 129 |
</li>
|
| 130 |
<li className="flex items-start gap-1.5">
|
| 131 |
<span className="text-green-500">✓</span>
|
| 132 |
-
|
| 133 |
</li>
|
| 134 |
<li className="flex items-start gap-1.5">
|
| 135 |
<span className="text-green-500">✓</span>
|
| 136 |
-
|
| 137 |
</li>
|
| 138 |
</ul>
|
| 139 |
</div>
|
|
@@ -153,15 +208,15 @@ My email is ${teacherEmail}. Please grade and create a full report.`,
|
|
| 153 |
</div>
|
| 154 |
<div>
|
| 155 |
<h2 className="text-lg font-semibold text-white">
|
| 156 |
-
ClassLens
|
| 157 |
</h2>
|
| 158 |
<p className="text-sm text-white/70">
|
| 159 |
-
|
| 160 |
</p>
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
<span className="px-2 py-1 rounded-full bg-white/20 text-white text-xs">
|
| 164 |
-
|
| 165 |
</span>
|
| 166 |
</div>
|
| 167 |
</div>
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
import { ChatKit, useChatKit } from "@openai/chatkit-react";
|
| 3 |
import { CHATKIT_API_DOMAIN_KEY, CHATKIT_API_URL } from "../lib/config";
|
| 4 |
import { ReasoningPanel } from "./ReasoningPanel";
|
|
|
|
| 10 |
|
| 11 |
export function ExamAnalyzer({ teacherEmail, onBack }: ExamAnalyzerProps) {
|
| 12 |
const [showCopiedToast, setShowCopiedToast] = useState(false);
|
| 13 |
+
const [selectedModel, setSelectedModel] = useState("gpt-4.1-mini");
|
| 14 |
+
const [availableModels, setAvailableModels] = useState<Record<string, {name: string; description: string; cost: string}>>({});
|
| 15 |
+
|
| 16 |
+
// Load available models
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
fetch("/api/models")
|
| 19 |
+
.then(res => res.json())
|
| 20 |
+
.then(data => {
|
| 21 |
+
setAvailableModels(data.models || {});
|
| 22 |
+
setSelectedModel(data.default || "gpt-4.1-mini");
|
| 23 |
+
})
|
| 24 |
+
.catch(err => console.error("Failed to load models:", err));
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
// Force Traditional Chinese locale for ChatKit
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
// Set document language to zh-TW
|
| 30 |
+
document.documentElement.lang = 'zh-TW';
|
| 31 |
+
|
| 32 |
+
// Try to override browser language detection
|
| 33 |
+
if (navigator.language) {
|
| 34 |
+
Object.defineProperty(navigator, 'language', {
|
| 35 |
+
get: () => 'zh-TW',
|
| 36 |
+
configurable: true,
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Set Accept-Language header hint (may not work for iframe)
|
| 41 |
+
const meta = document.createElement('meta');
|
| 42 |
+
meta.httpEquiv = 'Content-Language';
|
| 43 |
+
meta.content = 'zh-TW';
|
| 44 |
+
document.head.appendChild(meta);
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
// Store selected model in sessionStorage for backend access
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
sessionStorage.setItem("selected_model", selectedModel);
|
| 50 |
+
}, [selectedModel]);
|
| 51 |
|
| 52 |
const chatkit = useChatKit({
|
| 53 |
+
api: {
|
| 54 |
+
url: CHATKIT_API_URL,
|
| 55 |
+
domainKey: CHATKIT_API_DOMAIN_KEY,
|
| 56 |
+
},
|
| 57 |
composer: {
|
| 58 |
attachments: { enabled: false },
|
| 59 |
},
|
|
|
|
| 67 |
|
| 68 |
const examplePrompts = [
|
| 69 |
{
|
| 70 |
+
title: "📊 分析 Google 試算表",
|
| 71 |
+
prompt: `請分析這個 Google 試算表中的考試答案:
|
| 72 |
https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit
|
| 73 |
|
| 74 |
+
標準答案是:
|
| 75 |
- Q1: 4
|
| 76 |
+
- Q2: 加速度是速度的變化率
|
| 77 |
|
| 78 |
+
我的電子郵件是 ${teacherEmail}。請評分、解釋錯誤答案、建議同儕學習小組,並將報告發送到我的電子郵件。`,
|
| 79 |
},
|
| 80 |
{
|
| 81 |
+
title: "📝 分析 CSV 資料",
|
| 82 |
+
prompt: `請分析這些考試資料:
|
| 83 |
|
| 84 |
Timestamp,Email,Student Name,Q1 (2+2),Q2 (Explain acceleration)
|
| 85 |
2026-01-26 08:00:00,alice@student.edu,Alice,4,Acceleration is the rate of change of velocity
|
|
|
|
| 87 |
2026-01-26 08:02:00,carol@student.edu,Carol,4,velocity change over time
|
| 88 |
2026-01-26 08:03:00,david@student.edu,David,5,speed
|
| 89 |
|
| 90 |
+
標準答案是:
|
| 91 |
- Q1: 4
|
| 92 |
- Q2: Acceleration is the rate of change of velocity over time
|
| 93 |
|
| 94 |
+
我的電子郵件是 ${teacherEmail}。請評分並建立完整報告。`,
|
| 95 |
},
|
| 96 |
{
|
| 97 |
+
title: "⚡ 快速摘要",
|
| 98 |
+
prompt: `請給我班級表現的快速摘要。我的電子郵件是 ${teacherEmail}。`,
|
| 99 |
},
|
| 100 |
];
|
| 101 |
|
|
|
|
| 104 |
{/* Copied toast */}
|
| 105 |
{showCopiedToast && (
|
| 106 |
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg">
|
| 107 |
+
✓ 提示已複製!請在聊天中貼上。
|
| 108 |
</div>
|
| 109 |
)}
|
| 110 |
|
|
|
|
| 118 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 119 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
| 120 |
</svg>
|
| 121 |
+
返回首頁
|
| 122 |
</button>
|
| 123 |
|
| 124 |
<div className="flex items-center gap-3">
|
| 125 |
+
<span className="text-sm text-gray-500">已連接為</span>
|
| 126 |
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
| 127 |
{teacherEmail}
|
| 128 |
</span>
|
| 129 |
+
<div className="flex items-center gap-2">
|
| 130 |
+
<span className="text-sm text-gray-500">模型:</span>
|
| 131 |
+
<select
|
| 132 |
+
value={selectedModel}
|
| 133 |
+
onChange={(e) => setSelectedModel(e.target.value)}
|
| 134 |
+
className="px-3 py-1 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 135 |
+
>
|
| 136 |
+
{Object.entries(availableModels).map(([key, model]) => (
|
| 137 |
+
<option key={key} value={key}>
|
| 138 |
+
{model.name} {key === "gpt-4.1-mini" ? "(默認)" : ""} - {model.description}
|
| 139 |
+
</option>
|
| 140 |
+
))}
|
| 141 |
+
</select>
|
| 142 |
+
</div>
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
|
|
|
|
| 152 |
{/* Example Prompts */}
|
| 153 |
<div className="bg-white rounded-xl shadow-sm border p-4">
|
| 154 |
<h3 className="text-sm font-semibold text-gray-800 mb-3">
|
| 155 |
+
快速開始提示
|
| 156 |
</h3>
|
| 157 |
<div className="space-y-2">
|
| 158 |
{examplePrompts.map((example, i) => (
|
|
|
|
| 165 |
{example.title}
|
| 166 |
</div>
|
| 167 |
<div className="text-xs text-gray-500">
|
| 168 |
+
點擊複製
|
| 169 |
</div>
|
| 170 |
</button>
|
| 171 |
))}
|
|
|
|
| 175 |
{/* Tips */}
|
| 176 |
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-4 border border-blue-100">
|
| 177 |
<h3 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
| 178 |
+
💡 專業提示
|
| 179 |
</h3>
|
| 180 |
<ul className="space-y-1.5 text-xs text-gray-600">
|
| 181 |
<li className="flex items-start gap-1.5">
|
| 182 |
<span className="text-green-500">✓</span>
|
| 183 |
+
包含標準答案以提高準確度
|
| 184 |
</li>
|
| 185 |
<li className="flex items-start gap-1.5">
|
| 186 |
<span className="text-green-500">✓</span>
|
| 187 |
+
將 Google 試算表設為公開,或使用 CSV
|
| 188 |
</li>
|
| 189 |
<li className="flex items-start gap-1.5">
|
| 190 |
<span className="text-green-500">✓</span>
|
| 191 |
+
要求以電子郵件發送報告
|
| 192 |
</li>
|
| 193 |
</ul>
|
| 194 |
</div>
|
|
|
|
| 208 |
</div>
|
| 209 |
<div>
|
| 210 |
<h2 className="text-lg font-semibold text-white">
|
| 211 |
+
ClassLens 助手
|
| 212 |
</h2>
|
| 213 |
<p className="text-sm text-white/70">
|
| 214 |
+
請在下方貼上您的考試資料或 Google 試算表網址
|
| 215 |
</p>
|
| 216 |
</div>
|
| 217 |
</div>
|
| 218 |
<span className="px-2 py-1 rounded-full bg-white/20 text-white text-xs">
|
| 219 |
+
{availableModels[selectedModel]?.name || selectedModel}
|
| 220 |
</span>
|
| 221 |
</div>
|
| 222 |
</div>
|
chatkit/frontend/src/components/Features.tsx
CHANGED
|
@@ -6,8 +6,8 @@ export function Features() {
|
|
| 6 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 7 |
</svg>
|
| 8 |
),
|
| 9 |
-
title: "
|
| 10 |
-
description: "
|
| 11 |
color: "var(--color-primary)",
|
| 12 |
},
|
| 13 |
{
|
|
@@ -16,8 +16,8 @@ export function Features() {
|
|
| 16 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
| 17 |
</svg>
|
| 18 |
),
|
| 19 |
-
title: "
|
| 20 |
-
description: "
|
| 21 |
color: "var(--color-accent)",
|
| 22 |
},
|
| 23 |
{
|
|
@@ -26,8 +26,8 @@ export function Features() {
|
|
| 26 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
| 27 |
</svg>
|
| 28 |
),
|
| 29 |
-
title: "
|
| 30 |
-
description: "AI
|
| 31 |
color: "var(--color-success)",
|
| 32 |
},
|
| 33 |
{
|
|
@@ -36,8 +36,8 @@ export function Features() {
|
|
| 36 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
| 37 |
</svg>
|
| 38 |
),
|
| 39 |
-
title: "
|
| 40 |
-
description: "
|
| 41 |
color: "var(--color-warning)",
|
| 42 |
},
|
| 43 |
{
|
|
@@ -46,8 +46,8 @@ export function Features() {
|
|
| 46 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
| 47 |
</svg>
|
| 48 |
),
|
| 49 |
-
title: "
|
| 50 |
-
description: "
|
| 51 |
color: "var(--color-primary-light)",
|
| 52 |
},
|
| 53 |
{
|
|
@@ -56,8 +56,8 @@ export function Features() {
|
|
| 56 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 57 |
</svg>
|
| 58 |
),
|
| 59 |
-
title: "
|
| 60 |
-
description: "
|
| 61 |
color: "var(--color-accent-light)",
|
| 62 |
},
|
| 63 |
];
|
|
@@ -67,12 +67,11 @@ export function Features() {
|
|
| 67 |
<div className="max-w-6xl mx-auto">
|
| 68 |
<div className="text-center mb-16">
|
| 69 |
<h2 className="font-display text-4xl font-bold text-[var(--color-text)] mb-4">
|
| 70 |
-
|
| 71 |
-
<span className="text-gradient">
|
| 72 |
</h2>
|
| 73 |
<p className="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
|
| 74 |
-
ClassLens
|
| 75 |
-
to give you actionable insights.
|
| 76 |
</p>
|
| 77 |
</div>
|
| 78 |
|
|
|
|
| 6 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 7 |
</svg>
|
| 8 |
),
|
| 9 |
+
title: "自動評分",
|
| 10 |
+
description: "使用 AI 精準評分選擇題、數值題,甚至開放式問題。",
|
| 11 |
color: "var(--color-primary)",
|
| 12 |
},
|
| 13 |
{
|
|
|
|
| 16 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
| 17 |
</svg>
|
| 18 |
),
|
| 19 |
+
title: "智能解釋",
|
| 20 |
+
description: "為每個錯誤答案提供個人化解釋,幫助學生理解錯誤。",
|
| 21 |
color: "var(--color-accent)",
|
| 22 |
},
|
| 23 |
{
|
|
|
|
| 26 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
| 27 |
</svg>
|
| 28 |
),
|
| 29 |
+
title: "同儕學習小組",
|
| 30 |
+
description: "AI 建議的學生分組,將表現優秀的學生與需要特定主題幫助的學生配對。",
|
| 31 |
color: "var(--color-success)",
|
| 32 |
},
|
| 33 |
{
|
|
|
|
| 36 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
| 37 |
</svg>
|
| 38 |
),
|
| 39 |
+
title: "電子郵件報告",
|
| 40 |
+
description: "直接在收件匣中接收精美的格式化報告,隨時可與學生或家長分享。",
|
| 41 |
color: "var(--color-warning)",
|
| 42 |
},
|
| 43 |
{
|
|
|
|
| 46 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
| 47 |
</svg>
|
| 48 |
),
|
| 49 |
+
title: "隱私優先",
|
| 50 |
+
description: "學生資料安全處理。我們只存取您分享的內容,絕不儲存敏感資訊。",
|
| 51 |
color: "var(--color-primary-light)",
|
| 52 |
},
|
| 53 |
{
|
|
|
|
| 56 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 57 |
</svg>
|
| 58 |
),
|
| 59 |
+
title: "即時分析",
|
| 60 |
+
description: "幾秒內獲得全面洞察,而非數小時。更多時間教學,更少時間評分。",
|
| 61 |
color: "var(--color-accent-light)",
|
| 62 |
},
|
| 63 |
];
|
|
|
|
| 67 |
<div className="max-w-6xl mx-auto">
|
| 68 |
<div className="text-center mb-16">
|
| 69 |
<h2 className="font-display text-4xl font-bold text-[var(--color-text)] mb-4">
|
| 70 |
+
了解您的學生所需的一切
|
| 71 |
+
<span className="text-gradient"> 功能</span>
|
| 72 |
</h2>
|
| 73 |
<p className="text-lg text-[var(--color-text-muted)] max-w-2xl mx-auto">
|
| 74 |
+
ClassLens 結合 AI 的力量與深思熟慮的教育實踐,為您提供可操作的洞察。
|
|
|
|
| 75 |
</p>
|
| 76 |
</div>
|
| 77 |
|
chatkit/frontend/src/components/Hero.tsx
CHANGED
|
@@ -16,19 +16,18 @@ export function Hero({ isAuthenticated, onStartAnalysis }: HeroProps) {
|
|
| 16 |
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 17 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 18 |
</svg>
|
| 19 |
-
|
| 20 |
</span>
|
| 21 |
</div>
|
| 22 |
|
| 23 |
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-6 opacity-0 animate-fade-in-up stagger-2">
|
| 24 |
-
<span className="text-[var(--color-text)]">
|
| 25 |
<br />
|
| 26 |
-
<span className="text-gradient">
|
| 27 |
</h1>
|
| 28 |
|
| 29 |
<p className="text-xl text-[var(--color-text-muted)] max-w-2xl mx-auto mb-10 opacity-0 animate-fade-in-up stagger-3">
|
| 30 |
-
|
| 31 |
-
generate personalized explanations, and create peer learning groups—all in seconds.
|
| 32 |
</p>
|
| 33 |
|
| 34 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 opacity-0 animate-fade-in-up stagger-4">
|
|
@@ -37,7 +36,7 @@ export function Hero({ isAuthenticated, onStartAnalysis }: HeroProps) {
|
|
| 37 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 38 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
| 39 |
</svg>
|
| 40 |
-
|
| 41 |
</button>
|
| 42 |
) : (
|
| 43 |
<a href="#connect" className="btn btn-primary text-lg px-8 py-4">
|
|
@@ -47,20 +46,20 @@ export function Hero({ isAuthenticated, onStartAnalysis }: HeroProps) {
|
|
| 47 |
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 48 |
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 49 |
</svg>
|
| 50 |
-
|
| 51 |
</a>
|
| 52 |
)}
|
| 53 |
<a href="#features" className="btn btn-outline text-lg px-8 py-4">
|
| 54 |
-
|
| 55 |
</a>
|
| 56 |
</div>
|
| 57 |
|
| 58 |
{/* Stats */}
|
| 59 |
<div className="mt-20 grid grid-cols-3 gap-8 max-w-2xl mx-auto opacity-0 animate-fade-in-up stagger-5">
|
| 60 |
{[
|
| 61 |
-
{ value: "
|
| 62 |
-
{ value: "98%", label: "
|
| 63 |
-
{ value: "40+", label: "
|
| 64 |
].map((stat, i) => (
|
| 65 |
<div key={i} className="text-center">
|
| 66 |
<div className="font-display text-3xl font-bold text-[var(--color-primary)]">
|
|
|
|
| 16 |
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 17 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 18 |
</svg>
|
| 19 |
+
由 OpenAI ChatKit 提供技術支援
|
| 20 |
</span>
|
| 21 |
</div>
|
| 22 |
|
| 23 |
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-6 opacity-0 animate-fade-in-up stagger-2">
|
| 24 |
+
<span className="text-[var(--color-text)]">將考試轉化為</span>
|
| 25 |
<br />
|
| 26 |
+
<span className="text-gradient">教學洞察</span>
|
| 27 |
</h1>
|
| 28 |
|
| 29 |
<p className="text-xl text-[var(--color-text-muted)] max-w-2xl mx-auto mb-10 opacity-0 animate-fade-in-up stagger-3">
|
| 30 |
+
上傳您的 Google 表單回應,讓 AI 分析學生表現、生成個人化解釋並建立同儕學習小組——只需幾秒鐘。
|
|
|
|
| 31 |
</p>
|
| 32 |
|
| 33 |
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 opacity-0 animate-fade-in-up stagger-4">
|
|
|
|
| 36 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 37 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
| 38 |
</svg>
|
| 39 |
+
開始分析
|
| 40 |
</button>
|
| 41 |
) : (
|
| 42 |
<a href="#connect" className="btn btn-primary text-lg px-8 py-4">
|
|
|
|
| 46 |
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 47 |
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 48 |
</svg>
|
| 49 |
+
連接 Google 帳號
|
| 50 |
</a>
|
| 51 |
)}
|
| 52 |
<a href="#features" className="btn btn-outline text-lg px-8 py-4">
|
| 53 |
+
查看使用方式
|
| 54 |
</a>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
{/* Stats */}
|
| 58 |
<div className="mt-20 grid grid-cols-3 gap-8 max-w-2xl mx-auto opacity-0 animate-fade-in-up stagger-5">
|
| 59 |
{[
|
| 60 |
+
{ value: "5分鐘", label: "平均分析時間" },
|
| 61 |
+
{ value: "98%", label: "教師滿意度" },
|
| 62 |
+
{ value: "40+", label: "支援的題型" },
|
| 63 |
].map((stat, i) => (
|
| 64 |
<div key={i} className="text-center">
|
| 65 |
<div className="font-display text-3xl font-bold text-[var(--color-primary)]">
|
chatkit/frontend/src/index.css
CHANGED
|
@@ -291,6 +291,20 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 291 |
background-clip: text;
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
/* ChatKit customization */
|
| 295 |
.chatkit-container {
|
| 296 |
--ck-color-primary: var(--color-primary);
|
|
|
|
| 291 |
background-clip: text;
|
| 292 |
}
|
| 293 |
|
| 294 |
+
/* ChatKit customization - Force Traditional Chinese */
|
| 295 |
+
.chatkit-container,
|
| 296 |
+
[data-chatkit-composer] {
|
| 297 |
+
/* Force zh-TW locale */
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/* Override ChatKit placeholder text to Traditional Chinese */
|
| 301 |
+
[data-chatkit-composer] textarea::placeholder,
|
| 302 |
+
[data-chatkit-composer] input::placeholder,
|
| 303 |
+
.chatkit-composer textarea::placeholder,
|
| 304 |
+
.chatkit-composer input::placeholder {
|
| 305 |
+
/* This may not work as ChatKit uses iframe */
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
/* ChatKit customization */
|
| 309 |
.chatkit-container {
|
| 310 |
--ck-color-primary: var(--color-primary);
|
chatkit/run-local.sh
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Run ClassLens locally (both backend and frontend)
|
| 3 |
+
|
| 4 |
+
set -e
|
| 5 |
+
|
| 6 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 7 |
+
cd "$SCRIPT_DIR"
|
| 8 |
+
|
| 9 |
+
echo "🚀 Starting ClassLens locally..."
|
| 10 |
+
echo ""
|
| 11 |
+
|
| 12 |
+
# Check for .env file
|
| 13 |
+
if [ ! -f "../.env" ]; then
|
| 14 |
+
echo "⚠️ No .env file found. Please create one from env.example"
|
| 15 |
+
exit 1
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
# Function to cleanup on exit
|
| 19 |
+
cleanup() {
|
| 20 |
+
echo ""
|
| 21 |
+
echo "🛑 Stopping servers..."
|
| 22 |
+
pkill -f "uvicorn app.main" 2>/dev/null || true
|
| 23 |
+
pkill -f "vite" 2>/dev/null || true
|
| 24 |
+
exit 0
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
trap cleanup SIGINT SIGTERM
|
| 28 |
+
|
| 29 |
+
# Start backend
|
| 30 |
+
echo "📦 Starting backend on http://127.0.0.1:8000"
|
| 31 |
+
cd backend
|
| 32 |
+
if [ ! -d ".venv" ]; then
|
| 33 |
+
echo "Creating Python virtual environment..."
|
| 34 |
+
python3 -m venv .venv
|
| 35 |
+
fi
|
| 36 |
+
source .venv/bin/activate
|
| 37 |
+
pip install -q -e . > /dev/null 2>&1
|
| 38 |
+
|
| 39 |
+
# Load environment variables
|
| 40 |
+
export $(grep -v '^#' ../.env | xargs)
|
| 41 |
+
|
| 42 |
+
# Start backend in background
|
| 43 |
+
uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload > /tmp/classlens-backend.log 2>&1 &
|
| 44 |
+
BACKEND_PID=$!
|
| 45 |
+
|
| 46 |
+
# Wait a bit for backend to start
|
| 47 |
+
sleep 2
|
| 48 |
+
|
| 49 |
+
# Start frontend
|
| 50 |
+
echo "🎨 Starting frontend on http://localhost:3000"
|
| 51 |
+
cd ../frontend
|
| 52 |
+
|
| 53 |
+
# Install frontend dependencies if needed
|
| 54 |
+
if [ ! -d "node_modules" ]; then
|
| 55 |
+
echo "Installing frontend dependencies..."
|
| 56 |
+
npm install
|
| 57 |
+
fi
|
| 58 |
+
|
| 59 |
+
# Start frontend
|
| 60 |
+
npm run dev > /tmp/classlens-frontend.log 2>&1 &
|
| 61 |
+
FRONTEND_PID=$!
|
| 62 |
+
|
| 63 |
+
echo ""
|
| 64 |
+
echo "✅ ClassLens is running!"
|
| 65 |
+
echo ""
|
| 66 |
+
echo " Backend: http://127.0.0.1:8000"
|
| 67 |
+
echo " Frontend: http://localhost:3000"
|
| 68 |
+
echo ""
|
| 69 |
+
echo " Backend logs: tail -f /tmp/classlens-backend.log"
|
| 70 |
+
echo " Frontend logs: tail -f /tmp/classlens-frontend.log"
|
| 71 |
+
echo ""
|
| 72 |
+
echo "Press Ctrl+C to stop both servers"
|
| 73 |
+
echo ""
|
| 74 |
+
|
| 75 |
+
# Wait for both processes
|
| 76 |
+
wait $BACKEND_PID $FRONTEND_PID
|