Spaces:
Sleeping
Sleeping
Commit ·
c8c6034
0
Parent(s):
Add Clare product UI: run_web.sh, README, exclude hf_space from push
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +18 -0
- .gitattributes +44 -0
- .gitignore +33 -0
- ARCHITECTURE.md +384 -0
- DATA_COLLECTION_SUMMARY.md +102 -0
- Dockerfile +52 -0
- EMBEDDING_COMPARISON.md +237 -0
- Notiondb.py +101 -0
- PRESENTATION_SUMMARY.md +250 -0
- README.md +81 -0
- api/clare_core.py +627 -0
- api/config.py +202 -0
- api/models.py +49 -0
- api/rag_engine.py +643 -0
- api/routes_directory.py +38 -0
- api/server.py +1142 -0
- api/store.py +145 -0
- api/syllabus_utils.py +113 -0
- api/tts_podcast.py +132 -0
- app.py +1263 -0
- assets/hanbridge_logo.png +0 -0
- hf_space/ClareVoice +1 -0
- hf_space/GenAICoursesDB +1 -0
- hf_space/GenAICoursesDB_remote +1 -0
- hf_space/GenAICoursesDB_space +1 -0
- hf_space/test_AI_Agent +1 -0
- requirements.txt +26 -0
- run_web.sh +13 -0
- script/langsmith_smoketest.py +28 -0
- web/README.md +11 -0
- web/index.html +18 -0
- web/package-lock.json +0 -0
- web/package.json +65 -0
- web/src/App.tsx +1537 -0
- web/src/Attributions.md +3 -0
- web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png +0 -0
- web/src/assets/file-icons/pdf.png +0 -0
- web/src/assets/file-icons/ppt.png +0 -0
- web/src/components/ChatArea.tsx +1639 -0
- web/src/components/CourseInfoHeader.tsx +93 -0
- web/src/components/FileUploadArea.tsx +372 -0
- web/src/components/FloatingActionButtons.tsx +102 -0
- web/src/components/GroupMembers.tsx +119 -0
- web/src/components/Header.tsx +486 -0
- web/src/components/LearningModeSelector.tsx +101 -0
- web/src/components/LeftSidebar.tsx +6 -0
- web/src/components/LoginScreen.tsx +126 -0
- web/src/components/Message.tsx +556 -0
- web/src/components/Onboarding.tsx +571 -0
- web/src/components/ProfileEditor.tsx +298 -0
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and fill in your values. Never commit .env.
|
| 2 |
+
|
| 3 |
+
# Required for Clare
|
| 4 |
+
OPENAI_API_KEY=your-openai-api-key-here
|
| 5 |
+
|
| 6 |
+
# Optional: LangSmith (tracing / feedback)
|
| 7 |
+
# LANGSMITH_API_KEY=your-langsmith-key
|
| 8 |
+
# LANGSMITH_PROJECT=your-project-name
|
| 9 |
+
# CLARE_ENABLE_LANGSMITH_LOG=1
|
| 10 |
+
# CLARE_ENABLE_LANGSMITH_FEEDBACK=1
|
| 11 |
+
|
| 12 |
+
# Optional: override model / timeout
|
| 13 |
+
# CLARE_DEFAULT_MODEL=gpt-4.1-mini
|
| 14 |
+
# CLARE_OPENAI_TIMEOUT_SECONDS=20
|
| 15 |
+
|
| 16 |
+
# Optional: 方案三 - Clare 调用 GenAICoursesDB 向量知识库
|
| 17 |
+
# 设置为 HF Space ID 或完整 URL 时,Clare 会在对话时自动补充课程检索结果
|
| 18 |
+
# GENAI_COURSES_SPACE=claudqunwang/GenAICoursesDB
|
.gitattributes
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
Clare_Run.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
clare_mascot.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
Clare_reading.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
module10_responsible_ai.pdf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
assets/Clare_Run.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
assets/Clare_reading.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
assets/clare_mascot.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
api/module10_responsible_ai.pdf filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
web/src/assets/file-icons/other_format.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment and secrets – never commit
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.*.local
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
venv/
|
| 13 |
+
.venv/
|
| 14 |
+
env/
|
| 15 |
+
|
| 16 |
+
# Node / frontend
|
| 17 |
+
node_modules/
|
| 18 |
+
web/dist/
|
| 19 |
+
web/build/
|
| 20 |
+
web/out/
|
| 21 |
+
|
| 22 |
+
# IDE / OS
|
| 23 |
+
.idea/
|
| 24 |
+
.vscode/
|
| 25 |
+
.DS_Store
|
| 26 |
+
*.log
|
| 27 |
+
|
| 28 |
+
# Optional data
|
| 29 |
+
*.sqlite
|
| 30 |
+
data/courses/**/raw/
|
| 31 |
+
|
| 32 |
+
# HF Space clones (nested repos – push main project only)
|
| 33 |
+
hf_space/
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Clare AI + Weaviate 技术架构文档
|
| 2 |
+
|
| 3 |
+
## 📋 目录
|
| 4 |
+
1. [系统概述](#系统概述)
|
| 5 |
+
2. [架构图](#架构图)
|
| 6 |
+
3. [技术栈](#技术栈)
|
| 7 |
+
4. [核心组件](#核心组件)
|
| 8 |
+
5. [数据流](#数据流)
|
| 9 |
+
6. [部署架构](#部署架构)
|
| 10 |
+
7. [关键技术决策](#关键技术决策)
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 🎯 系统概述
|
| 15 |
+
|
| 16 |
+
**Clare AI** 是一个基于 RAG (Retrieval-Augmented Generation) 的智能教学助手,通过 **Weaviate Cloud** 向量数据库提供课程知识检索能力。
|
| 17 |
+
|
| 18 |
+
### 核心功能
|
| 19 |
+
- **多模式教学**:概念解释、苏格拉底式辅导、考试准备、作业助手、快速总结
|
| 20 |
+
- **知识检索**:从 151+ 个课程文档(PDF、DOCX、代码等)中检索相关内容
|
| 21 |
+
- **会话记忆**:跟踪学生弱项、认知状态、学习进度
|
| 22 |
+
- **多语言支持**:中英文双语对话
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 🏗️ 架构图
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 30 |
+
│ Hugging Face Space │
|
| 31 |
+
│ ┌──────────────────────────────────────────────────────┐ │
|
| 32 |
+
│ │ ClareVoice (Gradio App) │ │
|
| 33 |
+
│ │ ┌──────────────────────────────────────────────┐ │ │
|
| 34 |
+
│ │ │ Frontend: Gradio UI │ │ │
|
| 35 |
+
│ │ │ - Chat Interface │ │ │
|
| 36 |
+
│ │ │ - File Upload │ │ │
|
| 37 |
+
│ │ │ - Session Management │ │ │
|
| 38 |
+
│ │ └──────────────────────────────────────────────┘ │ │
|
| 39 |
+
│ │ │ │
|
| 40 |
+
│ │ ┌──────────────────────────────────────────────┐ │ │
|
| 41 |
+
│ │ │ Core Logic (clare_core.py) │ │ │
|
| 42 |
+
│ │ │ - LLM: GPT-4.1-mini (OpenAI) │ │ │
|
| 43 |
+
│ │ │ - Prompt Engineering │ │ │
|
| 44 |
+
│ │ │ - Session Memory │ │ │
|
| 45 |
+
│ │ └──────────────────────────────────────────────┘ │ │
|
| 46 |
+
│ │ │ │
|
| 47 |
+
│ │ ┌──────────────────────────────────────────────┐ │ │
|
| 48 |
+
│ │ │ RAG Engine (rag_engine.py) │ │ │
|
| 49 |
+
│ │ │ - Local FAISS (上传文件) │ │ │
|
| 50 |
+
│ │ │ - PDF/DOCX Parsing │ │ │
|
| 51 |
+
│ │ └──────────────────────────────────────────────┘ │ │
|
| 52 |
+
│ └──────────────────────────────────────────────────────┘ │
|
| 53 |
+
│ │ │
|
| 54 |
+
│ │ HTTPS + API Key │
|
| 55 |
+
│ ▼ │
|
| 56 |
+
└─────────────────────────────────────────────────────────────┘
|
| 57 |
+
│
|
| 58 |
+
│
|
| 59 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 60 |
+
│ Weaviate Cloud (GCP) │
|
| 61 |
+
│ ┌──────────────────────────────────────────────────────┐ │
|
| 62 |
+
│ │ Collection: GenAICourses │ │
|
| 63 |
+
│ │ - 151+ 文档块 (chunks) │ │
|
| 64 |
+
│ │ - Vector Embeddings (384-dim) │ │
|
| 65 |
+
│ │ - Metadata (source_file, section) │ │
|
| 66 |
+
│ └──────────────────────────────────────────────────────┘ │
|
| 67 |
+
└─────────────────────────────────────────────────────────────┘
|
| 68 |
+
▲
|
| 69 |
+
│
|
| 70 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 71 |
+
│ Index Builder (本地/CI) │
|
| 72 |
+
│ ┌──────────────────────────────────────────────────────┐ │
|
| 73 |
+
│ │ build_weaviate_index.py │ │
|
| 74 |
+
│ │ - 读取 GENAI COURSES/ 目录 │ │
|
| 75 |
+
│ │ - Embedding: sentence-transformers/all-MiniLM-L6-v2 │ │
|
| 76 |
+
│ │ - 上传到 Weaviate Cloud │ │
|
| 77 |
+
│ └──────────────────────────────────────────────────────┘ │
|
| 78 |
+
└─────────────────────────────────────────────────────────────┘
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 🛠️ 技术栈
|
| 84 |
+
|
| 85 |
+
### 前端层
|
| 86 |
+
- **Gradio 6.0+**: Web UI 框架,支持实时对话、文件上传
|
| 87 |
+
- **Python 3.11**: 运行环境
|
| 88 |
+
|
| 89 |
+
### 后端层
|
| 90 |
+
- **OpenAI GPT-4.1-mini**: 主要 LLM,用于生成回答
|
| 91 |
+
- **LangChain**: LLM 编排框架
|
| 92 |
+
- **LlamaIndex**: 向量检索框架
|
| 93 |
+
|
| 94 |
+
### 向量数据库
|
| 95 |
+
- **Weaviate Cloud**: 托管向量数据库(GCP)
|
| 96 |
+
- Collection: `GenAICourses`
|
| 97 |
+
- Embedding Model: `sentence-transformers/all-MiniLM-L6-v2` (384维)
|
| 98 |
+
- 文档类型: PDF, DOCX, TXT, PY, IPYNB
|
| 99 |
+
|
| 100 |
+
### Embedding 模型
|
| 101 |
+
- **建索引**: `sentence-transformers/all-MiniLM-L6-v2` (免费,本地运行)
|
| 102 |
+
- **查询**: 与建索引使用相同模型(保证向量空间一致)
|
| 103 |
+
|
| 104 |
+
### 文档解析
|
| 105 |
+
- **unstructured.io**: PDF 解析(优先)
|
| 106 |
+
- **pypdf**: PDF 解析(降级方案)
|
| 107 |
+
- **python-docx**: DOCX 解析
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## 🔧 核心组件
|
| 112 |
+
|
| 113 |
+
### 1. ClareVoice App (`app.py`)
|
| 114 |
+
|
| 115 |
+
**职责**: 主应用入口,处理用户交互
|
| 116 |
+
|
| 117 |
+
**关键功能**:
|
| 118 |
+
- Gradio UI 渲染
|
| 119 |
+
- 用户会话管理
|
| 120 |
+
- 调用 RAG 检索和 LLM 生成
|
| 121 |
+
- Weaviate 直连检索
|
| 122 |
+
|
| 123 |
+
**关键代码**:
|
| 124 |
+
```python
|
| 125 |
+
# Weaviate 检索(优先)
|
| 126 |
+
if USE_WEAVIATE_DIRECT:
|
| 127 |
+
course_chunks = _retrieve_from_weaviate(message)
|
| 128 |
+
|
| 129 |
+
# 本地 FAISS(上传文件)
|
| 130 |
+
rag_context_text = retrieve_relevant_chunks(message, rag_chunks)
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### 2. RAG Engine (`rag_engine.py`)
|
| 134 |
+
|
| 135 |
+
**职责**: 本地文件向量化和检索
|
| 136 |
+
|
| 137 |
+
**功能**:
|
| 138 |
+
- 解析上传的 PDF/DOCX
|
| 139 |
+
- 构建 FAISS 索引(内存)
|
| 140 |
+
- 向量相似度检索
|
| 141 |
+
|
| 142 |
+
### 3. Clare Core (`clare_core.py`)
|
| 143 |
+
|
| 144 |
+
**职责**: LLM 调用和 Prompt 构建
|
| 145 |
+
|
| 146 |
+
**功能**:
|
| 147 |
+
- 构建多轮对话上下文
|
| 148 |
+
- 整合 RAG 检索结果
|
| 149 |
+
- 管理会话记忆(弱项、认知状态)
|
| 150 |
+
- 生成教学回答
|
| 151 |
+
|
| 152 |
+
### 4. Weaviate 检索 (`app.py` → `_retrieve_from_weaviate`)
|
| 153 |
+
|
| 154 |
+
**职责**: 从 Weaviate Cloud 检索课程知识
|
| 155 |
+
|
| 156 |
+
**流程**:
|
| 157 |
+
1. 加载 embedding 模型(缓存)
|
| 158 |
+
2. 连接 Weaviate Cloud
|
| 159 |
+
3. 将用户问题编码为向量
|
| 160 |
+
4. 向量相似度搜索(top_k=5)
|
| 161 |
+
5. 返回相关文档块
|
| 162 |
+
|
| 163 |
+
**关键特性**:
|
| 164 |
+
- **超时保护**: 45秒超时,避免阻塞
|
| 165 |
+
- **模型预热**: 启动时后台加载,避免首次查询慢
|
| 166 |
+
- **错误降级**: 失败时静默返回空,不影响主流程
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## 📊 数据流
|
| 171 |
+
|
| 172 |
+
### 用户提问流程
|
| 173 |
+
|
| 174 |
+
```
|
| 175 |
+
1. 用户输入问题
|
| 176 |
+
│
|
| 177 |
+
▼
|
| 178 |
+
2. 检测是否为学术查询 (is_academic_query)
|
| 179 |
+
│
|
| 180 |
+
├─ 是 → 触发 RAG 检索
|
| 181 |
+
│ │
|
| 182 |
+
│ ├─ Weaviate 检索 (优先)
|
| 183 |
+
│ │ └─ 返回课程文档块
|
| 184 |
+
│ │
|
| 185 |
+
│ └─ 本地 FAISS 检索 (上传文件)
|
| 186 |
+
│ └─ 返回文档块
|
| 187 |
+
│
|
| 188 |
+
└─ 否 → 跳过 RAG
|
| 189 |
+
│
|
| 190 |
+
▼
|
| 191 |
+
3. 构建 Prompt
|
| 192 |
+
├─ System Prompt (Clare 身份)
|
| 193 |
+
├─ Session Memory (弱项、认知状态)
|
| 194 |
+
├─ RAG Context (检索到的文档)
|
| 195 |
+
└─ User Message
|
| 196 |
+
│
|
| 197 |
+
▼
|
| 198 |
+
4. 调用 OpenAI API (GPT-4.1-mini)
|
| 199 |
+
│
|
| 200 |
+
▼
|
| 201 |
+
5. 返回回答 + 更新会话历史
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### 索引构建流程
|
| 205 |
+
|
| 206 |
+
```
|
| 207 |
+
1. 本地运行 build_weaviate_index.py
|
| 208 |
+
│
|
| 209 |
+
▼
|
| 210 |
+
2. 读取 GENAI COURSES/ 目录
|
| 211 |
+
├─ 支持格式: .md, .pdf, .txt, .py, .ipynb, .docx
|
| 212 |
+
└─ 递归扫描所有子目录
|
| 213 |
+
│
|
| 214 |
+
▼
|
| 215 |
+
3. 文档分块 (LlamaIndex SimpleDirectoryReader)
|
| 216 |
+
└─ 自动分块,保留元数据
|
| 217 |
+
│
|
| 218 |
+
▼
|
| 219 |
+
4. Embedding 编码
|
| 220 |
+
└─ sentence-transformers/all-MiniLM-L6-v2
|
| 221 |
+
│
|
| 222 |
+
▼
|
| 223 |
+
5. 上传到 Weaviate Cloud
|
| 224 |
+
├─ Collection: GenAICourses
|
| 225 |
+
├─ 向量 + 元数据 (source_file, section)
|
| 226 |
+
└─ 验证 object count
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 🚀 部署架构
|
| 232 |
+
|
| 233 |
+
### Hugging Face Space (ClareVoice)
|
| 234 |
+
|
| 235 |
+
**部署方式**: Docker Space
|
| 236 |
+
|
| 237 |
+
**Dockerfile 关键配置**:
|
| 238 |
+
```dockerfile
|
| 239 |
+
FROM python:3.11-slim
|
| 240 |
+
# 安装系统依赖 (libxcb for unstructured)
|
| 241 |
+
RUN apt-get update && apt-get install -y libxcb1 libxcb-xinerama0
|
| 242 |
+
# 强制升��� huggingface_hub>=1.3.0
|
| 243 |
+
RUN pip install --upgrade "huggingface_hub>=1.3.0,<2.0"
|
| 244 |
+
# 安装依赖
|
| 245 |
+
RUN pip install -r requirements.txt
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
**环境变量 (Secrets)**:
|
| 249 |
+
- `OPENAI_API_KEY`: OpenAI API 密钥
|
| 250 |
+
- `WEAVIATE_URL`: Weaviate Cloud REST 地址
|
| 251 |
+
- `WEAVIATE_API_KEY`: Weaviate API Key
|
| 252 |
+
- `WEAVIATE_COLLECTION`: 集合名(默认 `GenAICourses`)
|
| 253 |
+
|
| 254 |
+
**启动流程**:
|
| 255 |
+
1. 加载环境变量
|
| 256 |
+
2. 后台预热 Weaviate embedding 模型
|
| 257 |
+
3. 启动 Gradio 服务(端口 7860)
|
| 258 |
+
|
| 259 |
+
### Weaviate Cloud
|
| 260 |
+
|
| 261 |
+
**托管**: Google Cloud Platform (GCP)
|
| 262 |
+
|
| 263 |
+
**配置**:
|
| 264 |
+
- Cluster URL: `https://xxx.c0.us-west3.gcp.weaviate.cloud`
|
| 265 |
+
- Authentication: API Key
|
| 266 |
+
- Collection Schema: 自动创建(LlamaIndex 管理)
|
| 267 |
+
|
| 268 |
+
**数据规模**:
|
| 269 |
+
- 文档数量: 151+ 文件
|
| 270 |
+
- 文档块数: ~917 objects
|
| 271 |
+
- 向量维度: 384 (all-MiniLM-L6-v2)
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## 💡 关键技术决策
|
| 276 |
+
|
| 277 |
+
### 1. 为什么选择 Weaviate Cloud?
|
| 278 |
+
|
| 279 |
+
**优势**:
|
| 280 |
+
- ✅ **托管服务**: 无需自建数据库,降低运维成本
|
| 281 |
+
- ✅ **高性能**: 向量检索延迟 <100ms
|
| 282 |
+
- ✅ **可扩展**: 支持大规模文档库
|
| 283 |
+
- ✅ **API 简单**: RESTful API,易于集成
|
| 284 |
+
|
| 285 |
+
**对比其他方案**:
|
| 286 |
+
- ❌ **本地 FAISS**: 内存限制,无法持久化
|
| 287 |
+
- ❌ **Pinecone**: 成本较高
|
| 288 |
+
- ❌ **自建 Weaviate**: 需要服务器和运维
|
| 289 |
+
|
| 290 |
+
### 2. 为什么使用 sentence-transformers 而非 OpenAI Embeddings?
|
| 291 |
+
|
| 292 |
+
**原因**:
|
| 293 |
+
- ✅ **成本**: 免费,无 API 调用费用
|
| 294 |
+
- ✅ **一致性**: 建索引和查询使用同一模型,保证向量空间一致
|
| 295 |
+
- ✅ **离线能力**: 本地运行,不依赖外部 API
|
| 296 |
+
|
| 297 |
+
**权衡**:
|
| 298 |
+
- ⚠️ **性能**: OpenAI `text-embedding-3-small` (1536维) 可能更准确
|
| 299 |
+
- ⚠️ **延迟**: 首次加载模型需要 10-30 秒(已通过预热解决)
|
| 300 |
+
|
| 301 |
+
### 3. 为什么采用 Docker Space 而非 Gradio SDK?
|
| 302 |
+
|
| 303 |
+
**原因**:
|
| 304 |
+
- ✅ **依赖控制**: 可以强制升级特定包(如 `huggingface_hub`)
|
| 305 |
+
- ✅ **系统库**: 可以安装 `libxcb` 等系统依赖
|
| 306 |
+
- ✅ **灵活性**: 完全控制构建过程
|
| 307 |
+
|
| 308 |
+
### 4. 双检索策略(Weaviate + FAISS)
|
| 309 |
+
|
| 310 |
+
**设计**:
|
| 311 |
+
- **Weaviate**: 课程知识库(151+ 文档,持久化)
|
| 312 |
+
- **FAISS**: 用户上传文件(临时,内存)
|
| 313 |
+
|
| 314 |
+
**优势**:
|
| 315 |
+
- ✅ 课程知识稳定可用
|
| 316 |
+
- ✅ 用户文件灵活处理
|
| 317 |
+
- ✅ 降级方案:Weaviate 失败时仍有 FAISS
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
## 📈 性能指标
|
| 322 |
+
|
| 323 |
+
### 检索性能
|
| 324 |
+
- **Weaviate 检索**: ~500-1000ms(含网络延迟)
|
| 325 |
+
- **FAISS 检索**: ~10-50ms(内存检索)
|
| 326 |
+
- **Embedding 编码**: ~100-300ms(首次需加载模型)
|
| 327 |
+
|
| 328 |
+
### LLM 响应
|
| 329 |
+
- **GPT-4.1-mini**: ~2-5秒(取决于回答长度)
|
| 330 |
+
|
| 331 |
+
### 系统资源
|
| 332 |
+
- **内存**: ~2-4GB(含 embedding 模型)
|
| 333 |
+
- **CPU**: 中等负载(embedding 计算)
|
| 334 |
+
|
| 335 |
+
---
|
| 336 |
+
|
| 337 |
+
## 🔒 安全与隐私
|
| 338 |
+
|
| 339 |
+
### API 密钥管理
|
| 340 |
+
- ✅ 使用 Hugging Face Secrets(加密存储)
|
| 341 |
+
- ✅ 不在代码中硬编码密钥
|
| 342 |
+
|
| 343 |
+
### 数据隐私
|
| 344 |
+
- ✅ 用户会话数据不持久化(仅内存)
|
| 345 |
+
- ✅ Weaviate 仅存储课程文档(公开内容)
|
| 346 |
+
- ✅ 用户上传文件仅用于当前会话
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
## 🐛 已知问题与解决方案
|
| 351 |
+
|
| 352 |
+
### 1. huggingface_hub 版本冲突
|
| 353 |
+
**问题**: HF Space 预装旧版 (0.36.2),`transformers` 需要 >=1.3.0
|
| 354 |
+
|
| 355 |
+
**解决**:
|
| 356 |
+
- Dockerfile 中强制升级
|
| 357 |
+
- App 启动时 monkey-patch `is_offline_mode`
|
| 358 |
+
|
| 359 |
+
### 2. Gradio 6.0 API 变更
|
| 360 |
+
**问题**: `Chatbot` 不再支持 `type="tuples"`,改为 `messages` 格式
|
| 361 |
+
|
| 362 |
+
**解决**: 添加格式转换函数 `_tuples_to_messages` / `_messages_to_tuples`
|
| 363 |
+
|
| 364 |
+
### 3. Embedding 模型首次加载慢
|
| 365 |
+
**问题**: 首次检索需要 10-30 秒加载模型
|
| 366 |
+
|
| 367 |
+
**解决**: 启动时后台预热(`_warmup_weaviate_embed`)
|
| 368 |
+
|
| 369 |
+
---
|
| 370 |
+
|
| 371 |
+
## 📝 总结
|
| 372 |
+
|
| 373 |
+
Clare AI 采用 **RAG + LLM** 架构,通过 **Weaviate Cloud** 提供稳定的课程知识检索能力。系统设计注重:
|
| 374 |
+
|
| 375 |
+
1. **可靠性**: 多重降级方案,确保服务可用
|
| 376 |
+
2. **性能**: 模型预热、超时保护、缓存机制
|
| 377 |
+
3. **可扩展**: 托管服务,易于扩展文档库
|
| 378 |
+
4. **成本**: 使用免费 embedding 模型,降低运营成本
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
**文档版本**: v1.0
|
| 383 |
+
**最后更新**: 2026-02-11
|
| 384 |
+
**维护者**: Clare AI Team
|
DATA_COLLECTION_SUMMARY.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Clare 登录与数据收集说明
|
| 2 |
+
|
| 3 |
+
## 一、登录机制
|
| 4 |
+
|
| 5 |
+
### 1.1 当前实现
|
| 6 |
+
|
| 7 |
+
- **入口**:侧边栏 “Student Login” → 输入 **Student Name** 和 **Email/ID** → 点击 “Enter”。
|
| 8 |
+
- **本质**:**前端身份标识**,不是账号密码认证。
|
| 9 |
+
- 不校验姓名/邮箱是否真实、是否已注册。
|
| 10 |
+
- 不连接学校 SSO 或任何外部认证系统。
|
| 11 |
+
- **用途**:
|
| 12 |
+
- 解锁聊天、导出、Quiz、TTS 等按钮(未填时这些功能不可用)。
|
| 13 |
+
- 在需要时作为 “学生标识” 参与日志(见下文)。
|
| 14 |
+
|
| 15 |
+
### 1.2 登录后保存的内容
|
| 16 |
+
|
| 17 |
+
所有“登录后”的状态都保存在 **Gradio 的会话状态(gr.State)** 里,且 **仅存在于当前会话**:
|
| 18 |
+
|
| 19 |
+
| 状态 | 含义 | 持久化 |
|
| 20 |
+
|------|------|--------|
|
| 21 |
+
| `user_name_state` | 学生姓名 | 否 |
|
| 22 |
+
| `user_id_state` | Email/ID | 否 |
|
| 23 |
+
| `weakness_state` | 弱项列表 | 否 |
|
| 24 |
+
| `cognitive_state_state` | 认知状态 | 否 |
|
| 25 |
+
| `course_outline_state` | 课程大纲 | 否 |
|
| 26 |
+
| `rag_chunks_state` | 上传文件 RAG 块 | 否 |
|
| 27 |
+
| 聊天历史 | 本轮对话 | 否 |
|
| 28 |
+
|
| 29 |
+
- **刷新页面或关闭标签页** → 上述状态全部清空,相当于新会话。
|
| 30 |
+
- **没有数据库、没有后端用户表、没有把学生信息或对话存到文件**。
|
| 31 |
+
|
| 32 |
+
因此:**当前没有“根据学生信息建立并持久化个人档案”的机制**,只有“当前这次访问”内的状态。
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 二、与 AI 互动的数据是否被收集?
|
| 37 |
+
|
| 38 |
+
### 2.1 设计上的“收集”逻辑(LangSmith)
|
| 39 |
+
|
| 40 |
+
代码里**设计了**把部分互动数据上报到 **LangSmith** 的逻辑:
|
| 41 |
+
|
| 42 |
+
- **数据集名**:`clare_user_events`(需在 LangSmith 中先创建该 dataset)。
|
| 43 |
+
- **上报内容**(`log_event`)包括:
|
| 44 |
+
- `student_id`:即当前输入的 Email/ID(或未登录时为 `"ANON"`)
|
| 45 |
+
- `event_type`:事件类型
|
| 46 |
+
- `question` / `answer`:问题与回答(部分事件)
|
| 47 |
+
- `timestamp`、`latency_ms`、`model_name`、`language`、`learning_mode` 等元数据
|
| 48 |
+
|
| 49 |
+
**会尝试上报的事件类型**包括:
|
| 50 |
+
|
| 51 |
+
| event_type | 含义 |
|
| 52 |
+
|------------|------|
|
| 53 |
+
| `chat_turn` | 每次和 Clare 的一轮问答 |
|
| 54 |
+
| `micro_quiz_start` | 开始 Micro-Quiz |
|
| 55 |
+
| `like` | 点赞某条回答 |
|
| 56 |
+
| `dislike` | 点踩某条回答 |
|
| 57 |
+
| `detailed_feedback` | 提交详细反馈 |
|
| 58 |
+
|
| 59 |
+
也就是说:**若 LangSmith 配置成功,理论上会按“学生 ID + 事件类型”收集这些与 AI 的互动数据**。
|
| 60 |
+
|
| 61 |
+
### 2.2 实际运行情况
|
| 62 |
+
|
| 63 |
+
- 你之前的日志里有:**“LangSmith log failed: Dataset clare_user_events not found”**。
|
| 64 |
+
- 说明:**当前环境里 LangSmith 写入失败**,多半是因为:
|
| 65 |
+
- 未在 LangSmith 中创建名为 `clare_user_events` 的 dataset,或
|
| 66 |
+
- API 密钥/项目配置不正确。
|
| 67 |
+
|
| 68 |
+
因此:**在当前配置下,这些互动数据并没有被成功写入 LangSmith**,即 **没有在实际落地存储**。
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## 三、总结回答你的问题
|
| 73 |
+
|
| 74 |
+
- **登录后,系统会不会根据学生信息“建立”个人档案?**
|
| 75 |
+
**不会。** 没有用户库、没有个人档案表,只有当前会话里的姓名/ID 和状态,刷新即丢失。
|
| 76 |
+
|
| 77 |
+
- **会不会“收集”与 AI 的互动数据?**
|
| 78 |
+
**设计上会**(通过 LangSmith,且带 `student_id`),但 **目前因为 LangSmith 报错,实际上没有成功写入任何持久化存储**。
|
| 79 |
+
|
| 80 |
+
- **数据存在哪里?**
|
| 81 |
+
- **会话内**:仅在浏览器与 Gradio 进程内存中,不落库、不写文件。
|
| 82 |
+
- **持久化**:当前没有;若修好 LangSmith 并创建 `clare_user_events`,则会在 LangSmith 中按事件类型 + 学生 ID 存储上述互动数据。
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 四、若要让“登录后根据学生信息建立和收集互动数据”真正生效
|
| 87 |
+
|
| 88 |
+
1. **LangSmith**
|
| 89 |
+
- 在 LangSmith 中创建 dataset:`clare_user_events`。
|
| 90 |
+
- 在 Clare 部署环境(如 HF Space Secrets)中配置正确的 `LANGSMITH_API_KEY` 等。
|
| 91 |
+
- 这样现有 `log_event` 才会成功,互动数据才会被收集并按 `student_id` 可查。
|
| 92 |
+
|
| 93 |
+
2. **持久化学生与长期档案(可选)**
|
| 94 |
+
- 若需要“为每个学生建立档案、跨会话保留弱项/认知状态/历史记录”,需要自行增加后端存储,例如:
|
| 95 |
+
- 数据库(如 PostgreSQL / SQLite)存用户表 + 会话表 + 事件表;或
|
| 96 |
+
- 用 Notion/Google Sheet 等 API 写入。
|
| 97 |
+
- 当前代码**没有**这类实现,只有 LangSmith 的“事件日志”设计。
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
**简要结论**:
|
| 102 |
+
当前 Clare 的“登录”只是前端解锁功能用的身份标识;会话状态不持久;与 AI 的互动数据在设计上会通过 LangSmith 按学生 ID 收集,但**目前因 LangSmith 未配置成功而没有实际写入**,因此**目前没有在后台根据学生信息建立或持久化收集与 AI 的互动数据**。
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =========================
|
| 2 |
+
# 1) Web build stage
|
| 3 |
+
# =========================
|
| 4 |
+
FROM node:20-slim AS web_builder
|
| 5 |
+
WORKDIR /web
|
| 6 |
+
|
| 7 |
+
COPY web/package*.json ./
|
| 8 |
+
RUN npm install
|
| 9 |
+
|
| 10 |
+
COPY web/ ./
|
| 11 |
+
RUN npm run build
|
| 12 |
+
|
| 13 |
+
# ✅ unify output into /web/out (supports vite dist OR custom outDir build)
|
| 14 |
+
RUN set -eux; \
|
| 15 |
+
rm -rf /web/out; \
|
| 16 |
+
mkdir -p /web/out; \
|
| 17 |
+
if [ -d "/web/dist" ]; then \
|
| 18 |
+
cp -r /web/dist/* /web/out/; \
|
| 19 |
+
elif [ -d "/web/build" ]; then \
|
| 20 |
+
cp -r /web/build/* /web/out/; \
|
| 21 |
+
else \
|
| 22 |
+
echo "ERROR: Neither /web/dist nor /web/build exists after build"; \
|
| 23 |
+
echo "=== ls -la /web ==="; ls -la /web; \
|
| 24 |
+
echo "=== ls -la /web/dist (if any) ==="; ls -la /web/dist || true; \
|
| 25 |
+
echo "=== ls -la /web/build (if any) ==="; ls -la /web/build || true; \
|
| 26 |
+
exit 1; \
|
| 27 |
+
fi; \
|
| 28 |
+
echo "=== Web output in /web/out ==="; \
|
| 29 |
+
ls -la /web/out | head -n 80
|
| 30 |
+
|
| 31 |
+
# =========================
|
| 32 |
+
# 2) API runtime stage
|
| 33 |
+
# =========================
|
| 34 |
+
FROM python:3.11-slim
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
|
| 37 |
+
# optional: keep git only if you truly need it at runtime
|
| 38 |
+
RUN apt-get update \
|
| 39 |
+
&& apt-get install -y --no-install-recommends ca-certificates \
|
| 40 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 41 |
+
|
| 42 |
+
COPY requirements.txt /app/requirements.txt
|
| 43 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 44 |
+
|
| 45 |
+
COPY api/ /app/api/
|
| 46 |
+
|
| 47 |
+
# ✅ always copy unified output to /app/web/build
|
| 48 |
+
COPY --from=web_builder /web/out /app/web/build
|
| 49 |
+
|
| 50 |
+
ENV PORT=7860
|
| 51 |
+
EXPOSE 7860
|
| 52 |
+
CMD ["uvicorn", "api.server:app", "--host", "0.0.0.0", "--port", "7860"]
|
EMBEDDING_COMPARISON.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenAI Embeddings vs sentence-transformers 对比分析
|
| 2 |
+
|
| 3 |
+
## 📊 快速对比表
|
| 4 |
+
|
| 5 |
+
| 维度 | OpenAI `text-embedding-3-small` | sentence-transformers `all-MiniLM-L6-v2` |
|
| 6 |
+
|------|--------------------------------|------------------------------------------|
|
| 7 |
+
| **向量维度** | 1536 | 384 |
|
| 8 |
+
| **模型大小** | 云端(无本地存储) | ~90MB(需下载) |
|
| 9 |
+
| **速度** | ~100-300ms(API调用) | ~50-150ms(本地推理) |
|
| 10 |
+
| **成本** | $0.02/1M tokens | 免费 |
|
| 11 |
+
| **准确性** | ⭐⭐⭐⭐⭐(更高) | ⭐⭐⭐⭐(良好) |
|
| 12 |
+
| **网络依赖** | 需要 | 不需要 |
|
| 13 |
+
| **一致性** | 需要保证建索引和查询都用 OpenAI | 本地运行,天然一致 |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 🚀 性能对比
|
| 18 |
+
|
| 19 |
+
### 1. 速度
|
| 20 |
+
|
| 21 |
+
#### OpenAI Embeddings
|
| 22 |
+
- **API 调用延迟**: ~100-300ms(含网络往返)
|
| 23 |
+
- **批量处理**: 支持最多 2048 个输入/请求
|
| 24 |
+
- **无本地加载**: 无需下载模型
|
| 25 |
+
|
| 26 |
+
#### sentence-transformers
|
| 27 |
+
- **首次加载**: ~10-30秒(下载模型 + 加载权重)
|
| 28 |
+
- **后续推理**: ~50-150ms(本地 CPU)
|
| 29 |
+
- **批量处理**: 本地批处理,无 API 限制
|
| 30 |
+
|
| 31 |
+
**结论**:
|
| 32 |
+
- ✅ **首次查询**: OpenAI 更快(无需加载模型)
|
| 33 |
+
- ✅ **后续查询**: sentence-transformers 稍快(本地推理)
|
| 34 |
+
- ⚠️ **已预热**: 两者速度接近(sentence-transformers 已预热)
|
| 35 |
+
|
| 36 |
+
### 2. 准确性
|
| 37 |
+
|
| 38 |
+
#### OpenAI `text-embedding-3-small`
|
| 39 |
+
- **维度**: 1536(更高表达能力)
|
| 40 |
+
- **训练数据**: 大规模多语言数据
|
| 41 |
+
- **性能**: 在 MTEB 基准测试中排名靠前
|
| 42 |
+
- **多语言**: 原生支持中英文
|
| 43 |
+
|
| 44 |
+
#### sentence-transformers `all-MiniLM-L6-v2`
|
| 45 |
+
- **维度**: 384(轻量级)
|
| 46 |
+
- **训练数据**: 较小规模
|
| 47 |
+
- **性能**: 在轻量级模型中表现良好
|
| 48 |
+
- **多语言**: 支持但不如 OpenAI 强
|
| 49 |
+
|
| 50 |
+
**结论**:
|
| 51 |
+
- ✅ **OpenAI 更准确**: 1536维 + 更好的训练数据
|
| 52 |
+
- ⚠️ **实际差异**: 对于课程文档检索,差异可能不明显(需要实测)
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## 💰 成本分析
|
| 57 |
+
|
| 58 |
+
### OpenAI Embeddings 成本
|
| 59 |
+
|
| 60 |
+
**定价**(2025年):
|
| 61 |
+
- `text-embedding-3-small`: **$0.02 / 1M tokens**
|
| 62 |
+
- `text-embedding-3-large`: $0.13 / 1M tokens
|
| 63 |
+
|
| 64 |
+
**估算**(基于你的数据):
|
| 65 |
+
- 151个文档,约 917 个文档块
|
| 66 |
+
- 平均每个块 ~500 tokens
|
| 67 |
+
- **建索引**: 917 × 500 = ~458,500 tokens ≈ **$0.009**(一次性)
|
| 68 |
+
- **每次查询**: 1 query × 500 tokens = 500 tokens ≈ **$0.00001**
|
| 69 |
+
- **1000次查询**: ~$0.01
|
| 70 |
+
|
| 71 |
+
**月度成本估算**:
|
| 72 |
+
- 假设每天 100 次查询
|
| 73 |
+
- 30天 × 100 = 3000 次查询
|
| 74 |
+
- 成本: ~$0.03/月(非常低)
|
| 75 |
+
|
| 76 |
+
### sentence-transformers 成本
|
| 77 |
+
|
| 78 |
+
- **建索引**: 免费(本地运行)
|
| 79 |
+
- **查询**: 免费(本地运行)
|
| 80 |
+
- **服务器资源**: 内存 ~500MB(模型权重)
|
| 81 |
+
|
| 82 |
+
**结论**:
|
| 83 |
+
- ✅ **成本差异很小**: OpenAI 月度成本 <$0.1
|
| 84 |
+
- ✅ **sentence-transformers**: 完全免费,但占用内存
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## 🔄 迁移方案
|
| 89 |
+
|
| 90 |
+
### 方案 A: 完全切换到 OpenAI Embeddings
|
| 91 |
+
|
| 92 |
+
**步骤**:
|
| 93 |
+
1. 修改 `build_weaviate_index.py`,使用 OpenAI
|
| 94 |
+
2. 修改 `app.py` 的 `_get_weaviate_embed_model()`,使用 OpenAI
|
| 95 |
+
3. 重新构建索引(一次性成本 ~$0.01)
|
| 96 |
+
4. 删除 sentence-transformers 依赖(可选)
|
| 97 |
+
|
| 98 |
+
**代码修改**:
|
| 99 |
+
|
| 100 |
+
```python
|
| 101 |
+
# build_weaviate_index.py
|
| 102 |
+
from llama_index.embeddings.openai import OpenAIEmbedding
|
| 103 |
+
Settings.embed_model = OpenAIEmbedding(
|
| 104 |
+
model="text-embedding-3-small",
|
| 105 |
+
api_key=os.getenv("OPENAI_API_KEY")
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# app.py
|
| 109 |
+
def _get_weaviate_embed_model():
|
| 110 |
+
global _WEAVIATE_EMBED_MODEL
|
| 111 |
+
if _WEAVIATE_EMBED_MODEL is None:
|
| 112 |
+
from llama_index.embeddings.openai import OpenAIEmbedding
|
| 113 |
+
_WEAVIATE_EMBED_MODEL = OpenAIEmbedding(
|
| 114 |
+
model="text-embedding-3-small"
|
| 115 |
+
)
|
| 116 |
+
return _WEAVIATE_EMBED_MODEL
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**优势**:
|
| 120 |
+
- ✅ 更准确的检索
|
| 121 |
+
- ✅ 无需预热(API 调用)
|
| 122 |
+
- ✅ 无需本地模型存储
|
| 123 |
+
|
| 124 |
+
**劣势**:
|
| 125 |
+
- ⚠️ 需要网络连接
|
| 126 |
+
- ⚠️ API 调用延迟(~100-300ms)
|
| 127 |
+
- ⚠️ 月度成本(虽然很低)
|
| 128 |
+
|
| 129 |
+
### 方案 B: 混合方案(推荐)
|
| 130 |
+
|
| 131 |
+
**策略**:
|
| 132 |
+
- **Weaviate 索引**: 使用 OpenAI(更准确,一次性成本低)
|
| 133 |
+
- **查询**: 使用 OpenAI(与索引一致)
|
| 134 |
+
- **保留 sentence-transformers**: 作为降级方案
|
| 135 |
+
|
| 136 |
+
**优势**:
|
| 137 |
+
- ✅ 最佳检索质量
|
| 138 |
+
- ✅ 有降级方案(网络故障时)
|
| 139 |
+
- ✅ 成本可控
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## 📈 效果提升预期
|
| 144 |
+
|
| 145 |
+
### 检索质量提升
|
| 146 |
+
|
| 147 |
+
基于向量维度差异(384 vs 1536)和模型质量:
|
| 148 |
+
|
| 149 |
+
| 场景 | 预期提升 |
|
| 150 |
+
|------|---------|
|
| 151 |
+
| **精确匹配** | +5-10% |
|
| 152 |
+
| **语义相似** | +10-20% |
|
| 153 |
+
| **多语言查询** | +15-25% |
|
| 154 |
+
| **复杂概念** | +10-15% |
|
| 155 |
+
|
| 156 |
+
**注意**: 这些是理论预期,实际效果需要 A/B 测试验证。
|
| 157 |
+
|
| 158 |
+
### 速度变化
|
| 159 |
+
|
| 160 |
+
| 场景 | OpenAI | sentence-transformers |
|
| 161 |
+
|------|--------|----------------------|
|
| 162 |
+
| **首次查询** | ~200ms | ~500ms(含加载) |
|
| 163 |
+
| **后续查询** | ~200ms | ~100ms |
|
| 164 |
+
| **批量查询** | ~200ms/次 | ~100ms/次 |
|
| 165 |
+
|
| 166 |
+
**结论**:
|
| 167 |
+
- 如果已预热,sentence-transformers 稍快
|
| 168 |
+
- OpenAI 更稳定��无首次加载延迟)
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## ✅ 推荐方案
|
| 173 |
+
|
| 174 |
+
### 推荐:切换到 OpenAI Embeddings
|
| 175 |
+
|
| 176 |
+
**理由**:
|
| 177 |
+
1. **成本极低**: 月度成本 <$0.1,可忽略
|
| 178 |
+
2. **效果更好**: 1536维 + 更好的模型
|
| 179 |
+
3. **无需预热**: 简化启动流程
|
| 180 |
+
4. **一致性**: 与本地 FAISS(已用 OpenAI)保持一致
|
| 181 |
+
|
| 182 |
+
### 实施步骤
|
| 183 |
+
|
| 184 |
+
1. **测试阶段**(1-2天):
|
| 185 |
+
```bash
|
| 186 |
+
# 修改 build_weaviate_index.py,使用 OpenAI
|
| 187 |
+
EMBEDDING_PROVIDER=openai python build_weaviate_index.py
|
| 188 |
+
|
| 189 |
+
# 修改 app.py,使用 OpenAI
|
| 190 |
+
# 测试检索质量
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
2. **A/B 测试**(可选):
|
| 194 |
+
- 对比检索结果质量
|
| 195 |
+
- 测量响应时间
|
| 196 |
+
- 收集用户反馈
|
| 197 |
+
|
| 198 |
+
3. **正式切换**:
|
| 199 |
+
- 更新代码
|
| 200 |
+
- 重新构建索引
|
| 201 |
+
- 部署到生产环境
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## 🎯 总结
|
| 206 |
+
|
| 207 |
+
### OpenAI Embeddings 优势
|
| 208 |
+
- ✅ **更准确**: 1536维,更好的模型
|
| 209 |
+
- ✅ **无需预热**: API 调用,启动更快
|
| 210 |
+
- ✅ **成本极低**: 月度 <$0.1
|
| 211 |
+
- ✅ **一致性**: 与本地 FAISS 统一
|
| 212 |
+
|
| 213 |
+
### sentence-transformers 优势
|
| 214 |
+
- ✅ **完全免费**: 无 API 成本
|
| 215 |
+
- ✅ **离线可用**: 无需网络
|
| 216 |
+
- ✅ **本地推理**: 隐私更好
|
| 217 |
+
|
| 218 |
+
### 最终建议
|
| 219 |
+
|
| 220 |
+
**切换到 OpenAI Embeddings**,因为:
|
| 221 |
+
1. 成本差异可忽略(<$0.1/月)
|
| 222 |
+
2. 检索质量提升明显(+10-20%)
|
| 223 |
+
3. 简化系统架构(无需预热)
|
| 224 |
+
4. 与现有 FAISS 保持一致
|
| 225 |
+
|
| 226 |
+
**保留 sentence-transformers 作为降级方案**(可选),用于:
|
| 227 |
+
- 网络故障时
|
| 228 |
+
- 成本敏感场景
|
| 229 |
+
- 离线部署需求
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
**建议行动**:
|
| 234 |
+
1. 先做小规模测试(10-20个文档)
|
| 235 |
+
2. 对比检索结果质量
|
| 236 |
+
3. 如果效果明显,全面切换
|
| 237 |
+
4. 监控成本和性能
|
Notiondb.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
# LlamaIndex 核心组件
|
| 7 |
+
from llama_index.core import (
|
| 8 |
+
VectorStoreIndex,
|
| 9 |
+
SimpleDirectoryReader,
|
| 10 |
+
StorageContext,
|
| 11 |
+
load_index_from_storage,
|
| 12 |
+
Settings,
|
| 13 |
+
)
|
| 14 |
+
from llama_index.embeddings.openai import OpenAIEmbedding
|
| 15 |
+
|
| 16 |
+
# ============================
|
| 17 |
+
# 环境变量 & 路径配置
|
| 18 |
+
# ============================
|
| 19 |
+
|
| 20 |
+
# 加载 .env(和 Clare 项目保持一致,直接复用 OPENAI_API_KEY)
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 24 |
+
|
| 25 |
+
# 1. GENAI 课程目录(你要向量化的本地课程代码 / 笔记)
|
| 26 |
+
# 这里用绝对路径更稳,不怕你从哪里运行脚本
|
| 27 |
+
EXPORT_DIR = PROJECT_ROOT / "GENAI COURSES"
|
| 28 |
+
|
| 29 |
+
# 2. 向量数据库持久化路径
|
| 30 |
+
PERSIST_DIR = PROJECT_ROOT / "genai_courses_index"
|
| 31 |
+
|
| 32 |
+
# 3. 显式指定 Embedding 模型(和 Clare 一致:text-embedding-3-small)
|
| 33 |
+
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
|
| 34 |
+
|
| 35 |
+
def get_index(force_rebuild=False):
|
| 36 |
+
"""
|
| 37 |
+
获取索引:优先读取本地缓存,如果不存在或强制刷新,则重新构建
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
# 情况 A: 数据库已存在,直接加载 (极快,不花钱)
|
| 41 |
+
if PERSIST_DIR.exists() and not force_rebuild:
|
| 42 |
+
print(f"📂 发现本地数据库 ({PERSIST_DIR}),正在加载...")
|
| 43 |
+
try:
|
| 44 |
+
storage_context = StorageContext.from_defaults(persist_dir=str(PERSIST_DIR))
|
| 45 |
+
index = load_index_from_storage(storage_context)
|
| 46 |
+
print("✅ 加载成功!")
|
| 47 |
+
return index
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"⚠️ 本地数据库加载失败: {e},准备重新构建...")
|
| 50 |
+
|
| 51 |
+
# 情况 B: 数据库不存在,或者要求强制更新 -> 读取本地文件构建
|
| 52 |
+
print(f"🚀 开始扫描本地文件并构建向量数据库:{EXPORT_DIR}")
|
| 53 |
+
if not EXPORT_DIR.exists():
|
| 54 |
+
raise FileNotFoundError(f"GENAI COURSES 目录不存在:{EXPORT_DIR}")
|
| 55 |
+
|
| 56 |
+
# 1. 读取文件
|
| 57 |
+
# recursive=True 会读取子文件夹,确保附件和嵌套页面都被读取
|
| 58 |
+
# required_exts 可以指定只读 .md,如果不加这行则会读取所有支持的文件(pdf, txt, etc.)
|
| 59 |
+
reader = SimpleDirectoryReader(
|
| 60 |
+
input_dir=str(EXPORT_DIR),
|
| 61 |
+
recursive=True,
|
| 62 |
+
# 可以根据需要调整:这里把常见的课程文件类型都包含进来
|
| 63 |
+
required_exts=[".md", ".pdf", ".txt", ".py", ".ipynb"],
|
| 64 |
+
)
|
| 65 |
+
documents = reader.load_data()
|
| 66 |
+
print(f"📄 成功读取了 {len(documents)} 个文件片段")
|
| 67 |
+
|
| 68 |
+
# 2. 构建索引 (这一步会调用 OpenAI API 进行 Embedding)
|
| 69 |
+
print("🧠 正在生成向量索引 (Embedding)...")
|
| 70 |
+
index = VectorStoreIndex.from_documents(documents)
|
| 71 |
+
|
| 72 |
+
# 3. 保存到硬盘
|
| 73 |
+
print(f"💾 正在保存数据库到 {PERSIST_DIR} ...")
|
| 74 |
+
index.storage_context.persist(persist_dir=PERSIST_DIR)
|
| 75 |
+
|
| 76 |
+
return index
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
# --- 主程序 ---
|
| 80 |
+
|
| 81 |
+
# 首次运行或通过参数控制 force_rebuild=True 来更新
|
| 82 |
+
index = get_index(force_rebuild=False)
|
| 83 |
+
|
| 84 |
+
# 创建查询引擎
|
| 85 |
+
query_engine = index.as_query_engine()
|
| 86 |
+
|
| 87 |
+
print("\n💬 本地知识库助手已就绪 (输入 'exit' 退出, 'update' 重建):")
|
| 88 |
+
|
| 89 |
+
while True:
|
| 90 |
+
question = input("\n请输入问题: ")
|
| 91 |
+
|
| 92 |
+
if question.lower() == 'exit':
|
| 93 |
+
break
|
| 94 |
+
elif question.lower() == 'update':
|
| 95 |
+
index = get_index(force_rebuild=True)
|
| 96 |
+
query_engine = index.as_query_engine()
|
| 97 |
+
print("✅ 数据库已更新!")
|
| 98 |
+
continue
|
| 99 |
+
|
| 100 |
+
response = query_engine.query(question)
|
| 101 |
+
print(f"\n🤖 回答:\n{response}")
|
PRESENTATION_SUMMARY.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Clare AI + Weaviate 技术汇报摘要
|
| 2 |
+
|
| 3 |
+
## 🎯 一、项目概述
|
| 4 |
+
|
| 5 |
+
**Clare AI** = 智能教学助手 + RAG 知识检索
|
| 6 |
+
|
| 7 |
+
- **核心能力**: 基于课程文档的智能问答
|
| 8 |
+
- **技术亮点**: Weaviate Cloud 向量数据库 + OpenAI GPT-4.1-mini
|
| 9 |
+
- **部署平台**: Hugging Face Space (Docker)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 🏗️ 二、架构设计
|
| 14 |
+
|
| 15 |
+
### 2.1 三层架构
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
┌─────────────────┐
|
| 19 |
+
│ Gradio UI │ 用户交互层
|
| 20 |
+
├─────────────────┤
|
| 21 |
+
│ Clare Core │ 业务逻辑层 (LLM + Prompt)
|
| 22 |
+
├─────────────────┤
|
| 23 |
+
│ RAG Engine │ 检索层 (Weaviate + FAISS)
|
| 24 |
+
└─────────────────┘
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### 2.2 数据流
|
| 28 |
+
|
| 29 |
+
**用户提问** → **RAG 检索** → **构建 Prompt** → **LLM 生成** → **返回回答**
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## 🛠️ 三、技术栈
|
| 34 |
+
|
| 35 |
+
| 层级 | 技术选型 | 说明 |
|
| 36 |
+
|------|---------|------|
|
| 37 |
+
| **前端** | Gradio 6.0 | Web UI 框架 |
|
| 38 |
+
| **LLM** | GPT-4.1-mini | OpenAI API |
|
| 39 |
+
| **向量数据库** | Weaviate Cloud | 托管服务 (GCP) |
|
| 40 |
+
| **Embedding** | sentence-transformers/all-MiniLM-L6-v2 | 免费,384维 |
|
| 41 |
+
| **文档解析** | unstructured.io + pypdf | PDF/DOCX 解析 |
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 📊 四、核心组件
|
| 46 |
+
|
| 47 |
+
### 4.1 Weaviate 检索 (`_retrieve_from_weaviate`)
|
| 48 |
+
|
| 49 |
+
**功能**: 从云端向量数据库检索课程知识
|
| 50 |
+
|
| 51 |
+
**流程**:
|
| 52 |
+
1. 连接 Weaviate Cloud (HTTPS + API Key)
|
| 53 |
+
2. 用户问题 → Embedding 向量
|
| 54 |
+
3. 向量相似度搜索 (top_k=5)
|
| 55 |
+
4. 返回相关文档块
|
| 56 |
+
|
| 57 |
+
**特性**:
|
| 58 |
+
- ✅ 45秒超时保护
|
| 59 |
+
- ✅ 启动时模型预热
|
| 60 |
+
- ✅ 失败时静默降级
|
| 61 |
+
|
| 62 |
+
### 4.2 索引构建 (`build_weaviate_index.py`)
|
| 63 |
+
|
| 64 |
+
**功能**: 一次性将课程文档上传到 Weaviate
|
| 65 |
+
|
| 66 |
+
**数据规模**:
|
| 67 |
+
- 📄 **151+ 文件**: PDF (22) + DOCX (112) + 其他 (17)
|
| 68 |
+
- 📦 **~917 文档块**: 自动分块
|
| 69 |
+
- 🔢 **384维向量**: all-MiniLM-L6-v2
|
| 70 |
+
|
| 71 |
+
**流程**:
|
| 72 |
+
```
|
| 73 |
+
本地目录 → 文档解析 → Embedding → 上传 Weaviate
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 💡 五、关键技术决策
|
| 79 |
+
|
| 80 |
+
### 5.1 为什么选择 Weaviate Cloud?
|
| 81 |
+
|
| 82 |
+
| 方案 | 优势 | 劣势 |
|
| 83 |
+
|------|------|------|
|
| 84 |
+
| **Weaviate Cloud** ✅ | 托管、高性能、可扩展 | 需要 API Key |
|
| 85 |
+
| 本地 FAISS | 免费、快速 | 内存限制、不持久化 |
|
| 86 |
+
| Pinecone | 易用 | 成本高 |
|
| 87 |
+
| 自建 Weaviate | 完全控制 | 需要运维 |
|
| 88 |
+
|
| 89 |
+
**结论**: Weaviate Cloud 平衡了成本、性能和运维复杂度
|
| 90 |
+
|
| 91 |
+
### 5.2 为什么使用 sentence-transformers?
|
| 92 |
+
|
| 93 |
+
**原因**:
|
| 94 |
+
- 💰 **免费**: 无 API 调用费用
|
| 95 |
+
- 🔄 **一致性**: 建索引和查询同一模型
|
| 96 |
+
- 🚀 **离线**: 本地运行,不依赖外部 API
|
| 97 |
+
|
| 98 |
+
**对比 OpenAI Embeddings**:
|
| 99 |
+
- OpenAI: 更准确 (1536维),但需付费
|
| 100 |
+
- sentence-transformers: 免费,性能足够
|
| 101 |
+
|
| 102 |
+
### 5.3 双检索策略
|
| 103 |
+
|
| 104 |
+
| 检索源 | 用途 | 特点 |
|
| 105 |
+
|--------|------|------|
|
| 106 |
+
| **Weaviate** | 课程知识库 | 持久化、151+ 文档 |
|
| 107 |
+
| **FAISS** | 用户上传文件 | 临时、内存检索 |
|
| 108 |
+
|
| 109 |
+
**优势**: 课程知识稳定 + 用户文件灵活
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## 📈 六、性能指标
|
| 114 |
+
|
| 115 |
+
| 指标 | 数值 | 说明 |
|
| 116 |
+
|------|------|------|
|
| 117 |
+
| **Weaviate 检索** | ~500-1000ms | 含网络延迟 |
|
| 118 |
+
| **FAISS 检索** | ~10-50ms | 内存检索 |
|
| 119 |
+
| **LLM 响应** | ~2-5秒 | GPT-4.1-mini |
|
| 120 |
+
| **系统内存** | ~2-4GB | 含 embedding 模型 |
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## 🔧 七、部署架构
|
| 125 |
+
|
| 126 |
+
### 7.1 Hugging Face Space
|
| 127 |
+
|
| 128 |
+
**部署方式**: Docker Space
|
| 129 |
+
|
| 130 |
+
**关键配置**:
|
| 131 |
+
- Python 3.11
|
| 132 |
+
- 强制 `huggingface_hub>=1.3.0`
|
| 133 |
+
- 系统库: `libxcb` (PDF 解析)
|
| 134 |
+
|
| 135 |
+
**环境变量**:
|
| 136 |
+
- `OPENAI_API_KEY`
|
| 137 |
+
- `WEAVIATE_URL`
|
| 138 |
+
- `WEAVIATE_API_KEY`
|
| 139 |
+
|
| 140 |
+
### 7.2 Weaviate Cloud
|
| 141 |
+
|
| 142 |
+
**托管**: GCP (Google Cloud Platform)
|
| 143 |
+
|
| 144 |
+
**配置**:
|
| 145 |
+
- Collection: `GenAICourses`
|
| 146 |
+
- 认证: API Key
|
| 147 |
+
- Schema: 自动管理 (LlamaIndex)
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## 🐛 八、技术挑战与解决
|
| 152 |
+
|
| 153 |
+
### 8.1 huggingface_hub 版本冲突
|
| 154 |
+
|
| 155 |
+
**问题**: HF Space 预装 0.36.2,`transformers` 需要 >=1.3.0
|
| 156 |
+
|
| 157 |
+
**解决**:
|
| 158 |
+
- Dockerfile 强制升级
|
| 159 |
+
- App 启动时 monkey-patch
|
| 160 |
+
|
| 161 |
+
### 8.2 Gradio 6.0 API 变更
|
| 162 |
+
|
| 163 |
+
**问题**: `Chatbot` 格式从 tuples 改为 messages
|
| 164 |
+
|
| 165 |
+
**解决**: 添加格式转换函数
|
| 166 |
+
|
| 167 |
+
### 8.3 Embedding 模型加载慢
|
| 168 |
+
|
| 169 |
+
**问题**: 首次检索需 10-30 秒
|
| 170 |
+
|
| 171 |
+
**解决**: 启动时后台预热
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## ✅ 九、项目成果
|
| 176 |
+
|
| 177 |
+
### 9.1 功能实现
|
| 178 |
+
|
| 179 |
+
- ✅ 多模式教学(5种学习模式)
|
| 180 |
+
- ✅ 课程知识检索(151+ 文档)
|
| 181 |
+
- ✅ 会话记忆管理
|
| 182 |
+
- ✅ 中英文双语支持
|
| 183 |
+
|
| 184 |
+
### 9.2 技术指标
|
| 185 |
+
|
| 186 |
+
- ✅ 检索延迟 <1秒
|
| 187 |
+
- ✅ LLM 响应 <5秒
|
| 188 |
+
- ✅ 系统可用性 >99%
|
| 189 |
+
|
| 190 |
+
### 9.3 成本优化
|
| 191 |
+
|
| 192 |
+
- ✅ 免费 embedding 模型
|
| 193 |
+
- ✅ 托管向量数据库(无需运维)
|
| 194 |
+
- ✅ 按需扩展
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## 🚀 十、未来规划
|
| 199 |
+
|
| 200 |
+
### 短期 (1-2个月)
|
| 201 |
+
- [ ] 优化检索精度(reranking)
|
| 202 |
+
- [ ] 支持更多文档格式(PPTX、HTML)
|
| 203 |
+
- [ ] 添加检索结果评分
|
| 204 |
+
|
| 205 |
+
### 中期 (3-6个月)
|
| 206 |
+
- [ ] 多模态支持(图片、视频)
|
| 207 |
+
- [ ] 知识图谱增强
|
| 208 |
+
- [ ] 个性化推荐
|
| 209 |
+
|
| 210 |
+
### 长期 (6-12个月)
|
| 211 |
+
- [ ] Agent 化(工具调用)
|
| 212 |
+
- [ ] 多租户支持
|
| 213 |
+
- [ ] 离线部署方案
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
## 📞 十一、Q&A 准备
|
| 218 |
+
|
| 219 |
+
### Q1: 为什么不用 OpenAI Embeddings?
|
| 220 |
+
|
| 221 |
+
**A**:
|
| 222 |
+
- 成本考虑:sentence-transformers 免费
|
| 223 |
+
- 一致性:建索引和查询同一模型
|
| 224 |
+
- 性能足够:384维向量已满足需求
|
| 225 |
+
|
| 226 |
+
### Q2: Weaviate 的成本如何?
|
| 227 |
+
|
| 228 |
+
**A**:
|
| 229 |
+
- Weaviate Cloud 有免费 tier
|
| 230 |
+
- 当前数据量在免费范围内
|
| 231 |
+
- 按需扩展,成本可控
|
| 232 |
+
|
| 233 |
+
### Q3: 如何保证检索质量?
|
| 234 |
+
|
| 235 |
+
**A**:
|
| 236 |
+
- 使用专业 embedding 模型
|
| 237 |
+
- top_k=5 平衡精度和速度
|
| 238 |
+
- 未来可加入 reranking
|
| 239 |
+
|
| 240 |
+
### Q4: 系统如何扩展?
|
| 241 |
+
|
| 242 |
+
**A**:
|
| 243 |
+
- Weaviate Cloud 自动扩展
|
| 244 |
+
- 添加文档只需重新运行索引脚本
|
| 245 |
+
- LLM 通过 OpenAI API 自动扩展
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
**汇报时间**: 15-20 分钟
|
| 250 |
+
**建议重点**: 架构设计、技术选型、性能指标
|
README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Hanbridge Clare Assistant (Product UI)
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Hanbridge Clare Assistant – Product Version
|
| 12 |
+
|
| 13 |
+
This Space hosts **Clare**, an AI-powered personalized learning assistant for Hanbridge University.
|
| 14 |
+
|
| 15 |
+
## 运行方式(推荐:产品版 Web UI)
|
| 16 |
+
|
| 17 |
+
**使用 React 产品界面(Hanbridge 仪表盘风格:Ask / Review / Quiz、侧边栏、SmartReview 等):**
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
# 1. 安装 Python 依赖(项目根目录)
|
| 21 |
+
pip install -r requirements.txt
|
| 22 |
+
|
| 23 |
+
# 2. 配置 .env(至少设置 OPENAI_API_KEY)
|
| 24 |
+
|
| 25 |
+
# 3. 一键启动(会自动构建 web 并启动后端,浏览器访问 http://localhost:8000)
|
| 26 |
+
chmod +x run_web.sh && ./run_web.sh
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
或分步执行:
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
cd web && npm install && npm run build
|
| 33 |
+
cd .. && uvicorn api.server:app --host 0.0.0.0 --port 8000
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
更多说明见 **web/使用说明.md**。
|
| 37 |
+
|
| 38 |
+
**可选:Gradio 界面**(根目录 `python app.py`,端口 7860)适用于快速演示或 Hugging Face Space 的 Gradio 版;产品部署推荐使用上述 Web UI。
|
| 39 |
+
|
| 40 |
+
## Architecture Overview
|
| 41 |
+
|
| 42 |
+
- **Frontend**: React + Vite (exported from Figma design)
|
| 43 |
+
- **Backend**: FastAPI (Python)
|
| 44 |
+
- **LLM Orchestration**: OpenAI + LangChain
|
| 45 |
+
- **RAG**: Vector database (FAISS) + OpenAI embeddings (text-embedding-3-small)
|
| 46 |
+
- **PDF Parsing**: unstructured.io (priority) + pypdf (fallback)
|
| 47 |
+
- **Observability**: LangSmith
|
| 48 |
+
- **Deployment**: Hugging Face Docker Space
|
| 49 |
+
|
| 50 |
+
### Optional: Text-to-Speech & Podcast
|
| 51 |
+
|
| 52 |
+
- **TTS**: Uses the same **OpenAI API key** (no extra secrets). Right panel: “Listen (TTS)” converts the current export/summary text to speech.
|
| 53 |
+
- **Podcast**: “Podcast (summary)” or “Podcast (chat)” generates an MP3 from the session summary or full conversation.
|
| 54 |
+
- **Hugging Face**: Set `OPENAI_API_KEY` in the Space **Settings → Secrets**. No extra env vars needed. For long podcasts, the Space may need sufficient timeout (default backend allows up to 2 minutes for `/api/podcast`).
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
📦 project/
|
| 58 |
+
├── app.py
|
| 59 |
+
├── api/
|
| 60 |
+
│ ├── server.py
|
| 61 |
+
│ ├── clare_core.py
|
| 62 |
+
│ ├── rag_engine.py ← RAG with vector DB (FAISS) + embeddings
|
| 63 |
+
│ └── tts_podcast.py ← TTS & podcast (OpenAI TTS)
|
| 64 |
+
├── web/ ← React frontend
|
| 65 |
+
└── requirements.txt
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### RAG with Vector Database
|
| 70 |
+
|
| 71 |
+
- **Embeddings**: OpenAI `text-embedding-3-small` (1536 dimensions)
|
| 72 |
+
- **Vector Storage**: FAISS (in-memory, L2 distance)
|
| 73 |
+
- **Retrieval Strategy**: Vector similarity search + token overlap rerank
|
| 74 |
+
- **PDF Parsing**:
|
| 75 |
+
- Primary: `unstructured.io` (better quality, handles complex layouts)
|
| 76 |
+
- Fallback: `pypdf` (if unstructured fails)
|
| 77 |
+
- **Backward Compatible**: Falls back to token-based retrieval if embeddings unavailable
|
| 78 |
+
|
| 79 |
+
### Optional: GenAICoursesDB 向量知识库(方案三)
|
| 80 |
+
|
| 81 |
+
Clare 可调用 Hugging Face 上的 **GenAICoursesDB** Space 获取 GENAI 课程检索结果。设置 `GENAI_COURSES_SPACE=claudqunwang/GenAICoursesDB` 即可启用;Clare 会在每次对话时自动将课程知识库的检索结果补充到 RAG 上下文中。
|
api/clare_core.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/clare_core.py
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import math
|
| 5 |
+
from typing import List, Dict, Tuple, Optional
|
| 6 |
+
|
| 7 |
+
from docx import Document
|
| 8 |
+
|
| 9 |
+
from .config import (
|
| 10 |
+
client,
|
| 11 |
+
DEFAULT_MODEL,
|
| 12 |
+
EMBEDDING_MODEL,
|
| 13 |
+
DEFAULT_COURSE_TOPICS,
|
| 14 |
+
CLARE_SYSTEM_PROMPT,
|
| 15 |
+
LEARNING_MODE_INSTRUCTIONS,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# ----------------------------
|
| 19 |
+
# Tracing toggle (LangSmith)
|
| 20 |
+
# ----------------------------
|
| 21 |
+
# Default OFF for speed + stability in HF cold start environments.
|
| 22 |
+
ENABLE_TRACING = os.getenv("CLARE_ENABLE_TRACING", "0").strip() == "1"
|
| 23 |
+
|
| 24 |
+
if ENABLE_TRACING:
|
| 25 |
+
from langsmith import traceable # type: ignore
|
| 26 |
+
from langsmith.run_helpers import set_run_metadata # type: ignore
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
# Available in newer langsmith versions
|
| 30 |
+
from langsmith.run_helpers import get_current_run_tree # type: ignore
|
| 31 |
+
except Exception:
|
| 32 |
+
get_current_run_tree = None # type: ignore
|
| 33 |
+
else:
|
| 34 |
+
# no-op decorators / funcs
|
| 35 |
+
def traceable(*args, **kwargs): # type: ignore
|
| 36 |
+
def _decorator(fn):
|
| 37 |
+
return fn
|
| 38 |
+
return _decorator
|
| 39 |
+
|
| 40 |
+
def set_run_metadata(**kwargs): # type: ignore
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
get_current_run_tree = None # type: ignore
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ----------------------------
|
| 47 |
+
# Speed knobs (simple + stable)
|
| 48 |
+
# ----------------------------
|
| 49 |
+
MAX_HISTORY_TURNS = int(os.getenv("CLARE_MAX_HISTORY_TURNS", "10"))
|
| 50 |
+
MAX_RAG_CHARS_IN_PROMPT = int(os.getenv("CLARE_MAX_RAG_CHARS", "2000"))
|
| 51 |
+
DEFAULT_MAX_OUTPUT_TOKENS = int(os.getenv("CLARE_MAX_OUTPUT_TOKENS", "384"))
|
| 52 |
+
|
| 53 |
+
# Similarity knobs
|
| 54 |
+
ENABLE_EMBEDDING_SIM = os.getenv("CLARE_ENABLE_EMBEDDING_SIMILARITY", "0").strip() == "1"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------- syllabus 解析 ----------
|
| 58 |
+
def parse_syllabus_docx(file_path: str, max_lines: int = 15) -> List[str]:
|
| 59 |
+
topics: List[str] = []
|
| 60 |
+
try:
|
| 61 |
+
doc = Document(file_path)
|
| 62 |
+
for para in doc.paragraphs:
|
| 63 |
+
text = para.text.strip()
|
| 64 |
+
if not text:
|
| 65 |
+
continue
|
| 66 |
+
topics.append(text)
|
| 67 |
+
if len(topics) >= max_lines:
|
| 68 |
+
break
|
| 69 |
+
except Exception as e:
|
| 70 |
+
topics = [f"[Error parsing syllabus: {e}]"]
|
| 71 |
+
return topics
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------- 简单“弱项”检测 ----------
|
| 75 |
+
WEAKNESS_KEYWORDS = [
|
| 76 |
+
"don't understand",
|
| 77 |
+
"do not understand",
|
| 78 |
+
"not understand",
|
| 79 |
+
"not sure",
|
| 80 |
+
"confused",
|
| 81 |
+
"hard to",
|
| 82 |
+
"difficult",
|
| 83 |
+
"struggle",
|
| 84 |
+
"不会",
|
| 85 |
+
"不懂",
|
| 86 |
+
"看不懂",
|
| 87 |
+
"搞不清",
|
| 88 |
+
"很难",
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
# ---------- 简单“掌握”检测 ----------
|
| 92 |
+
MASTERY_KEYWORDS = [
|
| 93 |
+
"got it",
|
| 94 |
+
"makes sense",
|
| 95 |
+
"now i see",
|
| 96 |
+
"i see",
|
| 97 |
+
"understand now",
|
| 98 |
+
"clear now",
|
| 99 |
+
"easy",
|
| 100 |
+
"no problem",
|
| 101 |
+
"没问题",
|
| 102 |
+
"懂了",
|
| 103 |
+
"明白了",
|
| 104 |
+
"清楚了",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def update_weaknesses_from_message(message: str, weaknesses: List[str]) -> List[str]:
|
| 109 |
+
lower_msg = (message or "").lower()
|
| 110 |
+
if any(k in lower_msg for k in WEAKNESS_KEYWORDS):
|
| 111 |
+
weaknesses = weaknesses or []
|
| 112 |
+
weaknesses.append(message)
|
| 113 |
+
return weaknesses
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def update_cognitive_state_from_message(
|
| 117 |
+
message: str,
|
| 118 |
+
state: Optional[Dict[str, int]],
|
| 119 |
+
) -> Dict[str, int]:
|
| 120 |
+
if state is None:
|
| 121 |
+
state = {"confusion": 0, "mastery": 0}
|
| 122 |
+
|
| 123 |
+
lower_msg = (message or "").lower()
|
| 124 |
+
if any(k in lower_msg for k in WEAKNESS_KEYWORDS):
|
| 125 |
+
state["confusion"] = state.get("confusion", 0) + 1
|
| 126 |
+
if any(k in lower_msg for k in MASTERY_KEYWORDS):
|
| 127 |
+
state["mastery"] = state.get("mastery", 0) + 1
|
| 128 |
+
return state
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def describe_cognitive_state(state: Optional[Dict[str, int]]) -> str:
|
| 132 |
+
if not state:
|
| 133 |
+
return "unknown"
|
| 134 |
+
confusion = state.get("confusion", 0)
|
| 135 |
+
mastery = state.get("mastery", 0)
|
| 136 |
+
if confusion >= 2 and confusion >= mastery + 1:
|
| 137 |
+
return "student shows signs of HIGH cognitive load (often confused)."
|
| 138 |
+
elif mastery >= 2 and mastery >= confusion + 1:
|
| 139 |
+
return "student seems COMFORTABLE; material may be slightly easy."
|
| 140 |
+
else:
|
| 141 |
+
return "mixed or uncertain cognitive state."
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------- Session Memory ----------
|
| 145 |
+
def build_session_memory_summary(
|
| 146 |
+
history: List[Tuple[str, str]],
|
| 147 |
+
weaknesses: Optional[List[str]],
|
| 148 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 149 |
+
max_questions: int = 3,
|
| 150 |
+
max_weaknesses: int = 2,
|
| 151 |
+
) -> str:
|
| 152 |
+
parts: List[str] = []
|
| 153 |
+
|
| 154 |
+
if history:
|
| 155 |
+
recent_qs = [u for (u, _a) in history[-max_questions:]]
|
| 156 |
+
trimmed_qs = []
|
| 157 |
+
for q in recent_qs:
|
| 158 |
+
q = (q or "").strip()
|
| 159 |
+
if len(q) > 120:
|
| 160 |
+
q = q[:117] + "..."
|
| 161 |
+
if q:
|
| 162 |
+
trimmed_qs.append(q)
|
| 163 |
+
if trimmed_qs:
|
| 164 |
+
parts.append("Recent student questions: " + " | ".join(trimmed_qs))
|
| 165 |
+
|
| 166 |
+
if weaknesses:
|
| 167 |
+
recent_weak = weaknesses[-max_weaknesses:]
|
| 168 |
+
trimmed_weak = []
|
| 169 |
+
for w in recent_weak:
|
| 170 |
+
w = (w or "").strip()
|
| 171 |
+
if len(w) > 120:
|
| 172 |
+
w = w[:117] + "..."
|
| 173 |
+
if w:
|
| 174 |
+
trimmed_weak.append(w)
|
| 175 |
+
if trimmed_weak:
|
| 176 |
+
parts.append("Recent difficulties: " + " | ".join(trimmed_weak))
|
| 177 |
+
|
| 178 |
+
if cognitive_state:
|
| 179 |
+
parts.append("Cognitive state: " + describe_cognitive_state(cognitive_state))
|
| 180 |
+
|
| 181 |
+
if not parts:
|
| 182 |
+
return "No prior session memory. Start with a short explanation and ask a quick check-up question."
|
| 183 |
+
|
| 184 |
+
return " | ".join(parts)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ---------- 语言检测 ----------
|
| 188 |
+
def detect_language(message: str, preference: str) -> str:
|
| 189 |
+
if preference in ("English", "中文"):
|
| 190 |
+
return preference
|
| 191 |
+
if re.search(r"[\u4e00-\u9fff]", message or ""):
|
| 192 |
+
return "中文"
|
| 193 |
+
return "English"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def build_error_message(e: Exception, lang: str, op: str = "chat") -> str:
|
| 197 |
+
if lang == "中文":
|
| 198 |
+
prefix = {
|
| 199 |
+
"chat": "抱歉,刚刚在和模型对话时出现了一点问题。",
|
| 200 |
+
"quiz": "抱歉,刚刚在生成测验题目时出现了一点问题。",
|
| 201 |
+
"summary": "抱歉,刚刚在生成总结时出现了一点问题。",
|
| 202 |
+
}.get(op, "抱歉,刚刚出现了一点问题。")
|
| 203 |
+
return prefix + " 请稍后再试一次,或者换个问法试试。"
|
| 204 |
+
|
| 205 |
+
prefix_en = {
|
| 206 |
+
"chat": "Sorry, I ran into a problem while talking to the model.",
|
| 207 |
+
"quiz": "Sorry, there was a problem while generating the quiz.",
|
| 208 |
+
"summary": "Sorry, there was a problem while generating the summary.",
|
| 209 |
+
}.get(op, "Sorry, something went wrong just now.")
|
| 210 |
+
return prefix_en + " Please try again in a moment or rephrase your request."
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------- Session 状态展示 ----------
|
| 214 |
+
def render_session_status(
|
| 215 |
+
learning_mode: str,
|
| 216 |
+
weaknesses: Optional[List[str]],
|
| 217 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 218 |
+
) -> str:
|
| 219 |
+
lines: List[str] = []
|
| 220 |
+
lines.append("### Session status\n")
|
| 221 |
+
lines.append(f"- Learning mode: **{learning_mode}**")
|
| 222 |
+
lines.append(f"- Cognitive state: {describe_cognitive_state(cognitive_state)}")
|
| 223 |
+
|
| 224 |
+
if weaknesses:
|
| 225 |
+
lines.append("- Recent difficulties (last 3):")
|
| 226 |
+
for w in weaknesses[-3:]:
|
| 227 |
+
lines.append(f" - {w}")
|
| 228 |
+
else:
|
| 229 |
+
lines.append("- Recent difficulties: *(none yet)*")
|
| 230 |
+
|
| 231 |
+
return "\n".join(lines)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ---------- Similarity helpers ----------
|
| 235 |
+
def _normalize_text(text: str) -> str:
|
| 236 |
+
text = (text or "").lower().strip()
|
| 237 |
+
text = re.sub(r"[^\w\s]", " ", text)
|
| 238 |
+
text = re.sub(r"\s+", " ", text)
|
| 239 |
+
return text
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _jaccard_similarity(a: str, b: str) -> float:
|
| 243 |
+
tokens_a = set(a.split())
|
| 244 |
+
tokens_b = set(b.split())
|
| 245 |
+
if not tokens_a or not tokens_b:
|
| 246 |
+
return 0.0
|
| 247 |
+
return len(tokens_a & tokens_b) / len(tokens_a | tokens_b)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def cosine_similarity(a: List[float], b: List[float]) -> float:
|
| 251 |
+
if not a or not b or len(a) != len(b):
|
| 252 |
+
return 0.0
|
| 253 |
+
dot = sum(x * y for x, y in zip(a, b))
|
| 254 |
+
norm_a = math.sqrt(sum(x * x for x in a))
|
| 255 |
+
norm_b = math.sqrt(sum(y * y for y in b))
|
| 256 |
+
if norm_a == 0 or norm_b == 0:
|
| 257 |
+
return 0.0
|
| 258 |
+
return dot / (norm_a * norm_b)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@traceable(run_type="embedding", name="get_embedding")
|
| 262 |
+
def get_embedding(text: str) -> Optional[List[float]]:
|
| 263 |
+
try:
|
| 264 |
+
resp = client.embeddings.create(
|
| 265 |
+
model=EMBEDDING_MODEL,
|
| 266 |
+
input=[text],
|
| 267 |
+
)
|
| 268 |
+
return resp.data[0].embedding
|
| 269 |
+
except Exception as e:
|
| 270 |
+
print(f"[Embedding error] {repr(e)}")
|
| 271 |
+
return None
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def find_similar_past_question(
|
| 275 |
+
message: str,
|
| 276 |
+
history: List[Tuple[str, str]],
|
| 277 |
+
jaccard_threshold: float = 0.65,
|
| 278 |
+
embedding_threshold: float = 0.85,
|
| 279 |
+
max_turns_to_check: int = 6,
|
| 280 |
+
) -> Optional[Tuple[str, str, float]]:
|
| 281 |
+
"""
|
| 282 |
+
Fast path:
|
| 283 |
+
- Always do Jaccard on normalized text for up to max_turns_to_check.
|
| 284 |
+
Optional path (disabled by default for speed/stability):
|
| 285 |
+
- Embedding-based similarity if ENABLE_EMBEDDING_SIM=1
|
| 286 |
+
"""
|
| 287 |
+
norm_msg = _normalize_text(message)
|
| 288 |
+
if not norm_msg:
|
| 289 |
+
return None
|
| 290 |
+
|
| 291 |
+
best_sim_j = 0.0
|
| 292 |
+
best_pair_j: Optional[Tuple[str, str]] = None
|
| 293 |
+
checked = 0
|
| 294 |
+
|
| 295 |
+
for user_q, assistant_a in reversed(history):
|
| 296 |
+
checked += 1
|
| 297 |
+
if checked > max_turns_to_check:
|
| 298 |
+
break
|
| 299 |
+
|
| 300 |
+
norm_hist_q = _normalize_text(user_q)
|
| 301 |
+
if not norm_hist_q:
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
if norm_msg == norm_hist_q:
|
| 305 |
+
return user_q, assistant_a, 1.0
|
| 306 |
+
|
| 307 |
+
sim_j = _jaccard_similarity(norm_msg, norm_hist_q)
|
| 308 |
+
if sim_j > best_sim_j:
|
| 309 |
+
best_sim_j = sim_j
|
| 310 |
+
best_pair_j = (user_q, assistant_a)
|
| 311 |
+
|
| 312 |
+
if best_pair_j and best_sim_j >= jaccard_threshold:
|
| 313 |
+
return best_pair_j[0], best_pair_j[1], best_sim_j
|
| 314 |
+
|
| 315 |
+
# Optional: embedding similarity (OFF by default)
|
| 316 |
+
if not ENABLE_EMBEDDING_SIM:
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
if not history:
|
| 320 |
+
return None
|
| 321 |
+
|
| 322 |
+
msg_emb = get_embedding(message)
|
| 323 |
+
if msg_emb is None:
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
+
best_sim_e = 0.0
|
| 327 |
+
best_pair_e: Optional[Tuple[str, str]] = None
|
| 328 |
+
checked = 0
|
| 329 |
+
|
| 330 |
+
for user_q, assistant_a in reversed(history):
|
| 331 |
+
checked += 1
|
| 332 |
+
if checked > max_turns_to_check:
|
| 333 |
+
break
|
| 334 |
+
|
| 335 |
+
hist_emb = get_embedding(user_q)
|
| 336 |
+
if hist_emb is None:
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
sim_e = cosine_similarity(msg_emb, hist_emb)
|
| 340 |
+
if sim_e > best_sim_e:
|
| 341 |
+
best_sim_e = sim_e
|
| 342 |
+
best_pair_e = (user_q, assistant_a)
|
| 343 |
+
|
| 344 |
+
if best_pair_e and best_sim_e >= embedding_threshold:
|
| 345 |
+
return best_pair_e[0], best_pair_e[1], best_sim_e
|
| 346 |
+
|
| 347 |
+
return None
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
@traceable(run_type="llm", name="safe_chat_completion")
|
| 351 |
+
def safe_chat_completion(
|
| 352 |
+
model_name: str,
|
| 353 |
+
messages: List[Dict[str, str]],
|
| 354 |
+
lang: str,
|
| 355 |
+
op: str = "chat",
|
| 356 |
+
temperature: float = 0.5,
|
| 357 |
+
max_tokens: Optional[int] = None,
|
| 358 |
+
) -> str:
|
| 359 |
+
preferred_model = model_name_or_default(model_name)
|
| 360 |
+
last_error: Optional[Exception] = None
|
| 361 |
+
max_tokens = int(max_tokens or DEFAULT_MAX_OUTPUT_TOKENS)
|
| 362 |
+
|
| 363 |
+
for attempt in range(2):
|
| 364 |
+
current_model = preferred_model if attempt == 0 else DEFAULT_MODEL
|
| 365 |
+
try:
|
| 366 |
+
resp = client.chat.completions.create(
|
| 367 |
+
model=current_model,
|
| 368 |
+
messages=messages,
|
| 369 |
+
temperature=temperature,
|
| 370 |
+
max_tokens=max_tokens,
|
| 371 |
+
timeout=20,
|
| 372 |
+
)
|
| 373 |
+
return resp.choices[0].message.content or ""
|
| 374 |
+
except Exception as e:
|
| 375 |
+
print(
|
| 376 |
+
f"[safe_chat_completion][{op}] attempt {attempt+1} failed with model={current_model}: {repr(e)}"
|
| 377 |
+
)
|
| 378 |
+
last_error = e
|
| 379 |
+
if current_model == DEFAULT_MODEL or attempt == 1:
|
| 380 |
+
break
|
| 381 |
+
|
| 382 |
+
return build_error_message(last_error or Exception("unknown error"), lang, op)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def build_messages(
|
| 386 |
+
user_message: str,
|
| 387 |
+
history: List[Tuple[str, str]],
|
| 388 |
+
language_preference: str,
|
| 389 |
+
learning_mode: str,
|
| 390 |
+
doc_type: str,
|
| 391 |
+
course_outline: Optional[List[str]],
|
| 392 |
+
weaknesses: Optional[List[str]],
|
| 393 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 394 |
+
rag_context: Optional[str] = None,
|
| 395 |
+
) -> List[Dict[str, str]]:
|
| 396 |
+
messages: List[Dict[str, str]] = [{"role": "system", "content": CLARE_SYSTEM_PROMPT}]
|
| 397 |
+
|
| 398 |
+
if learning_mode in LEARNING_MODE_INSTRUCTIONS:
|
| 399 |
+
mode_instruction = LEARNING_MODE_INSTRUCTIONS[learning_mode]
|
| 400 |
+
messages.append(
|
| 401 |
+
{
|
| 402 |
+
"role": "system",
|
| 403 |
+
"content": f"Current learning mode: {learning_mode}. {mode_instruction}",
|
| 404 |
+
}
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
topics = course_outline if course_outline else DEFAULT_COURSE_TOPICS
|
| 408 |
+
topics_text = " | ".join(topics)
|
| 409 |
+
messages.append(
|
| 410 |
+
{
|
| 411 |
+
"role": "system",
|
| 412 |
+
"content": (
|
| 413 |
+
"Here is the course syllabus context. Use this to stay aligned "
|
| 414 |
+
"with the course topics when answering: " + topics_text
|
| 415 |
+
),
|
| 416 |
+
}
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
if doc_type and doc_type != "Syllabus":
|
| 420 |
+
messages.append(
|
| 421 |
+
{
|
| 422 |
+
"role": "system",
|
| 423 |
+
"content": f"The student also uploaded a {doc_type} document as supporting material.",
|
| 424 |
+
}
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
if weaknesses:
|
| 428 |
+
weak_text = " | ".join((weaknesses or [])[-4:])
|
| 429 |
+
messages.append(
|
| 430 |
+
{
|
| 431 |
+
"role": "system",
|
| 432 |
+
"content": "Student struggles (recent). Be extra clear on these: " + weak_text,
|
| 433 |
+
}
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
if cognitive_state:
|
| 437 |
+
confusion = cognitive_state.get("confusion", 0)
|
| 438 |
+
mastery = cognitive_state.get("mastery", 0)
|
| 439 |
+
if confusion >= 2 and confusion >= mastery + 1:
|
| 440 |
+
messages.append(
|
| 441 |
+
{
|
| 442 |
+
"role": "system",
|
| 443 |
+
"content": "Student under HIGH cognitive load. Use simpler language and shorter steps.",
|
| 444 |
+
}
|
| 445 |
+
)
|
| 446 |
+
elif mastery >= 2 and mastery >= confusion + 1:
|
| 447 |
+
messages.append(
|
| 448 |
+
{
|
| 449 |
+
"role": "system",
|
| 450 |
+
"content": "Student comfortable. You may go slightly deeper and add a follow-up question.",
|
| 451 |
+
}
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
if language_preference == "English":
|
| 455 |
+
messages.append({"role": "system", "content": "Please answer in English."})
|
| 456 |
+
elif language_preference == "中文":
|
| 457 |
+
messages.append({"role": "system", "content": "请用中文回答学生的问题。"})
|
| 458 |
+
|
| 459 |
+
session_memory_text = build_session_memory_summary(
|
| 460 |
+
history=history,
|
| 461 |
+
weaknesses=weaknesses,
|
| 462 |
+
cognitive_state=cognitive_state,
|
| 463 |
+
)
|
| 464 |
+
messages.append({"role": "system", "content": "Session memory: " + session_memory_text})
|
| 465 |
+
|
| 466 |
+
if rag_context:
|
| 467 |
+
rc = (rag_context or "")[:MAX_RAG_CHARS_IN_PROMPT]
|
| 468 |
+
messages.append(
|
| 469 |
+
{
|
| 470 |
+
"role": "system",
|
| 471 |
+
"content": "Relevant excerpts (use as primary grounding):\n\n" + rc,
|
| 472 |
+
}
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
trimmed_history = history[-MAX_HISTORY_TURNS:] if history else []
|
| 476 |
+
for user, assistant in trimmed_history:
|
| 477 |
+
messages.append({"role": "user", "content": user})
|
| 478 |
+
if assistant is not None:
|
| 479 |
+
messages.append({"role": "assistant", "content": assistant})
|
| 480 |
+
|
| 481 |
+
messages.append({"role": "user", "content": user_message})
|
| 482 |
+
return messages
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def model_name_or_default(x: str) -> str:
|
| 486 |
+
return (x or "").strip() or DEFAULT_MODEL
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def get_langsmith_run_id() -> Optional[str]:
|
| 490 |
+
"""
|
| 491 |
+
从 traceable 上下文里获取当前 run_id(用于把 UI feedback 挂到同一个 run 上)
|
| 492 |
+
若 tracing 关闭或环境不支持,则返回 None
|
| 493 |
+
"""
|
| 494 |
+
if not ENABLE_TRACING:
|
| 495 |
+
return None
|
| 496 |
+
try:
|
| 497 |
+
if get_current_run_tree is None:
|
| 498 |
+
return None
|
| 499 |
+
rt = get_current_run_tree()
|
| 500 |
+
if not rt:
|
| 501 |
+
return None
|
| 502 |
+
rid = getattr(rt, "id", None)
|
| 503 |
+
if not rid:
|
| 504 |
+
return None
|
| 505 |
+
return str(rid)
|
| 506 |
+
except Exception as e:
|
| 507 |
+
print(f"[LangSmith get run id error] {repr(e)}")
|
| 508 |
+
return None
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
@traceable(run_type="chain", name="chat_with_clare")
|
| 512 |
+
def chat_with_clare(
|
| 513 |
+
message: str,
|
| 514 |
+
history: List[Tuple[str, str]],
|
| 515 |
+
model_name: str,
|
| 516 |
+
language_preference: str,
|
| 517 |
+
learning_mode: str,
|
| 518 |
+
doc_type: str,
|
| 519 |
+
course_outline: Optional[List[str]],
|
| 520 |
+
weaknesses: Optional[List[str]],
|
| 521 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 522 |
+
rag_context: Optional[str] = None,
|
| 523 |
+
) -> Tuple[str, List[Tuple[str, str]], Optional[str]]:
|
| 524 |
+
# avoid any tracing overhead when disabled (set_run_metadata is no-op in that case)
|
| 525 |
+
try:
|
| 526 |
+
set_run_metadata(
|
| 527 |
+
learning_mode=learning_mode,
|
| 528 |
+
language_preference=language_preference,
|
| 529 |
+
doc_type=doc_type,
|
| 530 |
+
)
|
| 531 |
+
except Exception as e:
|
| 532 |
+
# safe even if tracing enabled but misconfigured
|
| 533 |
+
print(f"[LangSmith metadata error in chat_with_clare] {repr(e)}")
|
| 534 |
+
|
| 535 |
+
messages = build_messages(
|
| 536 |
+
user_message=message,
|
| 537 |
+
history=history,
|
| 538 |
+
language_preference=language_preference,
|
| 539 |
+
learning_mode=learning_mode,
|
| 540 |
+
doc_type=doc_type,
|
| 541 |
+
course_outline=course_outline,
|
| 542 |
+
weaknesses=weaknesses,
|
| 543 |
+
cognitive_state=cognitive_state,
|
| 544 |
+
rag_context=rag_context,
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
answer = safe_chat_completion(
|
| 548 |
+
model_name=model_name,
|
| 549 |
+
messages=messages,
|
| 550 |
+
lang=language_preference,
|
| 551 |
+
op="chat",
|
| 552 |
+
temperature=0.5,
|
| 553 |
+
max_tokens=DEFAULT_MAX_OUTPUT_TOKENS,
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
# Get run_id AFTER the run exists (works when tracing enabled; otherwise None)
|
| 557 |
+
run_id = get_langsmith_run_id()
|
| 558 |
+
|
| 559 |
+
history = history + [(message, answer)]
|
| 560 |
+
return answer, history, run_id
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def export_conversation(
|
| 564 |
+
history: List[Tuple[str, str]],
|
| 565 |
+
course_outline: List[str],
|
| 566 |
+
learning_mode_val: str,
|
| 567 |
+
weaknesses: List[str],
|
| 568 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 569 |
+
) -> str:
|
| 570 |
+
lines: List[str] = []
|
| 571 |
+
lines.append("# Clare – Conversation Export\n")
|
| 572 |
+
lines.append(f"- Learning mode: **{learning_mode_val}**\n")
|
| 573 |
+
lines.append("- Course topics (short): " + "; ".join(course_outline[:5]) + "\n")
|
| 574 |
+
lines.append(f"- Cognitive state snapshot: {describe_cognitive_state(cognitive_state)}\n")
|
| 575 |
+
|
| 576 |
+
if weaknesses:
|
| 577 |
+
lines.append("- Observed student difficulties:\n")
|
| 578 |
+
for w in weaknesses[-5:]:
|
| 579 |
+
lines.append(f" - {w}\n")
|
| 580 |
+
lines.append("\n---\n\n")
|
| 581 |
+
|
| 582 |
+
for user, assistant in history:
|
| 583 |
+
lines.append(f"**Student:** {user}\n\n")
|
| 584 |
+
lines.append(f"**Clare:** {assistant}\n\n")
|
| 585 |
+
lines.append("---\n\n")
|
| 586 |
+
|
| 587 |
+
return "".join(lines)
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
@traceable(run_type="chain", name="summarize_conversation")
|
| 591 |
+
def summarize_conversation(
|
| 592 |
+
history: List[Tuple[str, str]],
|
| 593 |
+
course_outline: List[str],
|
| 594 |
+
weaknesses: List[str],
|
| 595 |
+
cognitive_state: Optional[Dict[str, int]],
|
| 596 |
+
model_name: str,
|
| 597 |
+
language_preference: str,
|
| 598 |
+
) -> str:
|
| 599 |
+
conversation_text = ""
|
| 600 |
+
for user, assistant in history[-10:]:
|
| 601 |
+
conversation_text += f"Student: {user}\nClare: {assistant}\n"
|
| 602 |
+
|
| 603 |
+
topics_text = "; ".join(course_outline[:8])
|
| 604 |
+
weakness_text = "; ".join(weaknesses[-5:]) if weaknesses else "N/A"
|
| 605 |
+
cog_text = describe_cognitive_state(cognitive_state)
|
| 606 |
+
|
| 607 |
+
messages = [
|
| 608 |
+
{"role": "system", "content": CLARE_SYSTEM_PROMPT},
|
| 609 |
+
{"role": "system", "content": "Produce a concept-only summary. Use bullet points. No off-topic text."},
|
| 610 |
+
{"role": "system", "content": f"Course topics: {topics_text}"},
|
| 611 |
+
{"role": "system", "content": f"Student difficulties: {weakness_text}"},
|
| 612 |
+
{"role": "system", "content": f"Cognitive state: {cog_text}"},
|
| 613 |
+
{"role": "user", "content": "Conversation:\n\n" + conversation_text},
|
| 614 |
+
]
|
| 615 |
+
|
| 616 |
+
if language_preference == "中文":
|
| 617 |
+
messages.append({"role": "system", "content": "请用中文输出要点总结(bullet points)。"})
|
| 618 |
+
|
| 619 |
+
summary_text = safe_chat_completion(
|
| 620 |
+
model_name=model_name,
|
| 621 |
+
messages=messages,
|
| 622 |
+
lang=language_preference,
|
| 623 |
+
op="summary",
|
| 624 |
+
temperature=0.4,
|
| 625 |
+
max_tokens=DEFAULT_MAX_OUTPUT_TOKENS,
|
| 626 |
+
)
|
| 627 |
+
return summary_text
|
api/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/config.py
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List, Dict
|
| 5 |
+
|
| 6 |
+
# Load .env from project root (parent of api/) so OPENAI_API_KEY etc. are set
|
| 7 |
+
try:
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
_root = Path(__file__).resolve().parent.parent
|
| 10 |
+
load_dotenv(_root / ".env")
|
| 11 |
+
except Exception:
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
from openai import OpenAI
|
| 16 |
+
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
| 17 |
+
|
| 18 |
+
# ============================
|
| 19 |
+
# Environment & Core Settings
|
| 20 |
+
# ============================
|
| 21 |
+
|
| 22 |
+
OPENAI_API_KEY = (os.getenv("OPENAI_API_KEY") or "").strip()
|
| 23 |
+
if not OPENAI_API_KEY:
|
| 24 |
+
raise RuntimeError("OPENAI_API_KEY is not set. Please go to Settings → Secrets and add it.")
|
| 25 |
+
|
| 26 |
+
# Optional: allow overriding base URL (useful for gateways / proxies)
|
| 27 |
+
OPENAI_BASE_URL = (os.getenv("OPENAI_BASE_URL") or "").strip() or None
|
| 28 |
+
|
| 29 |
+
# Models
|
| 30 |
+
DEFAULT_MODEL = (os.getenv("CLARE_DEFAULT_MODEL") or "gpt-4.1-mini").strip()
|
| 31 |
+
EMBEDDING_MODEL = (os.getenv("CLARE_EMBEDDING_MODEL") or "text-embedding-3-small").strip()
|
| 32 |
+
|
| 33 |
+
# Timeout (seconds) - single source of truth
|
| 34 |
+
OPENAI_TIMEOUT_SECONDS = float(os.getenv("CLARE_OPENAI_TIMEOUT_SECONDS", "20").strip())
|
| 35 |
+
|
| 36 |
+
# Connection pooling / keep-alive
|
| 37 |
+
HTTP_MAX_CONNECTIONS = int(os.getenv("CLARE_HTTP_MAX_CONNECTIONS", "20").strip())
|
| 38 |
+
HTTP_MAX_KEEPALIVE = int(os.getenv("CLARE_HTTP_MAX_KEEPALIVE", "10").strip())
|
| 39 |
+
HTTP_KEEPALIVE_EXPIRY = float(os.getenv("CLARE_HTTP_KEEPALIVE_EXPIRY", "30").strip())
|
| 40 |
+
|
| 41 |
+
# Network retries (transport-level)
|
| 42 |
+
HTTP_RETRIES = int(os.getenv("CLARE_HTTP_RETRIES", "2").strip())
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ============================
|
| 46 |
+
# Shared HTTP client (singleton)
|
| 47 |
+
# ============================
|
| 48 |
+
|
| 49 |
+
# httpx Timeout object (connect/read/write/pool)
|
| 50 |
+
_httpx_timeout = httpx.Timeout(
|
| 51 |
+
timeout=OPENAI_TIMEOUT_SECONDS,
|
| 52 |
+
connect=min(10.0, OPENAI_TIMEOUT_SECONDS),
|
| 53 |
+
read=OPENAI_TIMEOUT_SECONDS,
|
| 54 |
+
write=OPENAI_TIMEOUT_SECONDS,
|
| 55 |
+
pool=min(10.0, OPENAI_TIMEOUT_SECONDS),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
_limits = httpx.Limits(
|
| 59 |
+
max_connections=HTTP_MAX_CONNECTIONS,
|
| 60 |
+
max_keepalive_connections=HTTP_MAX_KEEPALIVE,
|
| 61 |
+
keepalive_expiry=HTTP_KEEPALIVE_EXPIRY,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# A single httpx client reused across the process
|
| 65 |
+
_http_client = httpx.Client(
|
| 66 |
+
timeout=_httpx_timeout,
|
| 67 |
+
limits=_limits,
|
| 68 |
+
headers={
|
| 69 |
+
# Helps some proxies; safe default
|
| 70 |
+
"Connection": "keep-alive",
|
| 71 |
+
},
|
| 72 |
+
follow_redirects=True,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# ============================
|
| 76 |
+
# OpenAI SDK client (singleton)
|
| 77 |
+
# ============================
|
| 78 |
+
|
| 79 |
+
# Keep naming as `client` to avoid touching call sites
|
| 80 |
+
client = OpenAI(
|
| 81 |
+
api_key=OPENAI_API_KEY,
|
| 82 |
+
base_url=OPENAI_BASE_URL,
|
| 83 |
+
http_client=_http_client,
|
| 84 |
+
max_retries=HTTP_RETRIES,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# ============================
|
| 88 |
+
# LangChain wrappers (optional)
|
| 89 |
+
# ============================
|
| 90 |
+
|
| 91 |
+
# If you use LangChain later, reuse the same timeout policy
|
| 92 |
+
llm_default = ChatOpenAI(
|
| 93 |
+
model=DEFAULT_MODEL,
|
| 94 |
+
temperature=0.5,
|
| 95 |
+
timeout=OPENAI_TIMEOUT_SECONDS,
|
| 96 |
+
# Note: LangChain uses its own http stack; keep it simple.
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
embedding_client = OpenAIEmbeddings(
|
| 100 |
+
model=EMBEDDING_MODEL,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# ============================
|
| 104 |
+
# Default course outline
|
| 105 |
+
# ============================
|
| 106 |
+
|
| 107 |
+
DEFAULT_COURSE_TOPICS: List[str] = [
|
| 108 |
+
"Week 0 – Welcome & What is Generative AI; course outcomes LO1–LO5.",
|
| 109 |
+
"Week 1 – Foundations of GenAI: LLMs, Transformer & self-attention, perplexity.",
|
| 110 |
+
"Week 2 – Foundation Models & multimodal models; data scale, bias & risks.",
|
| 111 |
+
"Week 3 – Choosing Pre-trained Models; open-source vs proprietary; cost vs quality.",
|
| 112 |
+
"Week 4 – Prompt Engineering: core principles; zero/few-shot; CoT; ReAct.",
|
| 113 |
+
"Week 5 – Building a Simple Chatbot; memory (short vs long term); LangChain & UI.",
|
| 114 |
+
"Week 6 – Review Week; cross-module consolidation & self-check prompts.",
|
| 115 |
+
"Week 7 – Retrieval-Augmented Generation (RAG); embeddings; hybrid retrieval.",
|
| 116 |
+
"Week 8 – Agents & Agentic RAG; planning, tools, knowledge augmentation.",
|
| 117 |
+
"Week 9 – Evaluating GenAI Apps; hallucination, bias/fairness, metrics.",
|
| 118 |
+
"Week 10 – Responsible AI; risks, governance, EU AI Act-style ideas.",
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
# ============================
|
| 122 |
+
# Learning modes
|
| 123 |
+
# ============================
|
| 124 |
+
|
| 125 |
+
LEARNING_MODES: List[str] = [
|
| 126 |
+
"Concept Explainer",
|
| 127 |
+
"Socratic Tutor",
|
| 128 |
+
"Exam Prep / Quiz",
|
| 129 |
+
"Assignment Helper",
|
| 130 |
+
"Quick Summary",
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
LEARNING_MODE_INSTRUCTIONS: Dict[str, str] = {
|
| 134 |
+
"Concept Explainer": (
|
| 135 |
+
"Explain concepts step by step. Use clear definitions, key formulas or structures, "
|
| 136 |
+
"and one or two simple examples. Focus on clarity over depth. Regularly check if "
|
| 137 |
+
"the student is following."
|
| 138 |
+
),
|
| 139 |
+
"Socratic Tutor": (
|
| 140 |
+
"Use a Socratic style. Ask the student ONE short question at a time, guide them to "
|
| 141 |
+
"reason step by step, and only give full explanations after they try. Prioritize "
|
| 142 |
+
"questions and hints over long lectures."
|
| 143 |
+
),
|
| 144 |
+
"Exam Prep / Quiz": (
|
| 145 |
+
"Behave like an exam prep coach. Often propose short quiz-style questions "
|
| 146 |
+
"(multiple choice or short answer), then explain the solutions clearly. Emphasize "
|
| 147 |
+
"common traps and how to avoid them."
|
| 148 |
+
),
|
| 149 |
+
"Assignment Helper": (
|
| 150 |
+
"Help with assignments WITHOUT giving full final solutions. Clarify requirements, "
|
| 151 |
+
"break tasks into smaller steps, and provide hints, partial examples, or pseudo-code "
|
| 152 |
+
"instead of complete code or final answers. Encourage the student to attempt each "
|
| 153 |
+
"step before revealing more."
|
| 154 |
+
),
|
| 155 |
+
"Quick Summary": (
|
| 156 |
+
"Provide concise, bullet-point style summaries and cheat-sheet style notes. "
|
| 157 |
+
"Focus on key ideas and avoid long paragraphs."
|
| 158 |
+
),
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# ============================
|
| 162 |
+
# Upload doc types
|
| 163 |
+
# ============================
|
| 164 |
+
|
| 165 |
+
DOC_TYPES: List[str] = [
|
| 166 |
+
"Syllabus",
|
| 167 |
+
"Lecture Slides / PPT",
|
| 168 |
+
"Literature Review / Paper",
|
| 169 |
+
"Other Course Document",
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
# ============================
|
| 173 |
+
# Clare system prompt
|
| 174 |
+
# ============================
|
| 175 |
+
|
| 176 |
+
CLARE_SYSTEM_PROMPT = """
|
| 177 |
+
You are Clare, an AI teaching assistant for Hanbridge University.
|
| 178 |
+
|
| 179 |
+
Core identity:
|
| 180 |
+
- You are patient, encouraging, and structured like a very good TA.
|
| 181 |
+
- Your UI and responses should be in ENGLISH by default.
|
| 182 |
+
- However, you can understand BOTH English and Chinese, and you may reply in Chinese
|
| 183 |
+
if the student clearly prefers Chinese or asks you to.
|
| 184 |
+
|
| 185 |
+
How to use course materials:
|
| 186 |
+
- The student may upload course documents (PDF/DOCX/PPT/images).
|
| 187 |
+
- You WILL answer using the provided "Relevant excerpts" and any other context supplied to you.
|
| 188 |
+
- Treat the provided excerpts as the primary grounding source when they exist.
|
| 189 |
+
- If the student asks about an uploaded file but you have no relevant excerpts, ask them to be more specific
|
| 190 |
+
(what section/topic to focus on) or ask them to re-upload if needed.
|
| 191 |
+
|
| 192 |
+
General responsibilities:
|
| 193 |
+
1. Help students understand course concepts step by step.
|
| 194 |
+
2. Ask short check-up questions to confirm understanding instead of giving huge long lectures.
|
| 195 |
+
3. When the student seems confused, break content into smaller chunks and use simple language first.
|
| 196 |
+
4. When the student is advanced, you can switch to more technical explanations.
|
| 197 |
+
|
| 198 |
+
Safety and honesty:
|
| 199 |
+
- If you don’t know, say you are not sure and suggest how to verify.
|
| 200 |
+
- Do not fabricate references, exam answers, or grades.
|
| 201 |
+
"""
|
| 202 |
+
|
api/models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/models.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from typing import List, Optional, Literal
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class PersonContact(BaseModel):
|
| 8 |
+
name: str = "N/A"
|
| 9 |
+
email: str = ""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CourseDirectoryItem(BaseModel):
|
| 13 |
+
id: str
|
| 14 |
+
name: str
|
| 15 |
+
instructor: PersonContact = Field(default_factory=PersonContact)
|
| 16 |
+
teachingAssistant: PersonContact = Field(default_factory=PersonContact)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class WorkspaceMember(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
name: str
|
| 22 |
+
email: Optional[str] = ""
|
| 23 |
+
role: Optional[Literal["owner", "member"]] = "member"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class WorkspaceCourseRef(BaseModel):
|
| 27 |
+
id: Optional[str] = None
|
| 28 |
+
name: Optional[str] = None
|
| 29 |
+
instructor: Optional[PersonContact] = None
|
| 30 |
+
teachingAssistant: Optional[PersonContact] = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Workspace(BaseModel):
|
| 34 |
+
id: str
|
| 35 |
+
type: Literal["individual", "group"]
|
| 36 |
+
category: Optional[Literal["course", "personal"]] = None
|
| 37 |
+
|
| 38 |
+
# ✅ Group Name:前端要编辑的 groupName 就用 name
|
| 39 |
+
name: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
# ✅ Group #
|
| 42 |
+
groupNo: Optional[int] = None
|
| 43 |
+
|
| 44 |
+
# ✅ Members
|
| 45 |
+
members: List[WorkspaceMember] = Field(default_factory=list)
|
| 46 |
+
|
| 47 |
+
# ✅ Course binding
|
| 48 |
+
courseId: Optional[str] = None
|
| 49 |
+
courseInfo: Optional[WorkspaceCourseRef] = None
|
api/rag_engine.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/rag_engine.py
|
| 2 |
+
"""
|
| 3 |
+
RAG engine with vector database support:
|
| 4 |
+
- build_rag_chunks_from_file(path, doc_type) -> List[chunk] (with embeddings)
|
| 5 |
+
- retrieve_relevant_chunks(query, chunks, ...) -> (context_text, used_chunks)
|
| 6 |
+
- Uses FAISS vector similarity + token overlap rerank
|
| 7 |
+
|
| 8 |
+
Chunk format (enhanced):
|
| 9 |
+
{
|
| 10 |
+
"text": str,
|
| 11 |
+
"source_file": str,
|
| 12 |
+
"section": str,
|
| 13 |
+
"doc_type": str,
|
| 14 |
+
"embedding": Optional[List[float]] # NEW: OpenAI embedding vector
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
PDF parsing:
|
| 18 |
+
- Priority: unstructured.io (better quality)
|
| 19 |
+
- Fallback: pypdf (if unstructured fails)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import re
|
| 24 |
+
import math
|
| 25 |
+
from typing import Dict, List, Tuple, Optional, Any
|
| 26 |
+
|
| 27 |
+
# Legacy parsers (fallback)
|
| 28 |
+
from pypdf import PdfReader
|
| 29 |
+
from docx import Document
|
| 30 |
+
from pptx import Presentation
|
| 31 |
+
|
| 32 |
+
# Embedding & vector DB
|
| 33 |
+
from .config import client, EMBEDDING_MODEL
|
| 34 |
+
from .clare_core import cosine_similarity
|
| 35 |
+
|
| 36 |
+
# ============================
|
| 37 |
+
# Optional: Better PDF parsing (unstructured.io)
|
| 38 |
+
# ============================
|
| 39 |
+
def _safe_import_unstructured():
|
| 40 |
+
try:
|
| 41 |
+
from unstructured.partition.auto import partition
|
| 42 |
+
return partition
|
| 43 |
+
except Exception:
|
| 44 |
+
try:
|
| 45 |
+
# Fallback to older API
|
| 46 |
+
from unstructured.partition.pdf import partition_pdf
|
| 47 |
+
return partition_pdf
|
| 48 |
+
except Exception:
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ============================
|
| 53 |
+
# Optional: FAISS vector database
|
| 54 |
+
# ============================
|
| 55 |
+
def _safe_import_faiss():
|
| 56 |
+
try:
|
| 57 |
+
import faiss # type: ignore
|
| 58 |
+
return faiss
|
| 59 |
+
except Exception:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ============================
|
| 64 |
+
# Token helpers (optional tiktoken)
|
| 65 |
+
# ============================
|
| 66 |
+
def _safe_import_tiktoken():
|
| 67 |
+
try:
|
| 68 |
+
import tiktoken # type: ignore
|
| 69 |
+
return tiktoken
|
| 70 |
+
except Exception:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _approx_tokens(text: str) -> int:
|
| 75 |
+
if not text:
|
| 76 |
+
return 0
|
| 77 |
+
return max(1, int(len(text) / 4))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _count_text_tokens(text: str, model: str = "") -> int:
|
| 81 |
+
tk = _safe_import_tiktoken()
|
| 82 |
+
if tk is None:
|
| 83 |
+
return _approx_tokens(text)
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
enc = tk.encoding_for_model(model) if model else tk.get_encoding("cl100k_base")
|
| 87 |
+
except Exception:
|
| 88 |
+
enc = tk.get_encoding("cl100k_base")
|
| 89 |
+
|
| 90 |
+
return len(enc.encode(text or ""))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _truncate_to_tokens(text: str, max_tokens: int, model: str = "") -> str:
|
| 94 |
+
"""Deterministic truncation. Uses tiktoken if available; otherwise approximates by char ratio."""
|
| 95 |
+
if not text:
|
| 96 |
+
return text
|
| 97 |
+
|
| 98 |
+
tk = _safe_import_tiktoken()
|
| 99 |
+
if tk is None:
|
| 100 |
+
total = _approx_tokens(text)
|
| 101 |
+
if total <= max_tokens:
|
| 102 |
+
return text
|
| 103 |
+
ratio = max_tokens / max(1, total)
|
| 104 |
+
cut = max(50, min(len(text), int(len(text) * ratio)))
|
| 105 |
+
s = text[:cut]
|
| 106 |
+
while _approx_tokens(s) > max_tokens and len(s) > 50:
|
| 107 |
+
s = s[: int(len(s) * 0.9)]
|
| 108 |
+
return s
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
enc = tk.encoding_for_model(model) if model else tk.get_encoding("cl100k_base")
|
| 112 |
+
except Exception:
|
| 113 |
+
enc = tk.get_encoding("cl100k_base")
|
| 114 |
+
|
| 115 |
+
ids = enc.encode(text or "")
|
| 116 |
+
if len(ids) <= max_tokens:
|
| 117 |
+
return text
|
| 118 |
+
return enc.decode(ids[:max_tokens])
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ============================
|
| 122 |
+
# RAG hard limits
|
| 123 |
+
# ============================
|
| 124 |
+
RAG_TOPK_LIMIT = 4
|
| 125 |
+
RAG_CHUNK_TOKEN_LIMIT = 500
|
| 126 |
+
RAG_CONTEXT_TOKEN_LIMIT = 2000 # 4 * 500
|
| 127 |
+
|
| 128 |
+
# Embedding dimension for text-embedding-3-small
|
| 129 |
+
EMBEDDING_DIM = 1536
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ----------------------------
|
| 133 |
+
# Helpers
|
| 134 |
+
# ----------------------------
|
| 135 |
+
def _clean_text(s: str) -> str:
|
| 136 |
+
s = (s or "").replace("\r", "\n")
|
| 137 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 138 |
+
return s.strip()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _split_into_chunks(text: str, max_chars: int = 1400) -> List[str]:
|
| 142 |
+
"""
|
| 143 |
+
Simple deterministic chunker:
|
| 144 |
+
- split by blank lines
|
| 145 |
+
- then pack into <= max_chars
|
| 146 |
+
"""
|
| 147 |
+
text = _clean_text(text)
|
| 148 |
+
if not text:
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 152 |
+
chunks: List[str] = []
|
| 153 |
+
buf = ""
|
| 154 |
+
|
| 155 |
+
for p in paras:
|
| 156 |
+
if not buf:
|
| 157 |
+
buf = p
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
if len(buf) + 2 + len(p) <= max_chars:
|
| 161 |
+
buf = buf + "\n\n" + p
|
| 162 |
+
else:
|
| 163 |
+
chunks.append(buf)
|
| 164 |
+
buf = p
|
| 165 |
+
|
| 166 |
+
if buf:
|
| 167 |
+
chunks.append(buf)
|
| 168 |
+
|
| 169 |
+
return chunks
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _file_label(path: str) -> str:
|
| 173 |
+
return os.path.basename(path) if path else "uploaded_file"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _basename(x: str) -> str:
|
| 177 |
+
try:
|
| 178 |
+
return os.path.basename(x or "")
|
| 179 |
+
except Exception:
|
| 180 |
+
return x or ""
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ----------------------------
|
| 184 |
+
# Embedding generation
|
| 185 |
+
# ----------------------------
|
| 186 |
+
def get_chunk_embedding(text: str) -> Optional[List[float]]:
|
| 187 |
+
"""Generate embedding for a chunk using OpenAI text-embedding-3-small."""
|
| 188 |
+
if not text or not text.strip():
|
| 189 |
+
return None
|
| 190 |
+
try:
|
| 191 |
+
resp = client.embeddings.create(
|
| 192 |
+
model=EMBEDDING_MODEL,
|
| 193 |
+
input=[text.strip()],
|
| 194 |
+
)
|
| 195 |
+
return resp.data[0].embedding
|
| 196 |
+
except Exception as e:
|
| 197 |
+
print(f"[rag_engine] embedding error: {repr(e)}")
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def get_chunk_embeddings_batch(texts: List[str], batch_size: int = 100) -> List[Optional[List[float]]]:
|
| 202 |
+
"""
|
| 203 |
+
Generate embeddings for multiple chunks in batches (more efficient than individual calls).
|
| 204 |
+
OpenAI API supports up to 2048 inputs per request, but we use smaller batches for reliability.
|
| 205 |
+
"""
|
| 206 |
+
if not texts:
|
| 207 |
+
return []
|
| 208 |
+
|
| 209 |
+
results: List[Optional[List[float]]] = []
|
| 210 |
+
|
| 211 |
+
for i in range(0, len(texts), batch_size):
|
| 212 |
+
batch = [t.strip() for t in texts[i:i + batch_size] if t and t.strip()]
|
| 213 |
+
if not batch:
|
| 214 |
+
results.extend([None] * (i + batch_size - len(results)))
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
resp = client.embeddings.create(
|
| 219 |
+
model=EMBEDDING_MODEL,
|
| 220 |
+
input=batch,
|
| 221 |
+
)
|
| 222 |
+
batch_results = [item.embedding for item in resp.data]
|
| 223 |
+
results.extend(batch_results)
|
| 224 |
+
except Exception as e:
|
| 225 |
+
print(f"[rag_engine] batch embedding error: {repr(e)}")
|
| 226 |
+
results.extend([None] * len(batch))
|
| 227 |
+
|
| 228 |
+
return results
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ----------------------------
|
| 232 |
+
# Enhanced PDF parsing (unstructured.io + fallback)
|
| 233 |
+
# ----------------------------
|
| 234 |
+
def _parse_pdf_to_text(path: str) -> List[Tuple[str, str]]:
|
| 235 |
+
"""
|
| 236 |
+
Returns list of (section_label, text)
|
| 237 |
+
Priority: unstructured.io (better quality)
|
| 238 |
+
Fallback: pypdf
|
| 239 |
+
"""
|
| 240 |
+
partition_func = _safe_import_unstructured()
|
| 241 |
+
|
| 242 |
+
# Try unstructured.io first
|
| 243 |
+
if partition_func is not None:
|
| 244 |
+
try:
|
| 245 |
+
# Try new API first (partition function)
|
| 246 |
+
if hasattr(partition_func, '__name__') and partition_func.__name__ == 'partition':
|
| 247 |
+
elements = partition_func(filename=path)
|
| 248 |
+
else:
|
| 249 |
+
# Old API (partition_pdf)
|
| 250 |
+
elements = partition_func(filename=path)
|
| 251 |
+
|
| 252 |
+
text_parts: List[str] = []
|
| 253 |
+
for elem in elements:
|
| 254 |
+
if hasattr(elem, "text") and elem.text:
|
| 255 |
+
text_parts.append(str(elem.text).strip())
|
| 256 |
+
if text_parts:
|
| 257 |
+
full_text = "\n\n".join(text_parts)
|
| 258 |
+
full_text = _clean_text(full_text)
|
| 259 |
+
if full_text:
|
| 260 |
+
return [("pdf_unstructured", full_text)]
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(f"[rag_engine] unstructured.io parse failed, fallback to pypdf: {repr(e)}")
|
| 263 |
+
|
| 264 |
+
# Fallback: pypdf
|
| 265 |
+
try:
|
| 266 |
+
reader = PdfReader(path)
|
| 267 |
+
out: List[Tuple[str, str]] = []
|
| 268 |
+
for i, page in enumerate(reader.pages):
|
| 269 |
+
t = page.extract_text() or ""
|
| 270 |
+
t = _clean_text(t)
|
| 271 |
+
if t:
|
| 272 |
+
out.append((f"p{i+1}", t))
|
| 273 |
+
return out
|
| 274 |
+
except Exception as e:
|
| 275 |
+
print(f"[rag_engine] pypdf parse error: {repr(e)}")
|
| 276 |
+
return []
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _parse_docx_to_text(path: str) -> List[Tuple[str, str]]:
|
| 280 |
+
doc = Document(path)
|
| 281 |
+
paras = [p.text.strip() for p in doc.paragraphs if p.text and p.text.strip()]
|
| 282 |
+
if not paras:
|
| 283 |
+
return []
|
| 284 |
+
full = "\n\n".join(paras)
|
| 285 |
+
return [("docx", _clean_text(full))]
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _parse_pptx_to_text(path: str) -> List[Tuple[str, str]]:
|
| 289 |
+
prs = Presentation(path)
|
| 290 |
+
out: List[Tuple[str, str]] = []
|
| 291 |
+
for idx, slide in enumerate(prs.slides, start=1):
|
| 292 |
+
lines: List[str] = []
|
| 293 |
+
for shape in slide.shapes:
|
| 294 |
+
if hasattr(shape, "text") and shape.text:
|
| 295 |
+
txt = shape.text.strip()
|
| 296 |
+
if txt:
|
| 297 |
+
lines.append(txt)
|
| 298 |
+
if lines:
|
| 299 |
+
out.append((f"slide{idx}", _clean_text("\n".join(lines))))
|
| 300 |
+
return out
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ----------------------------
|
| 304 |
+
# Vector database (FAISS) wrapper
|
| 305 |
+
# ----------------------------
|
| 306 |
+
class VectorStore:
|
| 307 |
+
"""Simple in-memory vector store using FAISS (or fallback to list-based cosine similarity)."""
|
| 308 |
+
|
| 309 |
+
def __init__(self):
|
| 310 |
+
self.faiss = _safe_import_faiss()
|
| 311 |
+
self.index = None
|
| 312 |
+
self.chunks: List[Dict] = []
|
| 313 |
+
self.use_faiss = False
|
| 314 |
+
|
| 315 |
+
def build_index(self, chunks: List[Dict]):
|
| 316 |
+
"""Build FAISS index from chunks with embeddings."""
|
| 317 |
+
self.chunks = chunks or []
|
| 318 |
+
if not self.chunks:
|
| 319 |
+
return
|
| 320 |
+
|
| 321 |
+
# Filter chunks that have embeddings
|
| 322 |
+
chunks_with_emb = [c for c in self.chunks if c.get("embedding") is not None]
|
| 323 |
+
if not chunks_with_emb:
|
| 324 |
+
print("[rag_engine] No chunks with embeddings, using token-based retrieval")
|
| 325 |
+
return
|
| 326 |
+
|
| 327 |
+
if self.faiss is None:
|
| 328 |
+
print("[rag_engine] FAISS not available, using list-based cosine similarity")
|
| 329 |
+
return
|
| 330 |
+
|
| 331 |
+
try:
|
| 332 |
+
dim = len(chunks_with_emb[0]["embedding"])
|
| 333 |
+
# Use L2 (Euclidean) index for FAISS
|
| 334 |
+
self.index = self.faiss.IndexFlatL2(dim)
|
| 335 |
+
embeddings = [c["embedding"] for c in chunks_with_emb]
|
| 336 |
+
import numpy as np
|
| 337 |
+
vectors = np.array(embeddings, dtype=np.float32)
|
| 338 |
+
self.index.add(vectors)
|
| 339 |
+
self.use_faiss = True
|
| 340 |
+
print(f"[rag_engine] Built FAISS index with {len(chunks_with_emb)} vectors")
|
| 341 |
+
except Exception as e:
|
| 342 |
+
print(f"[rag_engine] FAISS index build failed: {repr(e)}, using list-based")
|
| 343 |
+
self.use_faiss = False
|
| 344 |
+
|
| 345 |
+
def search(self, query_embedding: List[float], k: int) -> List[Tuple[float, Dict]]:
|
| 346 |
+
"""
|
| 347 |
+
Search top-k chunks by vector similarity.
|
| 348 |
+
Returns: List[(similarity_score, chunk_dict)]
|
| 349 |
+
"""
|
| 350 |
+
if not query_embedding or not self.chunks:
|
| 351 |
+
return []
|
| 352 |
+
|
| 353 |
+
chunks_with_emb = [c for c in self.chunks if c.get("embedding") is not None]
|
| 354 |
+
if not chunks_with_emb:
|
| 355 |
+
return []
|
| 356 |
+
|
| 357 |
+
if self.use_faiss and self.index is not None:
|
| 358 |
+
try:
|
| 359 |
+
import numpy as np
|
| 360 |
+
query_vec = np.array([query_embedding], dtype=np.float32)
|
| 361 |
+
distances, indices = self.index.search(query_vec, min(k, len(chunks_with_emb)))
|
| 362 |
+
results: List[Tuple[float, Dict]] = []
|
| 363 |
+
for dist, idx in zip(distances[0], indices[0]):
|
| 364 |
+
if idx < len(chunks_with_emb):
|
| 365 |
+
# Convert L2 distance to similarity (1 / (1 + distance))
|
| 366 |
+
similarity = 1.0 / (1.0 + float(dist))
|
| 367 |
+
results.append((similarity, chunks_with_emb[idx]))
|
| 368 |
+
return results
|
| 369 |
+
except Exception as e:
|
| 370 |
+
print(f"[rag_engine] FAISS search error: {repr(e)}, fallback to list-based")
|
| 371 |
+
|
| 372 |
+
# Fallback: list-based cosine similarity
|
| 373 |
+
results: List[Tuple[float, Dict]] = []
|
| 374 |
+
for chunk in chunks_with_emb:
|
| 375 |
+
emb = chunk.get("embedding")
|
| 376 |
+
if emb:
|
| 377 |
+
sim = cosine_similarity(query_embedding, emb)
|
| 378 |
+
results.append((sim, chunk))
|
| 379 |
+
results.sort(key=lambda x: x[0], reverse=True)
|
| 380 |
+
return results[:k]
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# ----------------------------
|
| 384 |
+
# Public API
|
| 385 |
+
# ----------------------------
|
| 386 |
+
def build_rag_chunks_from_file(path: str, doc_type: str, generate_embeddings: bool = True) -> List[Dict]:
|
| 387 |
+
"""
|
| 388 |
+
Build RAG chunks from a local file path.
|
| 389 |
+
Supports: .pdf / .docx / .pptx / .txt
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
path: File path
|
| 393 |
+
doc_type: Document type
|
| 394 |
+
generate_embeddings: If True, generate embeddings for each chunk (default: True)
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
List of chunk dicts with optional "embedding" field
|
| 398 |
+
"""
|
| 399 |
+
if not path or not os.path.exists(path):
|
| 400 |
+
return []
|
| 401 |
+
|
| 402 |
+
ext = os.path.splitext(path)[1].lower()
|
| 403 |
+
source_file = _file_label(path)
|
| 404 |
+
|
| 405 |
+
sections: List[Tuple[str, str]] = []
|
| 406 |
+
try:
|
| 407 |
+
if ext == ".pdf":
|
| 408 |
+
sections = _parse_pdf_to_text(path)
|
| 409 |
+
elif ext == ".docx":
|
| 410 |
+
sections = _parse_docx_to_text(path)
|
| 411 |
+
elif ext == ".pptx":
|
| 412 |
+
sections = _parse_pptx_to_text(path)
|
| 413 |
+
elif ext in [".txt", ".md"]:
|
| 414 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
| 415 |
+
sections = [("text", _clean_text(f.read()))]
|
| 416 |
+
else:
|
| 417 |
+
print(f"[rag_engine] unsupported file type: {ext}")
|
| 418 |
+
return []
|
| 419 |
+
except Exception as e:
|
| 420 |
+
print(f"[rag_engine] parse error for {source_file}: {repr(e)}")
|
| 421 |
+
return []
|
| 422 |
+
|
| 423 |
+
chunks: List[Dict] = []
|
| 424 |
+
chunk_texts: List[str] = []
|
| 425 |
+
|
| 426 |
+
# First, build all chunks without embeddings
|
| 427 |
+
for section, text in sections:
|
| 428 |
+
for j, piece in enumerate(_split_into_chunks(text), start=1):
|
| 429 |
+
chunk: Dict[str, Any] = {
|
| 430 |
+
"text": piece,
|
| 431 |
+
"source_file": source_file,
|
| 432 |
+
"section": f"{section}#{j}",
|
| 433 |
+
"doc_type": doc_type,
|
| 434 |
+
}
|
| 435 |
+
chunks.append(chunk)
|
| 436 |
+
if generate_embeddings:
|
| 437 |
+
chunk_texts.append(piece)
|
| 438 |
+
|
| 439 |
+
# Generate embeddings in batch (much faster than individual calls)
|
| 440 |
+
if generate_embeddings and chunk_texts:
|
| 441 |
+
embeddings = get_chunk_embeddings_batch(chunk_texts, batch_size=100)
|
| 442 |
+
for chunk, embedding in zip(chunks, embeddings):
|
| 443 |
+
if embedding:
|
| 444 |
+
chunk["embedding"] = embedding
|
| 445 |
+
|
| 446 |
+
return chunks
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def retrieve_relevant_chunks(
|
| 450 |
+
query: str,
|
| 451 |
+
chunks: List[Dict],
|
| 452 |
+
k: int = RAG_TOPK_LIMIT,
|
| 453 |
+
max_context_chars: int = 600,
|
| 454 |
+
min_score: int = 6,
|
| 455 |
+
chunk_token_limit: int = RAG_CHUNK_TOKEN_LIMIT,
|
| 456 |
+
max_context_tokens: int = RAG_CONTEXT_TOKEN_LIMIT,
|
| 457 |
+
model_for_tokenizer: str = "",
|
| 458 |
+
allowed_source_files: Optional[List[str]] = None,
|
| 459 |
+
allowed_doc_types: Optional[List[str]] = None,
|
| 460 |
+
use_vector_search: bool = True, # NEW: enable/disable vector search
|
| 461 |
+
vector_similarity_threshold: float = 0.7, # Minimum cosine similarity for vector results
|
| 462 |
+
) -> Tuple[str, List[Dict]]:
|
| 463 |
+
"""
|
| 464 |
+
Enhanced retrieval with vector similarity + token overlap rerank.
|
| 465 |
+
|
| 466 |
+
Strategy:
|
| 467 |
+
1. If use_vector_search=True and chunks have embeddings:
|
| 468 |
+
- Generate query embedding
|
| 469 |
+
- Use FAISS/list-based vector similarity to get candidate chunks
|
| 470 |
+
- Rerank by token overlap
|
| 471 |
+
2. Else: fallback to token-based retrieval (backward compatible)
|
| 472 |
+
|
| 473 |
+
Args:
|
| 474 |
+
use_vector_search: Enable vector similarity search (default: True)
|
| 475 |
+
vector_similarity_threshold: Minimum cosine similarity for vector results (default: 0.7)
|
| 476 |
+
"""
|
| 477 |
+
query = _clean_text(query)
|
| 478 |
+
if not query or not chunks:
|
| 479 |
+
return "", []
|
| 480 |
+
|
| 481 |
+
# ----------------------------
|
| 482 |
+
# Apply scoping BEFORE scoring
|
| 483 |
+
# ----------------------------
|
| 484 |
+
filtered = chunks or []
|
| 485 |
+
|
| 486 |
+
if allowed_source_files:
|
| 487 |
+
allow_files = {_basename(str(x)).strip() for x in allowed_source_files if str(x).strip()}
|
| 488 |
+
if allow_files:
|
| 489 |
+
filtered = [
|
| 490 |
+
c
|
| 491 |
+
for c in filtered
|
| 492 |
+
if _basename(str(c.get("source_file", ""))).strip() in allow_files
|
| 493 |
+
]
|
| 494 |
+
|
| 495 |
+
if allowed_doc_types:
|
| 496 |
+
allow_dt = {str(x).strip() for x in allowed_doc_types if str(x).strip()}
|
| 497 |
+
if allow_dt:
|
| 498 |
+
filtered = [c for c in filtered if str(c.get("doc_type", "")).strip() in allow_dt]
|
| 499 |
+
|
| 500 |
+
if not filtered:
|
| 501 |
+
return "", []
|
| 502 |
+
|
| 503 |
+
# Short query gate
|
| 504 |
+
q_tokens_list = re.findall(r"[a-zA-Z0-9]+", query.lower())
|
| 505 |
+
if (len(q_tokens_list) < 3) and (len(query) < 20):
|
| 506 |
+
return "", []
|
| 507 |
+
|
| 508 |
+
q_tokens = set(q_tokens_list)
|
| 509 |
+
if not q_tokens:
|
| 510 |
+
return "", []
|
| 511 |
+
|
| 512 |
+
# ----------------------------
|
| 513 |
+
# Vector search path (if enabled and embeddings available)
|
| 514 |
+
# ----------------------------
|
| 515 |
+
chunks_with_emb = [c for c in filtered if c.get("embedding") is not None]
|
| 516 |
+
|
| 517 |
+
if use_vector_search and chunks_with_emb:
|
| 518 |
+
try:
|
| 519 |
+
query_emb = get_chunk_embedding(query)
|
| 520 |
+
if query_emb:
|
| 521 |
+
# Build vector store and search
|
| 522 |
+
store = VectorStore()
|
| 523 |
+
store.build_index(chunks_with_emb)
|
| 524 |
+
vector_results = store.search(query_emb, k=k * 2) # Get 2x candidates for rerank
|
| 525 |
+
|
| 526 |
+
# Filter by similarity threshold
|
| 527 |
+
candidates: List[Tuple[float, Dict]] = []
|
| 528 |
+
for sim_score, chunk in vector_results:
|
| 529 |
+
if float(sim_score) >= vector_similarity_threshold:
|
| 530 |
+
candidates.append((float(sim_score), chunk))
|
| 531 |
+
|
| 532 |
+
if candidates:
|
| 533 |
+
# Rerank by token overlap
|
| 534 |
+
scored: List[Tuple[float, Dict]] = []
|
| 535 |
+
for sim_score, c in candidates:
|
| 536 |
+
text = (c.get("text") or "")
|
| 537 |
+
if not text:
|
| 538 |
+
continue
|
| 539 |
+
t_tokens = set(re.findall(r"[a-zA-Z0-9]+", text.lower()))
|
| 540 |
+
token_score = len(q_tokens.intersection(t_tokens))
|
| 541 |
+
token_ratio = min(1.0, float(token_score) / max(1, len(q_tokens)))
|
| 542 |
+
# Combined score: 70% vector similarity + 30% token overlap (normalized)
|
| 543 |
+
combined_score = 0.7 * float(sim_score) + 0.3 * token_ratio
|
| 544 |
+
|
| 545 |
+
c2 = dict(c)
|
| 546 |
+
c2["_rag_vector_sim"] = float(sim_score)
|
| 547 |
+
c2["_rag_token_overlap"] = int(token_score)
|
| 548 |
+
c2["_rag_token_overlap_ratio"] = float(token_ratio)
|
| 549 |
+
c2["_rag_score"] = float(combined_score)
|
| 550 |
+
scored.append((combined_score, c2))
|
| 551 |
+
|
| 552 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 553 |
+
top = [c for _, c in scored[:k]]
|
| 554 |
+
else:
|
| 555 |
+
# Vector search found nothing above threshold, fallback to token
|
| 556 |
+
top = []
|
| 557 |
+
else:
|
| 558 |
+
top = []
|
| 559 |
+
except Exception as e:
|
| 560 |
+
print(f"[rag_engine] vector search error: {repr(e)}, fallback to token-based")
|
| 561 |
+
top = []
|
| 562 |
+
else:
|
| 563 |
+
top = []
|
| 564 |
+
|
| 565 |
+
# If vector search returns unrelated chunks (e.g. zero token overlap), treat as no-hit and fallback.
|
| 566 |
+
if top:
|
| 567 |
+
doc_hint_tokens = {
|
| 568 |
+
"module", "week", "lab", "assignment", "syllabus", "lecture", "slide", "ppt", "pdf", "docx",
|
| 569 |
+
"课程", "模块", "周", "实验", "作业", "讲义", "课件", "大纲", "论文",
|
| 570 |
+
}
|
| 571 |
+
looks_like_course_query = any(t in query.lower() for t in doc_hint_tokens)
|
| 572 |
+
best_overlap = max(int(c.get("_rag_token_overlap", 0)) for c in top)
|
| 573 |
+
best_score = max(float(c.get("_rag_score", 0.0)) for c in top)
|
| 574 |
+
if (not looks_like_course_query and best_overlap <= 0) or best_score < 0.35:
|
| 575 |
+
top = []
|
| 576 |
+
|
| 577 |
+
# ----------------------------
|
| 578 |
+
# Fallback: token-based retrieval (if vector search failed or disabled)
|
| 579 |
+
# ----------------------------
|
| 580 |
+
if not top:
|
| 581 |
+
scored: List[Tuple[int, Dict]] = []
|
| 582 |
+
for c in filtered:
|
| 583 |
+
text = (c.get("text") or "")
|
| 584 |
+
if not text:
|
| 585 |
+
continue
|
| 586 |
+
t_tokens = set(re.findall(r"[a-zA-Z0-9]+", text.lower()))
|
| 587 |
+
score = len(q_tokens.intersection(t_tokens))
|
| 588 |
+
if score >= min_score:
|
| 589 |
+
scored.append((score, c))
|
| 590 |
+
|
| 591 |
+
if not scored:
|
| 592 |
+
return "", []
|
| 593 |
+
|
| 594 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 595 |
+
k_actual = min(int(k or RAG_TOPK_LIMIT), RAG_TOPK_LIMIT)
|
| 596 |
+
top = [c for _, c in scored[:k_actual]]
|
| 597 |
+
|
| 598 |
+
if not top:
|
| 599 |
+
return "", []
|
| 600 |
+
|
| 601 |
+
# ----------------------------
|
| 602 |
+
# Truncate and format context
|
| 603 |
+
# ----------------------------
|
| 604 |
+
used: List[Dict] = []
|
| 605 |
+
truncated_texts: List[str] = []
|
| 606 |
+
total_tokens = 0
|
| 607 |
+
|
| 608 |
+
for c in top:
|
| 609 |
+
raw = c.get("text") or ""
|
| 610 |
+
if not raw:
|
| 611 |
+
continue
|
| 612 |
+
|
| 613 |
+
t = _truncate_to_tokens(raw, max_tokens=chunk_token_limit, model=model_for_tokenizer)
|
| 614 |
+
|
| 615 |
+
t_tokens = _count_text_tokens(t, model=model_for_tokenizer)
|
| 616 |
+
if total_tokens + t_tokens > max_context_tokens:
|
| 617 |
+
remaining = max_context_tokens - total_tokens
|
| 618 |
+
if remaining <= 0:
|
| 619 |
+
break
|
| 620 |
+
t = _truncate_to_tokens(t, max_tokens=remaining, model=model_for_tokenizer)
|
| 621 |
+
t_tokens = _count_text_tokens(t, model=model_for_tokenizer)
|
| 622 |
+
|
| 623 |
+
if max_context_chars and max_context_chars > 0:
|
| 624 |
+
current_chars = sum(len(x) for x in truncated_texts)
|
| 625 |
+
if current_chars + len(t) > max_context_chars:
|
| 626 |
+
t = t[: max(0, max_context_chars - current_chars)]
|
| 627 |
+
|
| 628 |
+
t = _clean_text(t)
|
| 629 |
+
if not t:
|
| 630 |
+
continue
|
| 631 |
+
|
| 632 |
+
truncated_texts.append(t)
|
| 633 |
+
used.append(c)
|
| 634 |
+
total_tokens += t_tokens
|
| 635 |
+
|
| 636 |
+
if total_tokens >= max_context_tokens:
|
| 637 |
+
break
|
| 638 |
+
|
| 639 |
+
if not truncated_texts:
|
| 640 |
+
return "", []
|
| 641 |
+
|
| 642 |
+
context = "\n\n---\n\n".join(truncated_texts)
|
| 643 |
+
return context, used
|
api/routes_directory.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/routes_directory.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
from api.store import load_courses, load_workspaces, enrich_workspace_course_info, rename_workspace
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/api/course-directory")
|
| 12 |
+
def get_course_directory():
|
| 13 |
+
courses = load_courses()
|
| 14 |
+
return {"items": [c.model_dump() for c in courses]}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/api/workspaces")
|
| 18 |
+
def get_workspaces():
|
| 19 |
+
courses = load_courses()
|
| 20 |
+
workspaces = load_workspaces()
|
| 21 |
+
workspaces = enrich_workspace_course_info(workspaces, courses)
|
| 22 |
+
return {"items": [w.model_dump() for w in workspaces]}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class RenameWorkspaceBody(BaseModel):
|
| 26 |
+
name: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/api/workspaces/{workspace_id}/rename")
|
| 30 |
+
def post_rename_workspace(workspace_id: str, body: RenameWorkspaceBody):
|
| 31 |
+
name = (body.name or "").strip()
|
| 32 |
+
if not name:
|
| 33 |
+
raise HTTPException(status_code=400, detail="name is required")
|
| 34 |
+
try:
|
| 35 |
+
w = rename_workspace(workspace_id, name)
|
| 36 |
+
return {"ok": True, "workspace": w.model_dump()}
|
| 37 |
+
except KeyError as e:
|
| 38 |
+
raise HTTPException(status_code=404, detail=str(e))
|
api/server.py
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/server.py
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import threading
|
| 5 |
+
from typing import Dict, List, Optional, Any, Tuple
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, UploadFile, File, Form, Request
|
| 8 |
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
|
| 13 |
+
from api.config import DEFAULT_COURSE_TOPICS, DEFAULT_MODEL
|
| 14 |
+
from api.syllabus_utils import extract_course_topics_from_file
|
| 15 |
+
from api.rag_engine import build_rag_chunks_from_file, retrieve_relevant_chunks
|
| 16 |
+
from api.clare_core import (
|
| 17 |
+
detect_language,
|
| 18 |
+
chat_with_clare,
|
| 19 |
+
update_weaknesses_from_message,
|
| 20 |
+
update_cognitive_state_from_message,
|
| 21 |
+
render_session_status,
|
| 22 |
+
export_conversation,
|
| 23 |
+
summarize_conversation,
|
| 24 |
+
)
|
| 25 |
+
from api.tts_podcast import (
|
| 26 |
+
text_to_speech,
|
| 27 |
+
build_podcast_script_from_history,
|
| 28 |
+
build_podcast_script_from_summary,
|
| 29 |
+
generate_podcast_audio,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# ✅ NEW: course directory + workspace schema routes
|
| 33 |
+
from api.routes_directory import router as directory_router
|
| 34 |
+
|
| 35 |
+
# ✅ LangSmith (optional)
|
| 36 |
+
try:
|
| 37 |
+
from langsmith import Client
|
| 38 |
+
except Exception:
|
| 39 |
+
Client = None
|
| 40 |
+
|
| 41 |
+
# ----------------------------
|
| 42 |
+
# Paths / Constants
|
| 43 |
+
# ----------------------------
|
| 44 |
+
API_DIR = os.path.dirname(__file__)
|
| 45 |
+
|
| 46 |
+
MODULE10_PATH = os.path.join(API_DIR, "module10_responsible_ai.pdf")
|
| 47 |
+
MODULE10_DOC_TYPE = "Literature Review / Paper"
|
| 48 |
+
|
| 49 |
+
WEB_DIST = os.path.abspath(os.path.join(API_DIR, "..", "web", "build"))
|
| 50 |
+
WEB_INDEX = os.path.join(WEB_DIST, "index.html")
|
| 51 |
+
WEB_ASSETS = os.path.join(WEB_DIST, "assets")
|
| 52 |
+
|
| 53 |
+
LS_DATASET_NAME = os.getenv("LS_DATASET_NAME", "clare_user_events").strip()
|
| 54 |
+
LS_PROJECT = os.getenv("LANGSMITH_PROJECT", os.getenv("LANGCHAIN_PROJECT", "")).strip()
|
| 55 |
+
|
| 56 |
+
EXPERIMENT_ID = os.getenv("CLARE_EXPERIMENT_ID", "RESP_AI_W10").strip()
|
| 57 |
+
|
| 58 |
+
# 方案三:Clare 调用 GenAICoursesDB 向量知识库。设为 HF Space ID 或完整 URL 时启用
|
| 59 |
+
GENAI_COURSES_SPACE = (os.getenv("GENAI_COURSES_SPACE") or "").strip()
|
| 60 |
+
|
| 61 |
+
# ----------------------------
|
| 62 |
+
# Health / Warmup (cold start mitigation)
|
| 63 |
+
# ----------------------------
|
| 64 |
+
APP_START_TS = time.time()
|
| 65 |
+
|
| 66 |
+
WARMUP_DONE = False
|
| 67 |
+
WARMUP_ERROR: Optional[str] = None
|
| 68 |
+
WARMUP_STARTED = False
|
| 69 |
+
|
| 70 |
+
CLARE_ENABLE_WARMUP = os.getenv("CLARE_ENABLE_WARMUP", "1").strip() == "1"
|
| 71 |
+
CLARE_WARMUP_BLOCK_READY = os.getenv("CLARE_WARMUP_BLOCK_READY", "0").strip() == "1"
|
| 72 |
+
|
| 73 |
+
# Dataset logging (create_example)
|
| 74 |
+
CLARE_ENABLE_LANGSMITH_LOG = os.getenv("CLARE_ENABLE_LANGSMITH_LOG", "0").strip() == "1"
|
| 75 |
+
CLARE_LANGSMITH_ASYNC = os.getenv("CLARE_LANGSMITH_ASYNC", "1").strip() == "1"
|
| 76 |
+
|
| 77 |
+
# Feedback logging (create_feedback -> attach to run_id)
|
| 78 |
+
CLARE_ENABLE_LANGSMITH_FEEDBACK = os.getenv("CLARE_ENABLE_LANGSMITH_FEEDBACK", "1").strip() == "1"
|
| 79 |
+
|
| 80 |
+
# ----------------------------
|
| 81 |
+
# App
|
| 82 |
+
# ----------------------------
|
| 83 |
+
app = FastAPI(title="Clare API")
|
| 84 |
+
|
| 85 |
+
app.add_middleware(
|
| 86 |
+
CORSMiddleware,
|
| 87 |
+
allow_origins=["*"],
|
| 88 |
+
allow_credentials=True,
|
| 89 |
+
allow_methods=["*"],
|
| 90 |
+
allow_headers=["*"],
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# ✅ NEW: include directory/workspace APIs BEFORE SPA fallback
|
| 94 |
+
app.include_router(directory_router)
|
| 95 |
+
|
| 96 |
+
# ----------------------------
|
| 97 |
+
# Static hosting (Vite build)
|
| 98 |
+
# ----------------------------
|
| 99 |
+
if os.path.isdir(WEB_ASSETS):
|
| 100 |
+
app.mount("/assets", StaticFiles(directory=WEB_ASSETS), name="assets")
|
| 101 |
+
|
| 102 |
+
if os.path.isdir(WEB_DIST):
|
| 103 |
+
app.mount("/static", StaticFiles(directory=WEB_DIST), name="static")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@app.get("/")
|
| 107 |
+
def index():
|
| 108 |
+
if os.path.exists(WEB_INDEX):
|
| 109 |
+
return FileResponse(WEB_INDEX)
|
| 110 |
+
return JSONResponse(
|
| 111 |
+
{"detail": "web/build not found. Build frontend first (web/build/index.html)."},
|
| 112 |
+
status_code=500,
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ----------------------------
|
| 117 |
+
# In-memory session store (MVP)
|
| 118 |
+
# ----------------------------
|
| 119 |
+
SESSIONS: Dict[str, Dict[str, Any]] = {}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _preload_module10_chunks() -> List[Dict[str, Any]]:
|
| 123 |
+
if os.path.exists(MODULE10_PATH):
|
| 124 |
+
try:
|
| 125 |
+
return build_rag_chunks_from_file(MODULE10_PATH, MODULE10_DOC_TYPE) or []
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"[preload] module10 parse failed: {repr(e)}")
|
| 128 |
+
return []
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
MODULE10_CHUNKS_CACHE = _preload_module10_chunks()
|
| 133 |
+
|
| 134 |
+
def _get_session(user_id: str) -> Dict[str, Any]:
|
| 135 |
+
if user_id not in SESSIONS:
|
| 136 |
+
SESSIONS[user_id] = {
|
| 137 |
+
"user_id": user_id,
|
| 138 |
+
"name": "",
|
| 139 |
+
"history": [], # List[Tuple[str, str]]
|
| 140 |
+
"weaknesses": [],
|
| 141 |
+
"cognitive_state": {"confusion": 0, "mastery": 0},
|
| 142 |
+
"course_outline": DEFAULT_COURSE_TOPICS,
|
| 143 |
+
"rag_chunks": list(MODULE10_CHUNKS_CACHE),
|
| 144 |
+
"model_name": DEFAULT_MODEL,
|
| 145 |
+
"uploaded_files": [],
|
| 146 |
+
# NEW: profile init (MVP in-memory)
|
| 147 |
+
"profile_bio": "",
|
| 148 |
+
"init_answers": {},
|
| 149 |
+
"init_dismiss_until": 0,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if "uploaded_files" not in SESSIONS[user_id]:
|
| 153 |
+
SESSIONS[user_id]["uploaded_files"] = []
|
| 154 |
+
|
| 155 |
+
# NEW backfill
|
| 156 |
+
SESSIONS[user_id].setdefault("profile_bio", "")
|
| 157 |
+
SESSIONS[user_id].setdefault("init_answers", {})
|
| 158 |
+
SESSIONS[user_id].setdefault("init_dismiss_until", 0)
|
| 159 |
+
|
| 160 |
+
return SESSIONS[user_id]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# NEW: helper to build a deterministic “what files are loaded” hint for the LLM
|
| 165 |
+
def _build_upload_hint(sess: Dict[str, Any]) -> str:
|
| 166 |
+
files = sess.get("uploaded_files") or []
|
| 167 |
+
if not files:
|
| 168 |
+
# Still mention that base reading is available
|
| 169 |
+
return (
|
| 170 |
+
"Files available to you in this session:\n"
|
| 171 |
+
"- Base reading: module10_responsible_ai.pdf (pre-loaded)\n"
|
| 172 |
+
"If the student asks about an uploaded file but none exist, ask them to upload."
|
| 173 |
+
)
|
| 174 |
+
lines = [
|
| 175 |
+
"Files available to you in this session:",
|
| 176 |
+
"- Base reading: module10_responsible_ai.pdf (pre-loaded)",
|
| 177 |
+
]
|
| 178 |
+
# show last few only to keep prompt small
|
| 179 |
+
for f in files[-5:]:
|
| 180 |
+
fn = (f.get("filename") or "").strip()
|
| 181 |
+
dt = (f.get("doc_type") or "").strip()
|
| 182 |
+
chunks = f.get("added_chunks")
|
| 183 |
+
lines.append(f"- Uploaded: {fn} (doc_type={dt}, added_chunks={chunks})")
|
| 184 |
+
lines.append(
|
| 185 |
+
"When the student asks to summarize/read 'the uploaded file', interpret it as the MOST RECENT uploaded file unless specified."
|
| 186 |
+
)
|
| 187 |
+
return "\n".join(lines)
|
| 188 |
+
|
| 189 |
+
# NEW: force RAG on short "document actions" so refs exist
|
| 190 |
+
def _should_force_rag(message: str) -> bool:
|
| 191 |
+
m = (message or "").lower()
|
| 192 |
+
if not m:
|
| 193 |
+
return False
|
| 194 |
+
triggers = [
|
| 195 |
+
"summarize", "summary", "read", "analyze", "explain",
|
| 196 |
+
"the uploaded file", "uploaded", "file", "document", "pdf",
|
| 197 |
+
"slides", "ppt", "syllabus", "lecture",
|
| 198 |
+
"总结", "概括", "阅读", "读一下", "解析", "分析", "这份文件", "上传", "文档", "课件", "讲义",
|
| 199 |
+
]
|
| 200 |
+
return any(t in m for t in triggers)
|
| 201 |
+
|
| 202 |
+
def _retrieve_from_genai_courses(question: str, top_k: int = 5) -> str:
|
| 203 |
+
"""调用 GenAICoursesDB Space 的 retrieve 接口,获取课程检索结果。"""
|
| 204 |
+
if not GENAI_COURSES_SPACE or len(question.strip()) < 5:
|
| 205 |
+
return ""
|
| 206 |
+
try:
|
| 207 |
+
from gradio_client import Client
|
| 208 |
+
client = Client(GENAI_COURSES_SPACE)
|
| 209 |
+
result = client.predict(question, api_name="/retrieve")
|
| 210 |
+
return (result or "").strip()
|
| 211 |
+
except Exception as e:
|
| 212 |
+
print(f"[genai_courses] retrieve failed: {repr(e)}")
|
| 213 |
+
return ""
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _extract_filename_hint(message: str) -> Optional[str]:
|
| 217 |
+
m = (message or "").strip()
|
| 218 |
+
if not m:
|
| 219 |
+
return None
|
| 220 |
+
# 极简:如果用户直接提到了 .pdf/.ppt/.docx 文件名,就用它
|
| 221 |
+
for token in m.replace("“", '"').replace("”", '"').split():
|
| 222 |
+
if any(token.lower().endswith(ext) for ext in [".pdf", ".ppt", ".pptx", ".doc", ".docx"]):
|
| 223 |
+
return os.path.basename(token.strip('"').strip("'").strip())
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _resolve_rag_scope(sess: Dict[str, Any], msg: str) -> Tuple[Optional[List[str]], Optional[List[str]]]:
|
| 228 |
+
"""
|
| 229 |
+
Return (allowed_source_files, allowed_doc_types)
|
| 230 |
+
- If user is asking about "uploaded file"/document action -> restrict to latest uploaded file.
|
| 231 |
+
- If message contains an explicit filename -> restrict to that filename if we have it.
|
| 232 |
+
- Else no restriction (None, None).
|
| 233 |
+
"""
|
| 234 |
+
files = sess.get("uploaded_files") or []
|
| 235 |
+
msg_l = (msg or "").lower()
|
| 236 |
+
|
| 237 |
+
# 1) explicit filename mentioned
|
| 238 |
+
hinted = _extract_filename_hint(msg)
|
| 239 |
+
if hinted:
|
| 240 |
+
# only restrict if that file exists in session uploads
|
| 241 |
+
known = {os.path.basename(f.get("filename", "")) for f in files if f.get("filename")}
|
| 242 |
+
if hinted in known:
|
| 243 |
+
return ([hinted], None)
|
| 244 |
+
|
| 245 |
+
# 2) generic "uploaded file" intent
|
| 246 |
+
uploaded_intent = any(t in msg_l for t in [
|
| 247 |
+
"uploaded file", "uploaded files", "the uploaded file", "this file", "this document",
|
| 248 |
+
"上传的文件", "这份文件", "这个文件", "文档", "课件", "讲义"
|
| 249 |
+
])
|
| 250 |
+
if uploaded_intent and files:
|
| 251 |
+
last = files[-1]
|
| 252 |
+
fn = os.path.basename(last.get("filename", "")).strip() or None
|
| 253 |
+
dt = (last.get("doc_type") or "").strip() or None
|
| 254 |
+
allowed_files = [fn] if fn else None
|
| 255 |
+
allowed_doc_types = [dt] if dt else None
|
| 256 |
+
return (allowed_files, allowed_doc_types)
|
| 257 |
+
|
| 258 |
+
return (None, None)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# ----------------------------
|
| 262 |
+
# Warmup
|
| 263 |
+
# ----------------------------
|
| 264 |
+
def _do_warmup_once():
|
| 265 |
+
global WARMUP_DONE, WARMUP_ERROR, WARMUP_STARTED
|
| 266 |
+
if WARMUP_STARTED:
|
| 267 |
+
return
|
| 268 |
+
WARMUP_STARTED = True
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
from api.config import client
|
| 272 |
+
client.models.list()
|
| 273 |
+
_ = MODULE10_CHUNKS_CACHE
|
| 274 |
+
WARMUP_DONE = True
|
| 275 |
+
WARMUP_ERROR = None
|
| 276 |
+
except Exception as e:
|
| 277 |
+
WARMUP_DONE = False
|
| 278 |
+
WARMUP_ERROR = repr(e)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _start_warmup_background():
|
| 282 |
+
if not CLARE_ENABLE_WARMUP:
|
| 283 |
+
return
|
| 284 |
+
threading.Thread(target=_do_warmup_once, daemon=True).start()
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@app.on_event("startup")
|
| 288 |
+
def _on_startup():
|
| 289 |
+
_start_warmup_background()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ----------------------------
|
| 293 |
+
# LangSmith helpers
|
| 294 |
+
# ----------------------------
|
| 295 |
+
_ls_client = None
|
| 296 |
+
if (Client is not None) and CLARE_ENABLE_LANGSMITH_LOG:
|
| 297 |
+
try:
|
| 298 |
+
_ls_client = Client()
|
| 299 |
+
except Exception as e:
|
| 300 |
+
print("[langsmith] init failed:", repr(e))
|
| 301 |
+
_ls_client = None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _log_event_to_langsmith(data: Dict[str, Any]):
|
| 305 |
+
"""
|
| 306 |
+
Dataset logging: create_example into LS_DATASET_NAME
|
| 307 |
+
"""
|
| 308 |
+
if _ls_client is None:
|
| 309 |
+
return
|
| 310 |
+
|
| 311 |
+
def _do():
|
| 312 |
+
try:
|
| 313 |
+
inputs = {
|
| 314 |
+
"question": data.get("question", ""),
|
| 315 |
+
"student_id": data.get("student_id", ""),
|
| 316 |
+
"student_name": data.get("student_name", ""),
|
| 317 |
+
}
|
| 318 |
+
outputs = {"answer": data.get("answer", "")}
|
| 319 |
+
|
| 320 |
+
# keep metadata clean and JSON-serializable
|
| 321 |
+
metadata = {k: v for k, v in data.items() if k not in ("question", "answer")}
|
| 322 |
+
|
| 323 |
+
if LS_PROJECT:
|
| 324 |
+
metadata.setdefault("langsmith_project", LS_PROJECT)
|
| 325 |
+
|
| 326 |
+
_ls_client.create_example(
|
| 327 |
+
inputs=inputs,
|
| 328 |
+
outputs=outputs,
|
| 329 |
+
metadata=metadata,
|
| 330 |
+
dataset_name=LS_DATASET_NAME,
|
| 331 |
+
)
|
| 332 |
+
except Exception as e:
|
| 333 |
+
print("[langsmith] log failed:", repr(e))
|
| 334 |
+
|
| 335 |
+
if CLARE_LANGSMITH_ASYNC:
|
| 336 |
+
threading.Thread(target=_do, daemon=True).start()
|
| 337 |
+
else:
|
| 338 |
+
_do()
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _write_feedback_to_langsmith_run(
|
| 342 |
+
run_id: str,
|
| 343 |
+
rating: str,
|
| 344 |
+
comment: str = "",
|
| 345 |
+
tags: Optional[List[str]] = None,
|
| 346 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 347 |
+
) -> bool:
|
| 348 |
+
"""
|
| 349 |
+
Run-level feedback: create_feedback attached to a specific run_id.
|
| 350 |
+
This is separate from dataset create_example logging.
|
| 351 |
+
"""
|
| 352 |
+
if not CLARE_ENABLE_LANGSMITH_FEEDBACK:
|
| 353 |
+
return False
|
| 354 |
+
if Client is None:
|
| 355 |
+
return False
|
| 356 |
+
|
| 357 |
+
rid = (run_id or "").strip()
|
| 358 |
+
if not rid:
|
| 359 |
+
return False
|
| 360 |
+
|
| 361 |
+
try:
|
| 362 |
+
ls = Client()
|
| 363 |
+
score = 1 if rating == "helpful" else 0
|
| 364 |
+
|
| 365 |
+
meta = metadata or {}
|
| 366 |
+
if tags is not None:
|
| 367 |
+
meta["tags"] = tags
|
| 368 |
+
|
| 369 |
+
if LS_PROJECT:
|
| 370 |
+
meta.setdefault("langsmith_project", LS_PROJECT)
|
| 371 |
+
|
| 372 |
+
ls.create_feedback(
|
| 373 |
+
run_id=rid,
|
| 374 |
+
key="ui_rating",
|
| 375 |
+
score=score,
|
| 376 |
+
comment=comment or "",
|
| 377 |
+
metadata=meta,
|
| 378 |
+
)
|
| 379 |
+
return True
|
| 380 |
+
except Exception as e:
|
| 381 |
+
print("[langsmith] create_feedback failed:", repr(e))
|
| 382 |
+
return False
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ----------------------------
|
| 386 |
+
# Health endpoints
|
| 387 |
+
# ----------------------------
|
| 388 |
+
@app.get("/health")
|
| 389 |
+
def health():
|
| 390 |
+
return {
|
| 391 |
+
"ok": True,
|
| 392 |
+
"uptime_s": round(time.time() - APP_START_TS, 3),
|
| 393 |
+
"warmup_enabled": CLARE_ENABLE_WARMUP,
|
| 394 |
+
"warmup_started": bool(WARMUP_STARTED),
|
| 395 |
+
"warmup_done": bool(WARMUP_DONE),
|
| 396 |
+
"warmup_error": WARMUP_ERROR,
|
| 397 |
+
"ready": bool(WARMUP_DONE) if CLARE_WARMUP_BLOCK_READY else True,
|
| 398 |
+
"langsmith_enabled": bool(CLARE_ENABLE_LANGSMITH_LOG),
|
| 399 |
+
"langsmith_async": bool(CLARE_LANGSMITH_ASYNC),
|
| 400 |
+
"langsmith_feedback_enabled": bool(CLARE_ENABLE_LANGSMITH_FEEDBACK),
|
| 401 |
+
"ts": int(time.time()),
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
@app.get("/ready")
|
| 406 |
+
def ready():
|
| 407 |
+
if not CLARE_ENABLE_WARMUP or not CLARE_WARMUP_BLOCK_READY:
|
| 408 |
+
return {"ready": True}
|
| 409 |
+
if WARMUP_DONE:
|
| 410 |
+
return {"ready": True}
|
| 411 |
+
return JSONResponse({"ready": False, "error": WARMUP_ERROR}, status_code=503)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# ----------------------------
|
| 415 |
+
# Quiz (Micro-Quiz) Instruction
|
| 416 |
+
# ----------------------------
|
| 417 |
+
MICRO_QUIZ_INSTRUCTION = (
|
| 418 |
+
"We are running a short micro-quiz session based ONLY on **Module 10 – "
|
| 419 |
+
"Responsible AI (Alto, 2024, Chapter 12)** and the pre-loaded materials.\n\n"
|
| 420 |
+
"Step 1 – Before asking any content question:\n"
|
| 421 |
+
"• First ask me which quiz style I prefer right now:\n"
|
| 422 |
+
" - (1) Multiple-choice questions\n"
|
| 423 |
+
" - (2) Short-answer / open-ended questions\n"
|
| 424 |
+
"• Ask me explicitly: \"Which quiz style do you prefer now: 1) Multiple-choice or 2) Short-answer? "
|
| 425 |
+
"Please reply with 1 or 2.\"\n"
|
| 426 |
+
"• Do NOT start a content question until I have answered 1 or 2.\n\n"
|
| 427 |
+
"Step 2 – After I choose the style:\n"
|
| 428 |
+
"• If I choose 1 (multiple-choice):\n"
|
| 429 |
+
" - Ask ONE multiple-choice question at a time, based on Module 10 concepts "
|
| 430 |
+
"(Responsible AI definition, risk types, mitigation layers, EU AI Act, etc.).\n"
|
| 431 |
+
" - Provide 3–4 options (A, B, C, D) and make only one option clearly correct.\n"
|
| 432 |
+
"• If I choose 2 (short-answer):\n"
|
| 433 |
+
" - Ask ONE short-answer question at a time, also based on Module 10 concepts.\n"
|
| 434 |
+
" - Do NOT show the answer when you ask the question.\n\n"
|
| 435 |
+
"Step 3 – For each answer I give:\n"
|
| 436 |
+
"• Grade my answer (correct / partially correct / incorrect).\n"
|
| 437 |
+
"• Give a brief explanation and the correct answer.\n"
|
| 438 |
+
"• Then ask if I want another question of the SAME style.\n"
|
| 439 |
+
"• Continue this pattern until I explicitly say to stop.\n\n"
|
| 440 |
+
"Please start by asking me which quiz style I prefer (1 = multiple-choice, 2 = short-answer). "
|
| 441 |
+
"Do not ask any content question before I choose."
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ----------------------------
|
| 446 |
+
# Schemas
|
| 447 |
+
# ----------------------------
|
| 448 |
+
class LoginReq(BaseModel):
|
| 449 |
+
name: str
|
| 450 |
+
user_id: str
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
class ChatReq(BaseModel):
|
| 454 |
+
user_id: str
|
| 455 |
+
message: str
|
| 456 |
+
learning_mode: str
|
| 457 |
+
language_preference: str = "Auto"
|
| 458 |
+
doc_type: str = "Syllabus"
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
class QuizStartReq(BaseModel):
|
| 462 |
+
user_id: str
|
| 463 |
+
language_preference: str = "Auto"
|
| 464 |
+
doc_type: str = MODULE10_DOC_TYPE
|
| 465 |
+
learning_mode: str = "quiz"
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
class ExportReq(BaseModel):
|
| 469 |
+
user_id: str
|
| 470 |
+
learning_mode: str
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
class SummaryReq(BaseModel):
|
| 474 |
+
user_id: str
|
| 475 |
+
learning_mode: str
|
| 476 |
+
language_preference: str = "Auto"
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
class TtsReq(BaseModel):
|
| 480 |
+
user_id: str
|
| 481 |
+
text: str
|
| 482 |
+
voice: Optional[str] = "nova" # alloy, echo, fable, onyx, nova, shimmer
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
class PodcastReq(BaseModel):
|
| 486 |
+
user_id: str
|
| 487 |
+
source: str = "summary" # "summary" | "conversation"
|
| 488 |
+
voice: Optional[str] = "nova"
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
class FeedbackReq(BaseModel):
|
| 492 |
+
class Config:
|
| 493 |
+
extra = "ignore"
|
| 494 |
+
|
| 495 |
+
user_id: str
|
| 496 |
+
rating: str # "helpful" | "not_helpful"
|
| 497 |
+
run_id: Optional[str] = None
|
| 498 |
+
assistant_message_id: Optional[str] = None
|
| 499 |
+
assistant_text: str
|
| 500 |
+
user_text: Optional[str] = ""
|
| 501 |
+
comment: Optional[str] = ""
|
| 502 |
+
tags: Optional[List[str]] = []
|
| 503 |
+
refs: Optional[List[str]] = []
|
| 504 |
+
learning_mode: Optional[str] = None
|
| 505 |
+
doc_type: Optional[str] = None
|
| 506 |
+
timestamp_ms: Optional[int] = None
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
class ProfileStatusResp(BaseModel):
|
| 510 |
+
need_init: bool
|
| 511 |
+
bio_len: int
|
| 512 |
+
dismissed_until: int
|
| 513 |
+
|
| 514 |
+
class ProfileDismissReq(BaseModel):
|
| 515 |
+
user_id: str
|
| 516 |
+
days: int = 7
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
class ProfileInitSubmitReq(BaseModel):
|
| 520 |
+
user_id: str
|
| 521 |
+
answers: Dict[str, Any]
|
| 522 |
+
language_preference: str = "Auto"
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def _generate_profile_bio_with_clare(
|
| 526 |
+
sess: Dict[str, Any],
|
| 527 |
+
answers: Dict[str, Any],
|
| 528 |
+
language_preference: str = "Auto",
|
| 529 |
+
) -> str:
|
| 530 |
+
"""
|
| 531 |
+
Generates an English Profile Bio. Keep it neutral/supportive and non-judgmental.
|
| 532 |
+
IMPORTANT: Do not contaminate user's normal chat history; use empty history.
|
| 533 |
+
"""
|
| 534 |
+
student_name = (sess.get("name") or "").strip()
|
| 535 |
+
|
| 536 |
+
prompt = f"""
|
| 537 |
+
You are Clare, an AI teaching assistant.
|
| 538 |
+
|
| 539 |
+
Task:
|
| 540 |
+
Generate a concise English Profile Bio for the student using ONLY the initialization answers provided below.
|
| 541 |
+
|
| 542 |
+
Hard constraints:
|
| 543 |
+
- Output language: English.
|
| 544 |
+
- Tone: neutral, supportive, non-judgmental.
|
| 545 |
+
- No medical/psychological diagnosis language.
|
| 546 |
+
- Do not infer sensitive attributes (race, religion, political views, health status, sexuality, immigration status).
|
| 547 |
+
- Length: 60–120 words.
|
| 548 |
+
- Structure (4 short sentences max):
|
| 549 |
+
1) background & current context
|
| 550 |
+
2) learning goal for this course
|
| 551 |
+
3) learning preferences (format + pace)
|
| 552 |
+
4) how Clare will support them going forward (practical and concrete)
|
| 553 |
+
|
| 554 |
+
Student name (if available): {student_name}
|
| 555 |
+
|
| 556 |
+
Initialization answers (JSON):
|
| 557 |
+
{answers}
|
| 558 |
+
|
| 559 |
+
Return ONLY the bio text. Do not add a title.
|
| 560 |
+
""".strip()
|
| 561 |
+
|
| 562 |
+
resolved_lang = "English" # force English regardless of UI preference
|
| 563 |
+
|
| 564 |
+
try:
|
| 565 |
+
bio, _unused_history, _run_id = chat_with_clare(
|
| 566 |
+
message=prompt,
|
| 567 |
+
history=[],
|
| 568 |
+
model_name=sess["model_name"],
|
| 569 |
+
language_preference=resolved_lang,
|
| 570 |
+
learning_mode="summary",
|
| 571 |
+
doc_type="Other Course Document",
|
| 572 |
+
course_outline=sess["course_outline"],
|
| 573 |
+
weaknesses=sess["weaknesses"],
|
| 574 |
+
cognitive_state=sess["cognitive_state"],
|
| 575 |
+
rag_context="",
|
| 576 |
+
)
|
| 577 |
+
return (bio or "").strip()
|
| 578 |
+
except Exception as e:
|
| 579 |
+
print("[profile_bio] generate failed:", repr(e))
|
| 580 |
+
return ""
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
# ----------------------------
|
| 584 |
+
# API Routes
|
| 585 |
+
# ----------------------------
|
| 586 |
+
@app.post("/api/login")
|
| 587 |
+
def login(req: LoginReq):
|
| 588 |
+
user_id = (req.user_id or "").strip()
|
| 589 |
+
name = (req.name or "").strip()
|
| 590 |
+
if not user_id or not name:
|
| 591 |
+
return JSONResponse({"ok": False, "error": "Missing name/user_id"}, status_code=400)
|
| 592 |
+
|
| 593 |
+
sess = _get_session(user_id)
|
| 594 |
+
sess["name"] = name
|
| 595 |
+
return {"ok": True, "user": {"name": name, "user_id": user_id}}
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
@app.post("/api/chat")
|
| 599 |
+
def chat(req: ChatReq):
|
| 600 |
+
user_id = (req.user_id or "").strip()
|
| 601 |
+
msg = (req.message or "").strip()
|
| 602 |
+
if not user_id:
|
| 603 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 604 |
+
|
| 605 |
+
sess = _get_session(user_id)
|
| 606 |
+
|
| 607 |
+
if not msg:
|
| 608 |
+
return {
|
| 609 |
+
"reply": "",
|
| 610 |
+
"session_status_md": render_session_status(
|
| 611 |
+
req.learning_mode, sess["weaknesses"], sess["cognitive_state"]
|
| 612 |
+
),
|
| 613 |
+
"refs": [],
|
| 614 |
+
"latency_ms": 0.0,
|
| 615 |
+
"run_id": None,
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
t0 = time.time()
|
| 619 |
+
marks_ms: Dict[str, float] = {"start": 0.0}
|
| 620 |
+
|
| 621 |
+
resolved_lang = detect_language(msg, req.language_preference)
|
| 622 |
+
marks_ms["language_detect_done"] = (time.time() - t0) * 1000.0
|
| 623 |
+
|
| 624 |
+
sess["weaknesses"] = update_weaknesses_from_message(msg, sess["weaknesses"])
|
| 625 |
+
marks_ms["weakness_update_done"] = (time.time() - t0) * 1000.0
|
| 626 |
+
|
| 627 |
+
sess["cognitive_state"] = update_cognitive_state_from_message(msg, sess["cognitive_state"])
|
| 628 |
+
marks_ms["cognitive_update_done"] = (time.time() - t0) * 1000.0
|
| 629 |
+
|
| 630 |
+
# NEW: do NOT bypass RAG for document actions (so UI refs are preserved)
|
| 631 |
+
force_rag = _should_force_rag(msg)
|
| 632 |
+
|
| 633 |
+
allowed_files, allowed_doc_types = _resolve_rag_scope(sess, msg)
|
| 634 |
+
|
| 635 |
+
if (len(msg) < 20 and ("?" not in msg)) and (not force_rag):
|
| 636 |
+
rag_context_text, rag_used_chunks = "", []
|
| 637 |
+
else:
|
| 638 |
+
rag_context_text, rag_used_chunks = retrieve_relevant_chunks(
|
| 639 |
+
msg,
|
| 640 |
+
sess["rag_chunks"],
|
| 641 |
+
allowed_source_files=allowed_files,
|
| 642 |
+
allowed_doc_types=allowed_doc_types,
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
# 方案三:调用 GenAICoursesDB 向量知识库,补充课程检索结果
|
| 646 |
+
course_used = False
|
| 647 |
+
if GENAI_COURSES_SPACE:
|
| 648 |
+
course_chunks = _retrieve_from_genai_courses(msg)
|
| 649 |
+
if course_chunks:
|
| 650 |
+
prefix = "\n\n[来自 GENAI 课程知识库]\n\n"
|
| 651 |
+
rag_context_text = (rag_context_text or "") + prefix + course_chunks
|
| 652 |
+
course_used = True
|
| 653 |
+
|
| 654 |
+
marks_ms["rag_retrieve_done"] = (time.time() - t0) * 1000.0
|
| 655 |
+
|
| 656 |
+
# NEW: prepend deterministic upload/file-state hint so the model never says “no file”
|
| 657 |
+
upload_hint = _build_upload_hint(sess)
|
| 658 |
+
if upload_hint:
|
| 659 |
+
rag_context_text = (upload_hint + "\n\n---\n\n" + (rag_context_text or "")).strip()
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
answer, new_history, run_id = chat_with_clare(
|
| 663 |
+
message=msg,
|
| 664 |
+
history=sess["history"],
|
| 665 |
+
model_name=sess["model_name"],
|
| 666 |
+
language_preference=resolved_lang,
|
| 667 |
+
learning_mode=req.learning_mode,
|
| 668 |
+
doc_type=req.doc_type,
|
| 669 |
+
course_outline=sess["course_outline"],
|
| 670 |
+
weaknesses=sess["weaknesses"],
|
| 671 |
+
cognitive_state=sess["cognitive_state"],
|
| 672 |
+
rag_context=rag_context_text,
|
| 673 |
+
)
|
| 674 |
+
except Exception as e:
|
| 675 |
+
print(f"[chat] error: {repr(e)}")
|
| 676 |
+
return JSONResponse({"error": f"chat failed: {repr(e)}"}, status_code=500)
|
| 677 |
+
|
| 678 |
+
marks_ms["llm_done"] = (time.time() - t0) * 1000.0
|
| 679 |
+
total_ms = marks_ms["llm_done"]
|
| 680 |
+
|
| 681 |
+
ordered = [
|
| 682 |
+
"start",
|
| 683 |
+
"language_detect_done",
|
| 684 |
+
"weakness_update_done",
|
| 685 |
+
"cognitive_update_done",
|
| 686 |
+
"rag_retrieve_done",
|
| 687 |
+
"llm_done",
|
| 688 |
+
]
|
| 689 |
+
segments_ms: Dict[str, float] = {}
|
| 690 |
+
for i in range(1, len(ordered)):
|
| 691 |
+
a = ordered[i - 1]
|
| 692 |
+
b = ordered[i]
|
| 693 |
+
segments_ms[b] = max(0.0, marks_ms.get(b, 0.0) - marks_ms.get(a, 0.0))
|
| 694 |
+
|
| 695 |
+
latency_breakdown = {"marks_ms": marks_ms, "segments_ms": segments_ms, "total_ms": total_ms}
|
| 696 |
+
|
| 697 |
+
sess["history"] = new_history
|
| 698 |
+
|
| 699 |
+
refs: List[Dict[str, Optional[str]]] = []
|
| 700 |
+
for c in (rag_used_chunks or []):
|
| 701 |
+
a = c.get("source_file")
|
| 702 |
+
b = c.get("section")
|
| 703 |
+
s = c.get("_rag_score")
|
| 704 |
+
# Make reference more precise if score is available
|
| 705 |
+
if s is not None and b:
|
| 706 |
+
b = f"{b} (score={float(s):.2f})"
|
| 707 |
+
refs.append({"source_file": a, "section": b})
|
| 708 |
+
|
| 709 |
+
if course_used:
|
| 710 |
+
refs.append({"source_file": "GenAICoursesDB", "section": "retrieve (GENAI COURSES dataset)"})
|
| 711 |
+
|
| 712 |
+
if not refs:
|
| 713 |
+
refs = [
|
| 714 |
+
{
|
| 715 |
+
"source_file": "No RAG",
|
| 716 |
+
"section": "Answer based on model general knowledge; web search: not used.",
|
| 717 |
+
}
|
| 718 |
+
]
|
| 719 |
+
|
| 720 |
+
rag_context_chars = len((rag_context_text or ""))
|
| 721 |
+
rag_used_chunks_count = len(rag_used_chunks or [])
|
| 722 |
+
history_len = len(sess["history"])
|
| 723 |
+
|
| 724 |
+
_log_event_to_langsmith(
|
| 725 |
+
{
|
| 726 |
+
"experiment_id": EXPERIMENT_ID,
|
| 727 |
+
"student_id": user_id,
|
| 728 |
+
"student_name": sess.get("name", ""),
|
| 729 |
+
"event_type": "chat_turn",
|
| 730 |
+
"timestamp": time.time(),
|
| 731 |
+
"latency_ms": total_ms,
|
| 732 |
+
"latency_breakdown": latency_breakdown,
|
| 733 |
+
"rag_context_chars": rag_context_chars,
|
| 734 |
+
"rag_used_chunks_count": rag_used_chunks_count,
|
| 735 |
+
"history_len": history_len,
|
| 736 |
+
"question": msg,
|
| 737 |
+
"answer": answer,
|
| 738 |
+
"model_name": sess["model_name"],
|
| 739 |
+
"language": resolved_lang,
|
| 740 |
+
"learning_mode": req.learning_mode,
|
| 741 |
+
"doc_type": req.doc_type,
|
| 742 |
+
"refs": refs,
|
| 743 |
+
"run_id": run_id,
|
| 744 |
+
}
|
| 745 |
+
)
|
| 746 |
+
|
| 747 |
+
return {
|
| 748 |
+
"reply": answer,
|
| 749 |
+
"session_status_md": render_session_status(
|
| 750 |
+
req.learning_mode, sess["weaknesses"], sess["cognitive_state"]
|
| 751 |
+
),
|
| 752 |
+
"refs": refs,
|
| 753 |
+
"latency_ms": total_ms,
|
| 754 |
+
"run_id": run_id,
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
@app.post("/api/quiz/start")
|
| 759 |
+
def quiz_start(req: QuizStartReq):
|
| 760 |
+
user_id = (req.user_id or "").strip()
|
| 761 |
+
if not user_id:
|
| 762 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 763 |
+
|
| 764 |
+
sess = _get_session(user_id)
|
| 765 |
+
|
| 766 |
+
quiz_instruction = MICRO_QUIZ_INSTRUCTION
|
| 767 |
+
t0 = time.time()
|
| 768 |
+
|
| 769 |
+
resolved_lang = detect_language(quiz_instruction, req.language_preference)
|
| 770 |
+
|
| 771 |
+
rag_context_text, rag_used_chunks = retrieve_relevant_chunks(
|
| 772 |
+
"Module 10 quiz", sess["rag_chunks"]
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
# ✅ NEW: same hint for quiz start as well
|
| 776 |
+
upload_hint = _build_upload_hint(sess)
|
| 777 |
+
if upload_hint:
|
| 778 |
+
rag_context_text = (upload_hint + "\n\n---\n\n" + (rag_context_text or "")).strip()
|
| 779 |
+
|
| 780 |
+
try:
|
| 781 |
+
answer, new_history, run_id = chat_with_clare(
|
| 782 |
+
message=quiz_instruction,
|
| 783 |
+
history=sess["history"],
|
| 784 |
+
model_name=sess["model_name"],
|
| 785 |
+
language_preference=resolved_lang,
|
| 786 |
+
learning_mode=req.learning_mode,
|
| 787 |
+
doc_type=req.doc_type,
|
| 788 |
+
course_outline=sess["course_outline"],
|
| 789 |
+
weaknesses=sess["weaknesses"],
|
| 790 |
+
cognitive_state=sess["cognitive_state"],
|
| 791 |
+
rag_context=rag_context_text,
|
| 792 |
+
)
|
| 793 |
+
except Exception as e:
|
| 794 |
+
print(f"[quiz_start] error: {repr(e)}")
|
| 795 |
+
return JSONResponse({"error": f"quiz_start failed: {repr(e)}"}, status_code=500)
|
| 796 |
+
|
| 797 |
+
total_ms = (time.time() - t0) * 1000.0
|
| 798 |
+
sess["history"] = new_history
|
| 799 |
+
|
| 800 |
+
refs = [
|
| 801 |
+
{"source_file": c.get("source_file"), "section": c.get("section")}
|
| 802 |
+
for c in (rag_used_chunks or [])
|
| 803 |
+
]
|
| 804 |
+
|
| 805 |
+
_log_event_to_langsmith(
|
| 806 |
+
{
|
| 807 |
+
"experiment_id": EXPERIMENT_ID,
|
| 808 |
+
"student_id": user_id,
|
| 809 |
+
"student_name": sess.get("name", ""),
|
| 810 |
+
"event_type": "micro_quiz_start",
|
| 811 |
+
"timestamp": time.time(),
|
| 812 |
+
"latency_ms": total_ms,
|
| 813 |
+
"question": "[micro_quiz_start] " + quiz_instruction[:200],
|
| 814 |
+
"answer": answer,
|
| 815 |
+
"model_name": sess["model_name"],
|
| 816 |
+
"language": resolved_lang,
|
| 817 |
+
"learning_mode": req.learning_mode,
|
| 818 |
+
"doc_type": req.doc_type,
|
| 819 |
+
"refs": refs,
|
| 820 |
+
"rag_used_chunks_count": len(rag_used_chunks or []),
|
| 821 |
+
"history_len": len(sess["history"]),
|
| 822 |
+
"run_id": run_id,
|
| 823 |
+
}
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
return {
|
| 827 |
+
"reply": answer,
|
| 828 |
+
"session_status_md": render_session_status(
|
| 829 |
+
req.learning_mode, sess["weaknesses"], sess["cognitive_state"]
|
| 830 |
+
),
|
| 831 |
+
"refs": refs,
|
| 832 |
+
"latency_ms": total_ms,
|
| 833 |
+
"run_id": run_id,
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
@app.post("/api/upload")
|
| 838 |
+
async def upload(
|
| 839 |
+
user_id: str = Form(...),
|
| 840 |
+
doc_type: str = Form(...),
|
| 841 |
+
file: UploadFile = File(...),
|
| 842 |
+
):
|
| 843 |
+
user_id = (user_id or "").strip()
|
| 844 |
+
doc_type = (doc_type or "").strip()
|
| 845 |
+
|
| 846 |
+
if not user_id:
|
| 847 |
+
return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400)
|
| 848 |
+
if not file or not file.filename:
|
| 849 |
+
return JSONResponse({"ok": False, "error": "Missing file"}, status_code=400)
|
| 850 |
+
|
| 851 |
+
sess = _get_session(user_id)
|
| 852 |
+
|
| 853 |
+
safe_name = os.path.basename(file.filename).replace("..", "_")
|
| 854 |
+
tmp_path = os.path.join("/tmp", safe_name)
|
| 855 |
+
|
| 856 |
+
content = await file.read()
|
| 857 |
+
with open(tmp_path, "wb") as f:
|
| 858 |
+
f.write(content)
|
| 859 |
+
|
| 860 |
+
if doc_type == "Syllabus":
|
| 861 |
+
class _F:
|
| 862 |
+
pass
|
| 863 |
+
|
| 864 |
+
fo = _F()
|
| 865 |
+
fo.name = tmp_path
|
| 866 |
+
try:
|
| 867 |
+
sess["course_outline"] = extract_course_topics_from_file(fo, doc_type)
|
| 868 |
+
except Exception as e:
|
| 869 |
+
print(f"[upload] syllabus parse error: {repr(e)}")
|
| 870 |
+
|
| 871 |
+
try:
|
| 872 |
+
new_chunks = build_rag_chunks_from_file(tmp_path, doc_type) or []
|
| 873 |
+
sess["rag_chunks"] = (sess["rag_chunks"] or []) + new_chunks
|
| 874 |
+
except Exception as e:
|
| 875 |
+
print(f"[upload] rag build error: {repr(e)}")
|
| 876 |
+
new_chunks = []
|
| 877 |
+
|
| 878 |
+
# ✅ NEW: record upload metadata for prompting/debug
|
| 879 |
+
try:
|
| 880 |
+
sess["uploaded_files"] = sess.get("uploaded_files") or []
|
| 881 |
+
sess["uploaded_files"].append(
|
| 882 |
+
{
|
| 883 |
+
"filename": safe_name,
|
| 884 |
+
"doc_type": doc_type,
|
| 885 |
+
"added_chunks": len(new_chunks),
|
| 886 |
+
"ts": int(time.time()),
|
| 887 |
+
}
|
| 888 |
+
)
|
| 889 |
+
except Exception as e:
|
| 890 |
+
print(f"[upload] uploaded_files record error: {repr(e)}")
|
| 891 |
+
|
| 892 |
+
status_md = f"✅ Loaded base reading + uploaded {doc_type} file."
|
| 893 |
+
|
| 894 |
+
_log_event_to_langsmith(
|
| 895 |
+
{
|
| 896 |
+
"experiment_id": EXPERIMENT_ID,
|
| 897 |
+
"student_id": user_id,
|
| 898 |
+
"student_name": sess.get("name", ""),
|
| 899 |
+
"event_type": "upload",
|
| 900 |
+
"timestamp": time.time(),
|
| 901 |
+
"doc_type": doc_type,
|
| 902 |
+
"filename": safe_name,
|
| 903 |
+
"added_chunks": len(new_chunks),
|
| 904 |
+
"question": f"[upload] {safe_name}",
|
| 905 |
+
"answer": status_md,
|
| 906 |
+
}
|
| 907 |
+
)
|
| 908 |
+
|
| 909 |
+
return {"ok": True, "added_chunks": len(new_chunks), "status_md": status_md}
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
@app.post("/api/feedback")
|
| 913 |
+
def api_feedback(req: FeedbackReq):
|
| 914 |
+
user_id = (req.user_id or "").strip()
|
| 915 |
+
if not user_id:
|
| 916 |
+
return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400)
|
| 917 |
+
|
| 918 |
+
sess = _get_session(user_id)
|
| 919 |
+
student_name = sess.get("name", "")
|
| 920 |
+
|
| 921 |
+
rating = (req.rating or "").strip().lower()
|
| 922 |
+
if rating not in ("helpful", "not_helpful"):
|
| 923 |
+
return JSONResponse({"ok": False, "error": "Invalid rating"}, status_code=400)
|
| 924 |
+
|
| 925 |
+
assistant_text = (req.assistant_text or "").strip()
|
| 926 |
+
user_text = (req.user_text or "").strip()
|
| 927 |
+
comment = (req.comment or "").strip()
|
| 928 |
+
refs = req.refs or []
|
| 929 |
+
tags = req.tags or []
|
| 930 |
+
timestamp_ms = int(req.timestamp_ms or int(time.time() * 1000))
|
| 931 |
+
|
| 932 |
+
_log_event_to_langsmith(
|
| 933 |
+
{
|
| 934 |
+
"experiment_id": EXPERIMENT_ID,
|
| 935 |
+
"student_id": user_id,
|
| 936 |
+
"student_name": student_name,
|
| 937 |
+
"event_type": "feedback",
|
| 938 |
+
"timestamp": time.time(),
|
| 939 |
+
"timestamp_ms": timestamp_ms,
|
| 940 |
+
"rating": rating,
|
| 941 |
+
"assistant_message_id": req.assistant_message_id,
|
| 942 |
+
"run_id": req.run_id,
|
| 943 |
+
"question": user_text,
|
| 944 |
+
"answer": assistant_text,
|
| 945 |
+
"comment": comment,
|
| 946 |
+
"tags": tags,
|
| 947 |
+
"refs": refs,
|
| 948 |
+
"learning_mode": req.learning_mode,
|
| 949 |
+
"doc_type": req.doc_type,
|
| 950 |
+
}
|
| 951 |
+
)
|
| 952 |
+
|
| 953 |
+
wrote_run_feedback = False
|
| 954 |
+
if req.run_id:
|
| 955 |
+
wrote_run_feedback = _write_feedback_to_langsmith_run(
|
| 956 |
+
run_id=req.run_id,
|
| 957 |
+
rating=rating,
|
| 958 |
+
comment=comment,
|
| 959 |
+
tags=tags,
|
| 960 |
+
metadata={
|
| 961 |
+
"experiment_id": EXPERIMENT_ID,
|
| 962 |
+
"student_id": user_id,
|
| 963 |
+
"student_name": student_name,
|
| 964 |
+
"assistant_message_id": req.assistant_message_id,
|
| 965 |
+
"learning_mode": req.learning_mode,
|
| 966 |
+
"doc_type": req.doc_type,
|
| 967 |
+
"refs": refs,
|
| 968 |
+
"timestamp_ms": timestamp_ms,
|
| 969 |
+
},
|
| 970 |
+
)
|
| 971 |
+
|
| 972 |
+
return {"ok": True, "run_feedback_written": wrote_run_feedback}
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
@app.post("/api/export")
|
| 976 |
+
def api_export(req: ExportReq):
|
| 977 |
+
user_id = (req.user_id or "").strip()
|
| 978 |
+
if not user_id:
|
| 979 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 980 |
+
|
| 981 |
+
sess = _get_session(user_id)
|
| 982 |
+
md = export_conversation(
|
| 983 |
+
sess["history"],
|
| 984 |
+
sess["course_outline"],
|
| 985 |
+
req.learning_mode,
|
| 986 |
+
sess["weaknesses"],
|
| 987 |
+
sess["cognitive_state"],
|
| 988 |
+
)
|
| 989 |
+
return {"markdown": md}
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
@app.post("/api/summary")
|
| 993 |
+
def api_summary(req: SummaryReq):
|
| 994 |
+
user_id = (req.user_id or "").strip()
|
| 995 |
+
if not user_id:
|
| 996 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 997 |
+
|
| 998 |
+
sess = _get_session(user_id)
|
| 999 |
+
md = summarize_conversation(
|
| 1000 |
+
sess["history"],
|
| 1001 |
+
sess["course_outline"],
|
| 1002 |
+
sess["weaknesses"],
|
| 1003 |
+
sess["cognitive_state"],
|
| 1004 |
+
sess["model_name"],
|
| 1005 |
+
req.language_preference,
|
| 1006 |
+
)
|
| 1007 |
+
return {"markdown": md}
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
# ----------------------------
|
| 1011 |
+
# TTS & Podcast (OpenAI TTS API)
|
| 1012 |
+
# ----------------------------
|
| 1013 |
+
@app.post("/api/tts")
|
| 1014 |
+
def api_tts(req: TtsReq):
|
| 1015 |
+
"""Convert text to speech; returns MP3 audio."""
|
| 1016 |
+
user_id = (req.user_id or "").strip()
|
| 1017 |
+
if not user_id:
|
| 1018 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 1019 |
+
text = (req.text or "").strip()
|
| 1020 |
+
if not text:
|
| 1021 |
+
return JSONResponse({"error": "Missing text"}, status_code=400)
|
| 1022 |
+
if len(text) > 50_000:
|
| 1023 |
+
return JSONResponse({"error": "Text too long (max 50000 chars)"}, status_code=400)
|
| 1024 |
+
try:
|
| 1025 |
+
audio_bytes = text_to_speech(text, voice=req.voice or "nova")
|
| 1026 |
+
except Exception as e:
|
| 1027 |
+
print(f"[tts] error: {repr(e)}")
|
| 1028 |
+
return JSONResponse({"error": f"TTS failed: {repr(e)}"}, status_code=500)
|
| 1029 |
+
if not audio_bytes:
|
| 1030 |
+
return JSONResponse({"error": "No audio generated"}, status_code=500)
|
| 1031 |
+
return Response(content=audio_bytes, media_type="audio/mpeg")
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
@app.post("/api/podcast")
|
| 1035 |
+
def api_podcast(req: PodcastReq):
|
| 1036 |
+
"""Generate podcast audio from session summary or conversation. Returns MP3."""
|
| 1037 |
+
user_id = (req.user_id or "").strip()
|
| 1038 |
+
if not user_id:
|
| 1039 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 1040 |
+
sess = _get_session(user_id)
|
| 1041 |
+
source = (req.source or "summary").lower()
|
| 1042 |
+
voice = req.voice or "nova"
|
| 1043 |
+
try:
|
| 1044 |
+
if source == "conversation":
|
| 1045 |
+
script = build_podcast_script_from_history(sess["history"])
|
| 1046 |
+
else:
|
| 1047 |
+
md = summarize_conversation(
|
| 1048 |
+
sess["history"],
|
| 1049 |
+
sess["course_outline"],
|
| 1050 |
+
sess["weaknesses"],
|
| 1051 |
+
sess["cognitive_state"],
|
| 1052 |
+
sess["model_name"],
|
| 1053 |
+
"Auto",
|
| 1054 |
+
)
|
| 1055 |
+
script = build_podcast_script_from_summary(md)
|
| 1056 |
+
audio_bytes = generate_podcast_audio(script, voice=voice)
|
| 1057 |
+
except Exception as e:
|
| 1058 |
+
print(f"[podcast] error: {repr(e)}")
|
| 1059 |
+
return JSONResponse({"error": f"Podcast failed: {repr(e)}"}, status_code=500)
|
| 1060 |
+
if not audio_bytes:
|
| 1061 |
+
return JSONResponse({"error": "No audio generated"}, status_code=500)
|
| 1062 |
+
return Response(content=audio_bytes, media_type="audio/mpeg")
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
@app.get("/api/memoryline")
|
| 1066 |
+
def memoryline(user_id: str):
|
| 1067 |
+
_ = _get_session((user_id or "").strip())
|
| 1068 |
+
return {"next_review_label": "T+7", "progress_pct": 0.4}
|
| 1069 |
+
|
| 1070 |
+
@app.get("/api/profile/status")
|
| 1071 |
+
def profile_status(user_id: str):
|
| 1072 |
+
user_id = (user_id or "").strip()
|
| 1073 |
+
if not user_id:
|
| 1074 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 1075 |
+
|
| 1076 |
+
sess = _get_session(user_id)
|
| 1077 |
+
bio = (sess.get("profile_bio") or "").strip()
|
| 1078 |
+
bio_len = len(bio)
|
| 1079 |
+
|
| 1080 |
+
now = int(time.time())
|
| 1081 |
+
dismissed_until = int(sess.get("init_dismiss_until") or 0)
|
| 1082 |
+
|
| 1083 |
+
# 触发条件:bio <= 50 且不在 dismiss 窗口内
|
| 1084 |
+
need_init = (bio_len <= 50) and (now >= dismissed_until)
|
| 1085 |
+
|
| 1086 |
+
return {
|
| 1087 |
+
"need_init": need_init,
|
| 1088 |
+
"bio_len": bio_len,
|
| 1089 |
+
"dismissed_until": dismissed_until,
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
@app.post("/api/profile/dismiss")
|
| 1094 |
+
def profile_dismiss(req: ProfileDismissReq):
|
| 1095 |
+
user_id = (req.user_id or "").strip()
|
| 1096 |
+
if not user_id:
|
| 1097 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 1098 |
+
|
| 1099 |
+
sess = _get_session(user_id)
|
| 1100 |
+
days = max(1, min(int(req.days or 7), 30)) # 1–30 days
|
| 1101 |
+
sess["init_dismiss_until"] = int(time.time()) + days * 24 * 3600
|
| 1102 |
+
return {"ok": True, "dismissed_until": sess["init_dismiss_until"]}
|
| 1103 |
+
|
| 1104 |
+
|
| 1105 |
+
@app.post("/api/profile/init_submit")
|
| 1106 |
+
def profile_init_submit(req: ProfileInitSubmitReq):
|
| 1107 |
+
user_id = (req.user_id or "").strip()
|
| 1108 |
+
if not user_id:
|
| 1109 |
+
return JSONResponse({"error": "Missing user_id"}, status_code=400)
|
| 1110 |
+
|
| 1111 |
+
sess = _get_session(user_id)
|
| 1112 |
+
answers = req.answers or {}
|
| 1113 |
+
|
| 1114 |
+
sess["init_answers"] = answers
|
| 1115 |
+
|
| 1116 |
+
bio = _generate_profile_bio_with_clare(sess, answers, req.language_preference)
|
| 1117 |
+
if not bio:
|
| 1118 |
+
return JSONResponse({"error": "Failed to generate bio"}, status_code=500)
|
| 1119 |
+
|
| 1120 |
+
sess["profile_bio"] = bio
|
| 1121 |
+
|
| 1122 |
+
return {"ok": True, "bio": bio}
|
| 1123 |
+
|
| 1124 |
+
# ----------------------------
|
| 1125 |
+
# SPA Fallback
|
| 1126 |
+
# ----------------------------
|
| 1127 |
+
@app.get("/{full_path:path}")
|
| 1128 |
+
def spa_fallback(full_path: str, request: Request):
|
| 1129 |
+
if (
|
| 1130 |
+
full_path.startswith("api/")
|
| 1131 |
+
or full_path.startswith("assets/")
|
| 1132 |
+
or full_path.startswith("static/")
|
| 1133 |
+
):
|
| 1134 |
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
| 1135 |
+
|
| 1136 |
+
if os.path.exists(WEB_INDEX):
|
| 1137 |
+
return FileResponse(WEB_INDEX)
|
| 1138 |
+
|
| 1139 |
+
return JSONResponse(
|
| 1140 |
+
{"detail": "web/build not found. Build frontend first (web/build/index.html)."},
|
| 1141 |
+
status_code=500,
|
| 1142 |
+
)
|
api/store.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/store.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from api.models import CourseDirectoryItem, Workspace, PersonContact, WorkspaceCourseRef, WorkspaceMember
|
| 9 |
+
|
| 10 |
+
DATA_DIR = Path(os.getenv("CLARE_DATA_DIR", "./data"))
|
| 11 |
+
COURSE_FILE = DATA_DIR / "course_directory.json"
|
| 12 |
+
WORKSPACE_FILE = DATA_DIR / "workspaces.json"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _ensure_dir():
|
| 16 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _read_json(path: Path) -> Optional[Any]:
|
| 20 |
+
if not path.exists():
|
| 21 |
+
return None
|
| 22 |
+
with path.open("r", encoding="utf-8") as f:
|
| 23 |
+
return json.load(f)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _write_json(path: Path, obj: Any) -> None:
|
| 27 |
+
_ensure_dir()
|
| 28 |
+
with path.open("w", encoding="utf-8") as f:
|
| 29 |
+
json.dump(obj, f, ensure_ascii=False, indent=2)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def seed_if_missing() -> None:
|
| 33 |
+
"""
|
| 34 |
+
Provide sane defaults so HF can run out-of-the-box.
|
| 35 |
+
Replace these with your real course list/workspaces later.
|
| 36 |
+
"""
|
| 37 |
+
_ensure_dir()
|
| 38 |
+
|
| 39 |
+
if not COURSE_FILE.exists():
|
| 40 |
+
courses = [
|
| 41 |
+
CourseDirectoryItem(
|
| 42 |
+
id="course_ai_001",
|
| 43 |
+
name="Introduction to AI",
|
| 44 |
+
instructor=PersonContact(name="Dr. Smith", email="smith@uni.edu"),
|
| 45 |
+
teachingAssistant=PersonContact(name="Alice", email="alice@uni.edu"),
|
| 46 |
+
).model_dump()
|
| 47 |
+
]
|
| 48 |
+
_write_json(COURSE_FILE, {"items": courses})
|
| 49 |
+
|
| 50 |
+
if not WORKSPACE_FILE.exists():
|
| 51 |
+
workspaces = [
|
| 52 |
+
Workspace(
|
| 53 |
+
id="ws_group_ai_g3",
|
| 54 |
+
type="group",
|
| 55 |
+
category="course",
|
| 56 |
+
name="Group Alpha",
|
| 57 |
+
groupNo=3,
|
| 58 |
+
courseId="course_ai_001",
|
| 59 |
+
courseInfo=WorkspaceCourseRef(id="course_ai_001", name="Introduction to AI"),
|
| 60 |
+
members=[
|
| 61 |
+
WorkspaceMember(id="u1", name="Sarah", email="sarah@uni.edu", role="owner"),
|
| 62 |
+
WorkspaceMember(id="u2", name="Bob", email="bob@uni.edu", role="member"),
|
| 63 |
+
],
|
| 64 |
+
).model_dump()
|
| 65 |
+
]
|
| 66 |
+
_write_json(WORKSPACE_FILE, {"items": workspaces})
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_courses() -> List[CourseDirectoryItem]:
|
| 70 |
+
seed_if_missing()
|
| 71 |
+
raw = _read_json(COURSE_FILE) or {}
|
| 72 |
+
items = raw.get("items", [])
|
| 73 |
+
out: List[CourseDirectoryItem] = []
|
| 74 |
+
for x in items:
|
| 75 |
+
out.append(CourseDirectoryItem(**x))
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def save_courses(courses: List[CourseDirectoryItem]) -> None:
|
| 80 |
+
_write_json(COURSE_FILE, {"items": [c.model_dump() for c in courses]})
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_workspaces() -> List[Workspace]:
|
| 84 |
+
seed_if_missing()
|
| 85 |
+
raw = _read_json(WORKSPACE_FILE) or {}
|
| 86 |
+
items = raw.get("items", [])
|
| 87 |
+
out: List[Workspace] = []
|
| 88 |
+
for x in items:
|
| 89 |
+
out.append(Workspace(**x))
|
| 90 |
+
return out
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def save_workspaces(workspaces: List[Workspace]) -> None:
|
| 94 |
+
_write_json(WORKSPACE_FILE, {"items": [w.model_dump() for w in workspaces]})
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def enrich_workspace_course_info(workspaces: List[Workspace], courses: List[CourseDirectoryItem]) -> List[Workspace]:
|
| 98 |
+
"""
|
| 99 |
+
Ensure workspace.courseInfo has instructor/TA to satisfy CourseInfoSection fallback.
|
| 100 |
+
Frontend prefers availableCourses, but this makes fallback robust.
|
| 101 |
+
"""
|
| 102 |
+
by_id = {c.id.strip().lower(): c for c in courses}
|
| 103 |
+
by_name = {c.name.strip().lower(): c for c in courses}
|
| 104 |
+
|
| 105 |
+
enriched: List[Workspace] = []
|
| 106 |
+
for w in workspaces:
|
| 107 |
+
ws_course = w.courseInfo
|
| 108 |
+
hit: Optional[CourseDirectoryItem] = None
|
| 109 |
+
|
| 110 |
+
if w.courseId:
|
| 111 |
+
hit = by_id.get(w.courseId.strip().lower())
|
| 112 |
+
if not hit and ws_course and ws_course.id:
|
| 113 |
+
hit = by_id.get(ws_course.id.strip().lower())
|
| 114 |
+
if not hit and ws_course and ws_course.name:
|
| 115 |
+
hit = by_name.get(ws_course.name.strip().lower())
|
| 116 |
+
|
| 117 |
+
if hit:
|
| 118 |
+
w.courseId = w.courseId or hit.id
|
| 119 |
+
w.courseInfo = WorkspaceCourseRef(
|
| 120 |
+
id=hit.id,
|
| 121 |
+
name=hit.name,
|
| 122 |
+
instructor=hit.instructor,
|
| 123 |
+
teachingAssistant=hit.teachingAssistant,
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
# keep whatever exists; CourseInfoSection will show fallback if nothing matches
|
| 127 |
+
w.courseInfo = ws_course
|
| 128 |
+
|
| 129 |
+
enriched.append(w)
|
| 130 |
+
|
| 131 |
+
return enriched
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def rename_workspace(workspace_id: str, new_name: str) -> Workspace:
|
| 135 |
+
workspaces = load_workspaces()
|
| 136 |
+
found = None
|
| 137 |
+
for w in workspaces:
|
| 138 |
+
if w.id == workspace_id:
|
| 139 |
+
w.name = new_name
|
| 140 |
+
found = w
|
| 141 |
+
break
|
| 142 |
+
if not found:
|
| 143 |
+
raise KeyError(f"workspace not found: {workspace_id}")
|
| 144 |
+
save_workspaces(workspaces)
|
| 145 |
+
return found
|
api/syllabus_utils.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/syllabus_utils.py
|
| 2 |
+
"""
|
| 3 |
+
工具函数:
|
| 4 |
+
- 解析 Syllabus(.docx / .pdf / .pptx)
|
| 5 |
+
- 提取课程大纲 topics
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
from docx import Document
|
| 14 |
+
from pypdf import PdfReader
|
| 15 |
+
from pptx import Presentation # python-pptx
|
| 16 |
+
|
| 17 |
+
from api.config import DEFAULT_COURSE_TOPICS
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_syllabus_docx(path: str) -> List[str]:
|
| 21 |
+
"""
|
| 22 |
+
从 .docx 文件中提取课程大纲。
|
| 23 |
+
简单版:按段落抽取,过滤空行;优先识别 Week 开头行。
|
| 24 |
+
"""
|
| 25 |
+
doc = Document(path)
|
| 26 |
+
paragraphs = [p.text.strip() for p in doc.paragraphs if p.text and p.text.strip()]
|
| 27 |
+
|
| 28 |
+
week_like = [p for p in paragraphs if p.lower().startswith("week ")]
|
| 29 |
+
if week_like:
|
| 30 |
+
return week_like
|
| 31 |
+
|
| 32 |
+
return paragraphs[: len(DEFAULT_COURSE_TOPICS)] or DEFAULT_COURSE_TOPICS
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def parse_syllabus_pdf(path: str) -> List[str]:
|
| 36 |
+
"""
|
| 37 |
+
简单版 PDF 解析:
|
| 38 |
+
- 抽取所有页文本
|
| 39 |
+
- 按空行切段
|
| 40 |
+
- 返回前若干段作为“课程大纲 topics”
|
| 41 |
+
"""
|
| 42 |
+
reader = PdfReader(path)
|
| 43 |
+
pages_text: List[str] = []
|
| 44 |
+
|
| 45 |
+
for page in reader.pages:
|
| 46 |
+
text = page.extract_text() or ""
|
| 47 |
+
if text.strip():
|
| 48 |
+
pages_text.append(text)
|
| 49 |
+
|
| 50 |
+
full_text = "\n".join(pages_text)
|
| 51 |
+
|
| 52 |
+
raw_chunks = [chunk.strip() for chunk in full_text.split("\n\n")]
|
| 53 |
+
chunks = [c for c in raw_chunks if c]
|
| 54 |
+
|
| 55 |
+
return chunks[: len(DEFAULT_COURSE_TOPICS)] or DEFAULT_COURSE_TOPICS
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def parse_pptx_slides(path: str) -> List[str]:
|
| 59 |
+
"""
|
| 60 |
+
从 .pptx 文件中抽取每一页 slide 的文本(每页一个块)。
|
| 61 |
+
"""
|
| 62 |
+
prs = Presentation(path)
|
| 63 |
+
slide_texts: List[str] = []
|
| 64 |
+
|
| 65 |
+
for slide in prs.slides:
|
| 66 |
+
lines: List[str] = []
|
| 67 |
+
for shape in slide.shapes:
|
| 68 |
+
if hasattr(shape, "text") and shape.text:
|
| 69 |
+
txt = shape.text.strip()
|
| 70 |
+
if txt:
|
| 71 |
+
lines.append(txt)
|
| 72 |
+
if lines:
|
| 73 |
+
slide_texts.append("\n".join(lines))
|
| 74 |
+
|
| 75 |
+
return slide_texts
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def extract_course_topics_from_file(file_obj, doc_type: str) -> List[str]:
|
| 79 |
+
"""
|
| 80 |
+
根据上传文件和 doc_type 提取课程大纲 topics。
|
| 81 |
+
- 只有 doc_type == "syllabus" 时才尝试从文件解析;否则用默认大纲。
|
| 82 |
+
- 支持 .docx / .pdf / .pptx
|
| 83 |
+
"""
|
| 84 |
+
if file_obj is None:
|
| 85 |
+
return DEFAULT_COURSE_TOPICS
|
| 86 |
+
|
| 87 |
+
doc_type_norm = (doc_type or "").strip().lower()
|
| 88 |
+
if doc_type_norm != "syllabus":
|
| 89 |
+
return DEFAULT_COURSE_TOPICS
|
| 90 |
+
|
| 91 |
+
# 这里必须是“真实可读路径”,你的 server.py 会传 fo.name = /tmp/xxx
|
| 92 |
+
file_path = getattr(file_obj, "name", None)
|
| 93 |
+
if not file_path or not os.path.exists(file_path):
|
| 94 |
+
print(f"[Syllabus] file path missing or not found: {file_path!r}")
|
| 95 |
+
return DEFAULT_COURSE_TOPICS
|
| 96 |
+
|
| 97 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
if ext == ".docx":
|
| 101 |
+
topics = parse_syllabus_docx(file_path)
|
| 102 |
+
elif ext == ".pdf":
|
| 103 |
+
topics = parse_syllabus_pdf(file_path)
|
| 104 |
+
elif ext == ".pptx":
|
| 105 |
+
topics = parse_pptx_slides(file_path)
|
| 106 |
+
else:
|
| 107 |
+
print(f"[Syllabus] Unsupported file type for syllabus: {ext}")
|
| 108 |
+
topics = DEFAULT_COURSE_TOPICS
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"[Syllabus] parse error: {repr(e)}")
|
| 111 |
+
topics = DEFAULT_COURSE_TOPICS
|
| 112 |
+
|
| 113 |
+
return topics or DEFAULT_COURSE_TOPICS
|
api/tts_podcast.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# api/tts_podcast.py
|
| 2 |
+
"""
|
| 3 |
+
Text-to-Speech and Podcast generation for Clare.
|
| 4 |
+
Uses OpenAI TTS API (same OPENAI_API_KEY as chat). Safe for Hugging Face deployment.
|
| 5 |
+
- Max 4096 characters per TTS request; long text is chunked.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import re
|
| 9 |
+
from typing import List, Tuple, Optional
|
| 10 |
+
|
| 11 |
+
from .config import client
|
| 12 |
+
|
| 13 |
+
# OpenAI TTS limits (see https://platform.openai.com/docs/guides/text-to-speech)
|
| 14 |
+
TTS_MAX_CHARS = 4096
|
| 15 |
+
TTS_MODEL = "tts-1" # or "tts-1-hd" for higher quality (slower)
|
| 16 |
+
TTS_VOICES = ("alloy", "echo", "fable", "onyx", "nova", "shimmer")
|
| 17 |
+
DEFAULT_VOICE = "nova"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _chunk_text_for_tts(text: str, max_chars: int = TTS_MAX_CHARS - 100) -> List[str]:
|
| 21 |
+
"""Split text into chunks under max_chars, trying to break at sentence boundaries."""
|
| 22 |
+
text = (text or "").strip()
|
| 23 |
+
if not text:
|
| 24 |
+
return []
|
| 25 |
+
if len(text) <= max_chars:
|
| 26 |
+
return [text]
|
| 27 |
+
|
| 28 |
+
chunks: List[str] = []
|
| 29 |
+
# Prefer splitting on sentence end
|
| 30 |
+
pattern = re.compile(r'(?<=[.!?。!?\n])\s+')
|
| 31 |
+
parts = pattern.split(text)
|
| 32 |
+
current = ""
|
| 33 |
+
for p in parts:
|
| 34 |
+
if len(current) + len(p) + 1 <= max_chars:
|
| 35 |
+
current = (current + " " + p).strip() if current else p
|
| 36 |
+
else:
|
| 37 |
+
if current:
|
| 38 |
+
chunks.append(current)
|
| 39 |
+
# If single part is too long, split by hard limit
|
| 40 |
+
if len(p) > max_chars:
|
| 41 |
+
for i in range(0, len(p), max_chars):
|
| 42 |
+
chunks.append(p[i : i + max_chars])
|
| 43 |
+
current = ""
|
| 44 |
+
else:
|
| 45 |
+
current = p
|
| 46 |
+
if current:
|
| 47 |
+
chunks.append(current)
|
| 48 |
+
return chunks
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def text_to_speech(
|
| 52 |
+
text: str,
|
| 53 |
+
voice: str = DEFAULT_VOICE,
|
| 54 |
+
model: str = TTS_MODEL,
|
| 55 |
+
) -> bytes:
|
| 56 |
+
"""
|
| 57 |
+
Convert text to MP3 audio using OpenAI TTS.
|
| 58 |
+
Long text is chunked and concatenated (binary concatenation of MP3 is valid).
|
| 59 |
+
"""
|
| 60 |
+
if not text or not text.strip():
|
| 61 |
+
return b""
|
| 62 |
+
voice = (voice or DEFAULT_VOICE).lower()
|
| 63 |
+
if voice not in TTS_VOICES:
|
| 64 |
+
voice = DEFAULT_VOICE
|
| 65 |
+
|
| 66 |
+
chunks = _chunk_text_for_tts(text)
|
| 67 |
+
if not chunks:
|
| 68 |
+
return b""
|
| 69 |
+
|
| 70 |
+
all_bytes: List[bytes] = []
|
| 71 |
+
for chunk in chunks:
|
| 72 |
+
if not chunk.strip():
|
| 73 |
+
continue
|
| 74 |
+
resp = client.audio.speech.create(
|
| 75 |
+
model=model,
|
| 76 |
+
voice=voice,
|
| 77 |
+
input=chunk,
|
| 78 |
+
)
|
| 79 |
+
all_bytes.append(resp.content)
|
| 80 |
+
|
| 81 |
+
return b"".join(all_bytes)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_podcast_script_from_history(
|
| 85 |
+
history: List[Tuple[str, str]],
|
| 86 |
+
intro_title: str = "Clare Learning Summary",
|
| 87 |
+
max_turns: int = 20,
|
| 88 |
+
) -> str:
|
| 89 |
+
"""
|
| 90 |
+
Build a podcast script from chat history: intro + alternating user question / assistant answer.
|
| 91 |
+
"""
|
| 92 |
+
lines: List[str] = []
|
| 93 |
+
lines.append(f"Welcome to {intro_title}. Here are the key points from your session with Clare.")
|
| 94 |
+
turns = (history or [])[:max_turns]
|
| 95 |
+
for i, (user_msg, assistant_msg) in enumerate(turns):
|
| 96 |
+
if user_msg and user_msg.strip():
|
| 97 |
+
lines.append(f"Question: {user_msg.strip()}")
|
| 98 |
+
if assistant_msg and assistant_msg.strip():
|
| 99 |
+
# Optional: truncate very long answers for listenability
|
| 100 |
+
msg = assistant_msg.strip()
|
| 101 |
+
if len(msg) > 1500:
|
| 102 |
+
msg = msg[:1500] + " ..."
|
| 103 |
+
lines.append(f"Clare: {msg}")
|
| 104 |
+
lines.append("Thanks for listening. Keep learning with Clare.")
|
| 105 |
+
return "\n\n".join(lines)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def build_podcast_script_from_summary(summary_md: str, intro_title: str = "Clare Summary Podcast") -> str:
|
| 109 |
+
"""Build a short podcast script from an existing summary markdown."""
|
| 110 |
+
if not summary_md or not summary_md.strip():
|
| 111 |
+
return f"Welcome to {intro_title}. No summary available for this session."
|
| 112 |
+
# Strip markdown for cleaner speech
|
| 113 |
+
text = summary_md.strip()
|
| 114 |
+
# Fix: patterns without capture groups should use empty string replacement
|
| 115 |
+
# Remove markdown headers (no capture group, replace with empty)
|
| 116 |
+
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
|
| 117 |
+
# Remove bold (**text** -> text)
|
| 118 |
+
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
| 119 |
+
# Remove italic (*text* -> text)
|
| 120 |
+
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
| 121 |
+
# Remove links ([text](url) -> text)
|
| 122 |
+
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
| 123 |
+
return f"Welcome to {intro_title}. {text} Thanks for listening."
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def generate_podcast_audio(
|
| 127 |
+
script: str,
|
| 128 |
+
voice: str = DEFAULT_VOICE,
|
| 129 |
+
model: str = TTS_MODEL,
|
| 130 |
+
) -> bytes:
|
| 131 |
+
"""Generate full podcast audio from a script (chunked TTS)."""
|
| 132 |
+
return text_to_speech(script, voice=voice, model=model)
|
app.py
ADDED
|
@@ -0,0 +1,1263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import base64
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import List, Dict, Tuple, Optional
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
from langsmith import Client # LangSmith 客户端
|
| 10 |
+
|
| 11 |
+
from config import (
|
| 12 |
+
DEFAULT_MODEL,
|
| 13 |
+
DEFAULT_COURSE_TOPICS,
|
| 14 |
+
LEARNING_MODES,
|
| 15 |
+
DOC_TYPES,
|
| 16 |
+
)
|
| 17 |
+
from clare_core import (
|
| 18 |
+
update_weaknesses_from_message,
|
| 19 |
+
update_cognitive_state_from_message,
|
| 20 |
+
render_session_status,
|
| 21 |
+
detect_language,
|
| 22 |
+
chat_with_clare,
|
| 23 |
+
export_conversation,
|
| 24 |
+
summarize_conversation,
|
| 25 |
+
)
|
| 26 |
+
from rag_engine import (
|
| 27 |
+
build_rag_chunks_from_file,
|
| 28 |
+
retrieve_relevant_chunks,
|
| 29 |
+
)
|
| 30 |
+
from syllabus_utils import extract_course_topics_from_file
|
| 31 |
+
|
| 32 |
+
# ================== Assets ==================
|
| 33 |
+
CLARE_LOGO_PATH = "clare_mascot.png"
|
| 34 |
+
CLARE_RUN_PATH = "Clare_Run.png"
|
| 35 |
+
CLARE_READING_PATH = "Clare_reading.png" # 确保存在
|
| 36 |
+
|
| 37 |
+
# ================== Base64 Helper ==================
|
| 38 |
+
def image_to_base64(image_path: str) -> str:
|
| 39 |
+
if not os.path.exists(image_path):
|
| 40 |
+
return ""
|
| 41 |
+
with open(image_path, "rb") as img_file:
|
| 42 |
+
encoded_string = base64.b64encode(img_file.read()).decode("utf-8")
|
| 43 |
+
if image_path.lower().endswith(".png"):
|
| 44 |
+
mime = "image/png"
|
| 45 |
+
elif image_path.lower().endswith((".jpg", ".jpeg")):
|
| 46 |
+
mime = "image/jpeg"
|
| 47 |
+
else:
|
| 48 |
+
mime = "image/png"
|
| 49 |
+
return f"data:{mime};base64,{encoded_string}"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ================== User Guide Content ==================
|
| 53 |
+
USER_GUIDE_SECTIONS = {
|
| 54 |
+
"getting_started": """
|
| 55 |
+
Welcome to **Clare — Your Personalized AI Tutor**.
|
| 56 |
+
|
| 57 |
+
For this controlled experiment, Clare is already pre-loaded with:
|
| 58 |
+
|
| 59 |
+
📘 **Module 10 Reading – Responsible AI (Alto, 2024, Chapter 12)**
|
| 60 |
+
|
| 61 |
+
You do **NOT** need to upload any materials.
|
| 62 |
+
You may optionally upload extra files, but Clare will always include the Module 10 reading as core context.
|
| 63 |
+
|
| 64 |
+
**To begin:**
|
| 65 |
+
1. Log in with your **Student Name + Email/ID** on the right.
|
| 66 |
+
2. Select your **Learning Mode** on the left.
|
| 67 |
+
3. (Optional) Upload additional Module 10 slides / notes at the top.
|
| 68 |
+
4. Ask Clare any question about **Module 10 – Responsible AI**.
|
| 69 |
+
""",
|
| 70 |
+
"mode_definition": """
|
| 71 |
+
Clare offers different teaching modes to match how you prefer to learn.
|
| 72 |
+
|
| 73 |
+
### Concept Explainer
|
| 74 |
+
Clear, structured explanations with examples — ideal for learning new topics.
|
| 75 |
+
|
| 76 |
+
### Socratic Tutor
|
| 77 |
+
Clare asks guiding questions instead of giving direct answers.
|
| 78 |
+
Helps you build reasoning and problem-solving skills.
|
| 79 |
+
|
| 80 |
+
### Exam Prep / Quiz
|
| 81 |
+
Generates short practice questions aligned with your course week.
|
| 82 |
+
Useful for self-testing and preparing for exams.
|
| 83 |
+
|
| 84 |
+
### Assignment Helper
|
| 85 |
+
Helps you interpret assignment prompts, plan structure, and understand requirements.
|
| 86 |
+
❗ Clare does **not** produce full assignment answers (academic integrity).
|
| 87 |
+
|
| 88 |
+
### Quick Summary
|
| 89 |
+
Gives brief summaries of slides, reading materials, or long questions.
|
| 90 |
+
""",
|
| 91 |
+
"how_clare_works": """
|
| 92 |
+
Clare combines **course context + learning science + AI reasoning** to generate answers.
|
| 93 |
+
|
| 94 |
+
For this experiment, Clare always includes:
|
| 95 |
+
|
| 96 |
+
- Module 10 Reading – Responsible AI (Alto, 2024, Chapter 12)
|
| 97 |
+
- Any additional Module 10 files you upload
|
| 98 |
+
|
| 99 |
+
Clare uses:
|
| 100 |
+
- **Learning Mode**: tone, depth, and interaction style.
|
| 101 |
+
- **Reinforcement model**: may prioritize concepts you’re likely to forget.
|
| 102 |
+
- **Responsible AI principles**: avoids harmful output and preserves academic integrity.
|
| 103 |
+
""",
|
| 104 |
+
"memory_line": """
|
| 105 |
+
**Memory Line** is a visualization of your *learning reinforcement cycle*.
|
| 106 |
+
|
| 107 |
+
Based on the **forgetting-curve model**, Clare organizes your review topics into:
|
| 108 |
+
- **T+0 (Current Week)** – new concepts
|
| 109 |
+
- **T+7** – first spaced review
|
| 110 |
+
- **T+14** – reinforcement review
|
| 111 |
+
- **T+30** – long-term consolidation
|
| 112 |
+
|
| 113 |
+
In this experiment, Memory Line should be interpreted as your **Module 10** reinforcement status.
|
| 114 |
+
""",
|
| 115 |
+
"learning_progress": """
|
| 116 |
+
The Learning Progress Report highlights:
|
| 117 |
+
- **Concepts mastered**
|
| 118 |
+
- **Concepts in progress**
|
| 119 |
+
- **Concepts due for review**
|
| 120 |
+
- Your recent **micro-quiz results**
|
| 121 |
+
- Suggested **next-step topics**
|
| 122 |
+
""",
|
| 123 |
+
"how_files": """
|
| 124 |
+
Your uploaded materials help Clare:
|
| 125 |
+
|
| 126 |
+
- Align explanations with your exact course (here: **Module 10 – Responsible AI**)
|
| 127 |
+
- Use terminology consistent with your professor
|
| 128 |
+
- Improve factual accuracy
|
| 129 |
+
|
| 130 |
+
🔒 **Privacy**
|
| 131 |
+
- Files are used only within your session
|
| 132 |
+
- They are not kept as permanent training data
|
| 133 |
+
|
| 134 |
+
Accepted formats: **.docx / .pdf / .pptx**
|
| 135 |
+
|
| 136 |
+
For this experiment, Clare is **already pre-loaded** with the Module 10 reading. Uploads are optional.
|
| 137 |
+
""",
|
| 138 |
+
"micro_quiz": """
|
| 139 |
+
The **Micro-Quiz** function provides a:
|
| 140 |
+
|
| 141 |
+
- 1-minute self-check
|
| 142 |
+
- 1–3 questions about **Module 10 – Responsible AI**
|
| 143 |
+
- Instant feedback inside the main chat
|
| 144 |
+
|
| 145 |
+
**How it works:**
|
| 146 |
+
1. Click “Let’s Try (Micro-Quiz)” on the right.
|
| 147 |
+
2. Clare will send the **first quiz question** in the main chat.
|
| 148 |
+
3. Type your answer in the chat box.
|
| 149 |
+
4. Clare will:
|
| 150 |
+
- Judge correctness
|
| 151 |
+
- Give a brief explanation
|
| 152 |
+
- Ask if you want another question
|
| 153 |
+
5. You can continue or say “stop” at any time.
|
| 154 |
+
""",
|
| 155 |
+
"summarization": """
|
| 156 |
+
Clare can summarize:
|
| 157 |
+
|
| 158 |
+
- Module 10 reading
|
| 159 |
+
- Uploaded slides / notes
|
| 160 |
+
- Long conversation threads
|
| 161 |
+
""",
|
| 162 |
+
"export_conversation": """
|
| 163 |
+
You can export your chat session for:
|
| 164 |
+
|
| 165 |
+
- Study review
|
| 166 |
+
- Exam preparation
|
| 167 |
+
- Saving important explanations
|
| 168 |
+
|
| 169 |
+
Export format: **Markdown / plain text**.
|
| 170 |
+
""",
|
| 171 |
+
"faq": """
|
| 172 |
+
**Q: Does Clare give assignment answers?**
|
| 173 |
+
No. Clare assists with understanding and planning but does **not** generate full solutions.
|
| 174 |
+
|
| 175 |
+
**Q: Does Clare replace lectures or TA office hours?**
|
| 176 |
+
No. Clare supplements your learning by providing on-demand guidance.
|
| 177 |
+
|
| 178 |
+
**Q: What languages does Clare support?**
|
| 179 |
+
Currently: English & 简体中文.
|
| 180 |
+
""",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# ================== CSS ==================
|
| 184 |
+
CUSTOM_CSS = """
|
| 185 |
+
/* --- Main Header --- */
|
| 186 |
+
.header-container { padding: 10px 20px; background-color: #ffffff; border-bottom: 2px solid #f3f4f6; margin-bottom: 15px; display: flex; align-items: center; }
|
| 187 |
+
|
| 188 |
+
/* --- Sidebar Login Panel --- */
|
| 189 |
+
.login-panel {
|
| 190 |
+
background-color: #e5e7eb;
|
| 191 |
+
padding: 15px;
|
| 192 |
+
border-radius: 8px;
|
| 193 |
+
text-align: center;
|
| 194 |
+
margin-bottom: 20px;
|
| 195 |
+
}
|
| 196 |
+
.login-panel img {
|
| 197 |
+
display: block;
|
| 198 |
+
margin: 0 auto 10px auto;
|
| 199 |
+
height: 80px;
|
| 200 |
+
object-fit: contain;
|
| 201 |
+
}
|
| 202 |
+
.login-main-btn {
|
| 203 |
+
background-color: #ffffff !important;
|
| 204 |
+
color: #000 !important;
|
| 205 |
+
border: 1px solid #000 !important;
|
| 206 |
+
font-weight: bold !important;
|
| 207 |
+
}
|
| 208 |
+
.logout-btn {
|
| 209 |
+
background-color: #6b2828 !important;
|
| 210 |
+
color: #fff !important;
|
| 211 |
+
border: none !important;
|
| 212 |
+
font-weight: bold !important;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* User Guide */
|
| 216 |
+
.main-user-guide { border: none !important; background: transparent !important; box-shadow: none !important; }
|
| 217 |
+
.main-user-guide > .label-wrap { border: none !important; background: transparent !important; padding: 10px 0 !important; }
|
| 218 |
+
.main-user-guide > .label-wrap span { font-size: 1.3rem !important; font-weight: 800 !important; color: #111827 !important; }
|
| 219 |
+
|
| 220 |
+
.clean-accordion { border: none !important; background: transparent !important; box-shadow: none !important; margin-bottom: 0px !important; padding: 0 !important; border-radius: 0 !important; }
|
| 221 |
+
.clean-accordion > .label-wrap { padding: 8px 5px !important; border: none !important; background: transparent !important; border-bottom: 1px solid #e5e7eb !important; }
|
| 222 |
+
.clean-accordion > .label-wrap span { font-size: 0.9rem !important; font-weight: 500 !important; color: #374151 !important; }
|
| 223 |
+
.clean-accordion > .label-wrap:hover { background-color: #f9fafb !important; }
|
| 224 |
+
|
| 225 |
+
/* Action Buttons */
|
| 226 |
+
.action-btn { font-weight: bold !important; font-size: 0.9rem !important; position: relative; overflow: visible !important; }
|
| 227 |
+
|
| 228 |
+
/* Tooltips & Memory Line */
|
| 229 |
+
.html-tooltip { border-bottom: 1px dashed #999; cursor: help; position: relative; }
|
| 230 |
+
.html-tooltip:hover::before { content: attr(data-tooltip); position: absolute; bottom: 120%; left: 0; background-color: #333; color: #fff; padding: 5px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; z-index: 100; pointer-events: none; }
|
| 231 |
+
.memory-line-box { border: 1px solid #e5e7eb; padding: 12px; border-radius: 8px; background-color: #f9fafb; height: 100%; display: flex; flex-direction: column; justify-content: space-between; }
|
| 232 |
+
|
| 233 |
+
/* Results Box Style */
|
| 234 |
+
.result-box { border: 1px solid #e5e7eb; background: #ffffff; padding: 10px; border-radius: 8px; height: 100%; }
|
| 235 |
+
.result-box .prose { font-size: 0.9rem; }
|
| 236 |
+
"""
|
| 237 |
+
|
| 238 |
+
# ========== Preload Module 10 PDF ==========
|
| 239 |
+
MODULE10_PATH = "module10_responsible_ai.pdf"
|
| 240 |
+
MODULE10_DOC_TYPE = "Literature Review / Paper"
|
| 241 |
+
|
| 242 |
+
preloaded_topics: List[str] = []
|
| 243 |
+
preloaded_chunks: List[Dict] = []
|
| 244 |
+
|
| 245 |
+
if os.path.exists(MODULE10_PATH):
|
| 246 |
+
try:
|
| 247 |
+
preloaded_topics = extract_course_topics_from_file(MODULE10_PATH, MODULE10_DOC_TYPE)
|
| 248 |
+
preloaded_chunks = build_rag_chunks_from_file(MODULE10_PATH, MODULE10_DOC_TYPE)
|
| 249 |
+
print("Module 10 PDF preloaded successfully.")
|
| 250 |
+
except Exception as e:
|
| 251 |
+
print("Module 10 preload failed:", e)
|
| 252 |
+
else:
|
| 253 |
+
print("Module 10 PDF not found at path:", MODULE10_PATH)
|
| 254 |
+
|
| 255 |
+
# ===== LangSmith logging =====
|
| 256 |
+
ls_client = Client()
|
| 257 |
+
LS_DATASET_NAME = "clare_user_events"
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def log_event(data: Dict):
|
| 261 |
+
"""
|
| 262 |
+
把日志写入 LangSmith Dataset (clare_user_events)
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
inputs = {
|
| 266 |
+
"question": data.get("question"),
|
| 267 |
+
"student_id": data.get("student_id"),
|
| 268 |
+
}
|
| 269 |
+
metadata = {k: v for k, v in data.items() if k not in ("question", "answer")}
|
| 270 |
+
|
| 271 |
+
ls_client.create_example(
|
| 272 |
+
inputs=inputs,
|
| 273 |
+
outputs={"answer": data.get("answer")},
|
| 274 |
+
metadata=metadata,
|
| 275 |
+
dataset_name=LS_DATASET_NAME,
|
| 276 |
+
)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
print("LangSmith log failed:", e)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ===== Reference Formatting Helper =====
|
| 282 |
+
def format_references(rag_chunks: List[Dict], max_files: int = 2, max_sections_per_file: int = 3) -> str:
|
| 283 |
+
if not rag_chunks:
|
| 284 |
+
return "\n".join(
|
| 285 |
+
[
|
| 286 |
+
"**References:**",
|
| 287 |
+
"- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)",
|
| 288 |
+
]
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
chunks = list(rag_chunks or [])
|
| 292 |
+
chunks.sort(key=lambda c: float(c.get("_rag_score", 0.0)), reverse=True)
|
| 293 |
+
|
| 294 |
+
refs_by_file: Dict[str, List[str]] = defaultdict(list)
|
| 295 |
+
|
| 296 |
+
for chunk in chunks:
|
| 297 |
+
file_name = chunk.get("source_file") or "module10_responsible_ai.pdf"
|
| 298 |
+
section = chunk.get("section") or "Related section"
|
| 299 |
+
score = chunk.get("_rag_score")
|
| 300 |
+
score_str = f" (score={float(score):.2f})" if score is not None else ""
|
| 301 |
+
entry = section + score_str
|
| 302 |
+
if entry not in refs_by_file[file_name]:
|
| 303 |
+
refs_by_file[file_name].append(entry)
|
| 304 |
+
|
| 305 |
+
if not refs_by_file:
|
| 306 |
+
return "\n".join(
|
| 307 |
+
[
|
| 308 |
+
"**References:**",
|
| 309 |
+
"- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)",
|
| 310 |
+
]
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
lines = ["**References (RAG context used):**"]
|
| 314 |
+
for i, (file_name, sections) in enumerate(refs_by_file.items()):
|
| 315 |
+
if i >= max_files:
|
| 316 |
+
break
|
| 317 |
+
short_sections = sections[:max_sections_per_file]
|
| 318 |
+
if short_sections:
|
| 319 |
+
section_str = "; ".join(short_sections)
|
| 320 |
+
lines.append(f"- *{file_name}* — {section_str}")
|
| 321 |
+
else:
|
| 322 |
+
lines.append(f"- *{file_name}*")
|
| 323 |
+
|
| 324 |
+
if len(lines) <= 1:
|
| 325 |
+
return "\n".join(
|
| 326 |
+
[
|
| 327 |
+
"**References:**",
|
| 328 |
+
"- (No RAG context used. Answer is based on the model's general knowledge. Web search: not used.)",
|
| 329 |
+
]
|
| 330 |
+
)
|
| 331 |
+
return "\n".join(lines)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def is_academic_query(message: str) -> bool:
|
| 335 |
+
if not message:
|
| 336 |
+
return False
|
| 337 |
+
m = message.strip().lower()
|
| 338 |
+
if not m:
|
| 339 |
+
return False
|
| 340 |
+
m = " ".join(m.split())
|
| 341 |
+
|
| 342 |
+
smalltalk_tokens = {"hi", "hello", "hey", "yo", "thanks", "thank", "thank you", "ok", "okay", "bye", "goodbye", "see you", "haha", "lol"}
|
| 343 |
+
tokens = m.split()
|
| 344 |
+
|
| 345 |
+
if "?" not in m and all(t in smalltalk_tokens for t in tokens):
|
| 346 |
+
return False
|
| 347 |
+
|
| 348 |
+
meta_phrases = [
|
| 349 |
+
"who are you",
|
| 350 |
+
"what are you",
|
| 351 |
+
"what is your name",
|
| 352 |
+
"introduce yourself",
|
| 353 |
+
"tell me about yourself",
|
| 354 |
+
"what can you do",
|
| 355 |
+
"how can you help",
|
| 356 |
+
"how do you help",
|
| 357 |
+
"how do i use",
|
| 358 |
+
"how to use this",
|
| 359 |
+
"what is this app",
|
| 360 |
+
"what is this tool",
|
| 361 |
+
"what is clare",
|
| 362 |
+
"who is clare",
|
| 363 |
+
]
|
| 364 |
+
if any(p in m for p in meta_phrases):
|
| 365 |
+
return False
|
| 366 |
+
|
| 367 |
+
if len(tokens) <= 2 and "?" not in m:
|
| 368 |
+
return False
|
| 369 |
+
|
| 370 |
+
return True
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def normalize_lang_pref(lang_pref: str) -> str:
|
| 374 |
+
# UI uses "简体中文", core uses "中文"
|
| 375 |
+
if lang_pref == "简体中文":
|
| 376 |
+
return "中文"
|
| 377 |
+
return lang_pref
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# ================== Gradio App ==================
|
| 381 |
+
with gr.Blocks(title="Clare – Hanbridge AI Teaching Assistant", css=CUSTOM_CSS) as demo:
|
| 382 |
+
# 全局状态
|
| 383 |
+
course_outline_state = gr.State(preloaded_topics or DEFAULT_COURSE_TOPICS)
|
| 384 |
+
weakness_state = gr.State([])
|
| 385 |
+
cognitive_state_state = gr.State({"confusion": 0, "mastery": 0})
|
| 386 |
+
rag_chunks_state = gr.State(preloaded_chunks or [])
|
| 387 |
+
|
| 388 |
+
last_question_state = gr.State("")
|
| 389 |
+
last_answer_state = gr.State("")
|
| 390 |
+
|
| 391 |
+
user_name_state = gr.State("")
|
| 392 |
+
user_id_state = gr.State("")
|
| 393 |
+
|
| 394 |
+
feedback_used_state = gr.State(False)
|
| 395 |
+
|
| 396 |
+
# --- Header ---
|
| 397 |
+
with gr.Row(elem_classes="header-container"):
|
| 398 |
+
with gr.Column(scale=3):
|
| 399 |
+
gr.HTML(
|
| 400 |
+
f"""
|
| 401 |
+
<div style="display:flex; align-items:center; gap: 20px;">
|
| 402 |
+
<img src="{image_to_base64(CLARE_LOGO_PATH)}" style="height: 75px; object-fit: contain;">
|
| 403 |
+
<div style="display:flex; flex-direction:column;">
|
| 404 |
+
<div style="font-size: 32px; font-weight: 800; line-height: 1.1; color: #000;">
|
| 405 |
+
Clare
|
| 406 |
+
<span style="font-size: 18px; font-weight: 600; margin-left: 10px;">Your Personalized AI Tutor</span>
|
| 407 |
+
</div>
|
| 408 |
+
<div style="font-size: 14px; font-style: italic; color: #333; margin-top: 4px;">
|
| 409 |
+
Personalized guidance, review, and intelligent reinforcement
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
"""
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
# --- Main Layout ---
|
| 417 |
+
with gr.Row():
|
| 418 |
+
# === Left Sidebar ===
|
| 419 |
+
with gr.Column(scale=1, min_width=200):
|
| 420 |
+
clear_btn = gr.Button("Reset Conversation", variant="stop", interactive=False)
|
| 421 |
+
|
| 422 |
+
gr.Markdown("### Model Settings")
|
| 423 |
+
model_name = gr.Textbox(label="Model", value="gpt-4.1-mini", interactive=False, lines=1)
|
| 424 |
+
language_preference = gr.Radio(
|
| 425 |
+
choices=["Auto", "English", "简体中文"],
|
| 426 |
+
value="Auto",
|
| 427 |
+
label="Language",
|
| 428 |
+
interactive=False,
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
learning_mode = gr.Radio(
|
| 432 |
+
choices=LEARNING_MODES,
|
| 433 |
+
value="Concept Explainer",
|
| 434 |
+
label="Learning Mode",
|
| 435 |
+
info="See User Guide for mode definition details.",
|
| 436 |
+
interactive=False,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
with gr.Accordion("User Guide", open=True, elem_classes="main-user-guide"):
|
| 440 |
+
with gr.Accordion("Getting Started", open=False, elem_classes="clean-accordion"):
|
| 441 |
+
gr.Markdown(USER_GUIDE_SECTIONS["getting_started"])
|
| 442 |
+
with gr.Accordion("Mode Definition", open=False, elem_classes="clean-accordion"):
|
| 443 |
+
gr.Markdown(USER_GUIDE_SECTIONS["mode_definition"])
|
| 444 |
+
with gr.Accordion("How Clare Works", open=False, elem_classes="clean-accordion"):
|
| 445 |
+
gr.Markdown(USER_GUIDE_SECTIONS["how_clare_works"])
|
| 446 |
+
with gr.Accordion("What is Memory Line", open=False, elem_classes="clean-accordion"):
|
| 447 |
+
gr.Markdown(USER_GUIDE_SECTIONS["memory_line"])
|
| 448 |
+
with gr.Accordion("Learning Progress Report", open=False, elem_classes="clean-accordion"):
|
| 449 |
+
gr.Markdown(USER_GUIDE_SECTIONS["learning_progress"])
|
| 450 |
+
with gr.Accordion("How Clare Uses Your Files", open=False, elem_classes="clean-accordion"):
|
| 451 |
+
gr.Markdown(USER_GUIDE_SECTIONS["how_files"])
|
| 452 |
+
with gr.Accordion("Micro-Quiz", open=False, elem_classes="clean-accordion"):
|
| 453 |
+
gr.Markdown(USER_GUIDE_SECTIONS["micro_quiz"])
|
| 454 |
+
with gr.Accordion("Summarization", open=False, elem_classes="clean-accordion"):
|
| 455 |
+
gr.Markdown(USER_GUIDE_SECTIONS["summarization"])
|
| 456 |
+
with gr.Accordion("Export Conversation", open=False, elem_classes="clean-accordion"):
|
| 457 |
+
gr.Markdown(USER_GUIDE_SECTIONS["export_conversation"])
|
| 458 |
+
with gr.Accordion("FAQ", open=False, elem_classes="clean-accordion"):
|
| 459 |
+
gr.Markdown(USER_GUIDE_SECTIONS["faq"])
|
| 460 |
+
|
| 461 |
+
gr.Markdown("---")
|
| 462 |
+
gr.Button("System Settings", size="sm", variant="secondary", interactive=False)
|
| 463 |
+
|
| 464 |
+
gr.HTML(
|
| 465 |
+
"""
|
| 466 |
+
<div style="font-size: 11px; color: #9ca3af; margin-top: 15px; text-align: left;">
|
| 467 |
+
© 2025 Made by <a href="https://www.linkedin.com/in/qinghua-xia-479199252/" target="_blank" style="color: #6b7280; text-decoration: underline;">Sarah Xia</a>
|
| 468 |
+
</div>
|
| 469 |
+
"""
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
# === Center Main ===
|
| 473 |
+
with gr.Column(scale=3):
|
| 474 |
+
gr.Markdown(
|
| 475 |
+
"""
|
| 476 |
+
<div style="background-color:#f9fafb; padding:10px; border-radius:5px; margin-top:10px; font-size:0.9em; color:#555;">
|
| 477 |
+
✦ <b>Instruction:</b> This prototype is <b>pre-loaded</b> with <b>Module 10 – Responsible AI (Alto, 2024, Chapter 12)</b>.<br>
|
| 478 |
+
✦ You do <b>not</b> need to upload files (uploads are optional).<br>
|
| 479 |
+
✦ Please log in on the right before chatting with Clare.
|
| 480 |
+
</div>
|
| 481 |
+
"""
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
chatbot = gr.Chatbot(
|
| 485 |
+
label="",
|
| 486 |
+
height=450,
|
| 487 |
+
avatar_images=(None, CLARE_LOGO_PATH),
|
| 488 |
+
show_label=False,
|
| 489 |
+
type="tuples",
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
gr.Markdown("#### Rate Clare’s last answer")
|
| 493 |
+
with gr.Row():
|
| 494 |
+
thumb_up_btn = gr.Button("👍 Helpful", size="sm", interactive=False)
|
| 495 |
+
thumb_down_btn = gr.Button("👎 Not helpful", size="sm", interactive=False)
|
| 496 |
+
|
| 497 |
+
feedback_toggle_btn = gr.Button(
|
| 498 |
+
"Give detailed feedback", size="sm", variant="secondary", interactive=False
|
| 499 |
+
)
|
| 500 |
+
feedback_text = gr.Textbox(
|
| 501 |
+
label="What worked well or what was wrong?",
|
| 502 |
+
placeholder="Optional: describe what you liked / what was confusing or incorrect.",
|
| 503 |
+
lines=3,
|
| 504 |
+
visible=False,
|
| 505 |
+
)
|
| 506 |
+
feedback_submit_btn = gr.Button(
|
| 507 |
+
"Submit Feedback", size="sm", variant="primary", visible=False, interactive=False
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
user_input = gr.Textbox(
|
| 511 |
+
label="Your Input",
|
| 512 |
+
placeholder="Please log in on the right before asking Clare anything...",
|
| 513 |
+
show_label=False,
|
| 514 |
+
container=True,
|
| 515 |
+
autofocus=False,
|
| 516 |
+
interactive=False,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
with gr.Row():
|
| 520 |
+
with gr.Column(scale=2):
|
| 521 |
+
syllabus_file = gr.File(
|
| 522 |
+
file_types=[".docx", ".pdf", ".pptx"],
|
| 523 |
+
file_count="single",
|
| 524 |
+
height=160,
|
| 525 |
+
label="Upload additional Module 10 file (.docx/.pdf/.pptx) — optional",
|
| 526 |
+
interactive=False,
|
| 527 |
+
)
|
| 528 |
+
with gr.Column(scale=1):
|
| 529 |
+
doc_type = gr.Dropdown(
|
| 530 |
+
choices=DOC_TYPES,
|
| 531 |
+
value="Syllabus",
|
| 532 |
+
label="File type",
|
| 533 |
+
container=True,
|
| 534 |
+
interactive=False,
|
| 535 |
+
)
|
| 536 |
+
gr.HTML("<div style='height:5px'></div>")
|
| 537 |
+
docs_btn = gr.Button("📂 Loaded Docs", size="sm", variant="secondary", interactive=False)
|
| 538 |
+
with gr.Column(scale=2):
|
| 539 |
+
with gr.Group(elem_classes="memory-line-box"):
|
| 540 |
+
gr.HTML(
|
| 541 |
+
f"""
|
| 542 |
+
<div style="font-weight:bold; font-size:14px; margin-bottom:5px;">
|
| 543 |
+
<span class="html-tooltip" data-tooltip="See User Guide for explanation">Memory Line</span>
|
| 544 |
+
</div>
|
| 545 |
+
<div style="position: relative; height: 35px; margin-top: 10px; margin-bottom: 5px;">
|
| 546 |
+
<div style="position: absolute; bottom: 5px; left: 0; width: 100%; height: 8px; background-color: #e5e7eb; border-radius: 4px;"></div>
|
| 547 |
+
<div style="position: absolute; bottom: 5px; left: 0; width: 40%; height: 8px; background-color: #8B1A1A; border-radius: 4px 0 0 4px;"></div>
|
| 548 |
+
<img src="{image_to_base64(CLARE_RUN_PATH)}" style="position: absolute; left: 36%; bottom: 8px; height: 35px; z-index: 10;">
|
| 549 |
+
</div>
|
| 550 |
+
<div style="display:flex; justify-content:space-between; align-items:center;">
|
| 551 |
+
<div style="font-size: 12px; color: #666;">Next Review: T+7</div>
|
| 552 |
+
<div style="font-size: 12px; color: #004a99; text-decoration:underline; cursor:pointer;">Report ⬇️</div>
|
| 553 |
+
</div>
|
| 554 |
+
"""
|
| 555 |
+
)
|
| 556 |
+
review_btn = gr.Button("Review Now", size="sm", variant="primary", interactive=False)
|
| 557 |
+
session_status = gr.Markdown(visible=False)
|
| 558 |
+
|
| 559 |
+
# === Right Sidebar ===
|
| 560 |
+
with gr.Column(scale=1, min_width=180):
|
| 561 |
+
with gr.Group(elem_classes="login-panel"):
|
| 562 |
+
gr.HTML(f"<img src='{image_to_base64(CLARE_READING_PATH)}'>")
|
| 563 |
+
|
| 564 |
+
with gr.Group(visible=True) as login_state_1:
|
| 565 |
+
login_start_btn = gr.Button("Student Login", elem_classes="login-main-btn")
|
| 566 |
+
|
| 567 |
+
with gr.Group(visible=False) as login_state_2:
|
| 568 |
+
name_input = gr.Textbox(label="Student Name", placeholder="Name", container=True)
|
| 569 |
+
id_input = gr.Textbox(label="Email/ID", placeholder="ID", container=True)
|
| 570 |
+
login_confirm_btn = gr.Button("Enter", variant="primary", size="sm")
|
| 571 |
+
|
| 572 |
+
with gr.Group(visible=False) as login_state_3:
|
| 573 |
+
student_info_html = gr.HTML()
|
| 574 |
+
logout_btn = gr.Button("Log out", elem_classes="logout-btn", size="sm")
|
| 575 |
+
|
| 576 |
+
gr.Markdown("### Actions")
|
| 577 |
+
export_btn = gr.Button("Export Conversation", size="sm", elem_classes="action-btn", interactive=False)
|
| 578 |
+
quiz_btn = gr.Button("Let's Try (Micro-Quiz)", size="sm", elem_classes="action-btn", interactive=False)
|
| 579 |
+
summary_btn = gr.Button("Summarization", size="sm", elem_classes="action-btn", interactive=False)
|
| 580 |
+
|
| 581 |
+
gr.Markdown("### Results")
|
| 582 |
+
with gr.Group(elem_classes="result-box"):
|
| 583 |
+
result_display = gr.Markdown(
|
| 584 |
+
value="Results (export / summary) will appear here...",
|
| 585 |
+
label="Generated Content",
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
# ================== Login Flow ==================
|
| 589 |
+
def show_inputs():
|
| 590 |
+
return {
|
| 591 |
+
login_state_1: gr.update(visible=False),
|
| 592 |
+
login_state_2: gr.update(visible=True),
|
| 593 |
+
login_state_3: gr.update(visible=False),
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
login_start_btn.click(show_inputs, outputs=[login_state_1, login_state_2, login_state_3])
|
| 597 |
+
|
| 598 |
+
def confirm_login(name, id_val):
|
| 599 |
+
if not name or not id_val:
|
| 600 |
+
return {
|
| 601 |
+
login_state_1: gr.update(),
|
| 602 |
+
login_state_2: gr.update(),
|
| 603 |
+
login_state_3: gr.update(),
|
| 604 |
+
student_info_html: gr.update(
|
| 605 |
+
value="<p style='color:red; font-size:12px;'>Please enter both Name and Email/ID to start.</p>"
|
| 606 |
+
),
|
| 607 |
+
user_name_state: gr.update(),
|
| 608 |
+
user_id_state: gr.update(),
|
| 609 |
+
feedback_used_state: False,
|
| 610 |
+
user_input: gr.update(interactive=False),
|
| 611 |
+
clear_btn: gr.update(interactive=False),
|
| 612 |
+
export_btn: gr.update(interactive=False),
|
| 613 |
+
quiz_btn: gr.update(interactive=False),
|
| 614 |
+
summary_btn: gr.update(interactive=False),
|
| 615 |
+
syllabus_file: gr.update(interactive=False),
|
| 616 |
+
doc_type: gr.update(interactive=False),
|
| 617 |
+
review_btn: gr.update(interactive=False),
|
| 618 |
+
language_preference: gr.update(interactive=False),
|
| 619 |
+
learning_mode: gr.update(interactive=False),
|
| 620 |
+
model_name: gr.update(interactive=False),
|
| 621 |
+
docs_btn: gr.update(interactive=False),
|
| 622 |
+
thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"),
|
| 623 |
+
thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"),
|
| 624 |
+
feedback_toggle_btn: gr.update(interactive=False),
|
| 625 |
+
feedback_text: gr.update(visible=False, value=""),
|
| 626 |
+
feedback_submit_btn: gr.update(interactive=False, visible=False),
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
info_html = f"""
|
| 630 |
+
<div style="margin-bottom:10px;">
|
| 631 |
+
<div style="font-weight:bold; font-size:16px;">{name}</div>
|
| 632 |
+
<div style="color:#666; font-size:12px;">{id_val}</div>
|
| 633 |
+
</div>
|
| 634 |
+
"""
|
| 635 |
+
return {
|
| 636 |
+
login_state_1: gr.update(visible=False),
|
| 637 |
+
login_state_2: gr.update(visible=False),
|
| 638 |
+
login_state_3: gr.update(visible=True),
|
| 639 |
+
student_info_html: gr.update(value=info_html),
|
| 640 |
+
user_name_state: name,
|
| 641 |
+
user_id_state: id_val,
|
| 642 |
+
feedback_used_state: False,
|
| 643 |
+
user_input: gr.update(
|
| 644 |
+
interactive=True,
|
| 645 |
+
placeholder="Ask about Module 10 concepts, Responsible AI, or let Clare test you...",
|
| 646 |
+
),
|
| 647 |
+
clear_btn: gr.update(interactive=True),
|
| 648 |
+
export_btn: gr.update(interactive=True),
|
| 649 |
+
quiz_btn: gr.update(interactive=True),
|
| 650 |
+
summary_btn: gr.update(interactive=True),
|
| 651 |
+
syllabus_file: gr.update(interactive=True),
|
| 652 |
+
doc_type: gr.update(interactive=True),
|
| 653 |
+
review_btn: gr.update(interactive=True),
|
| 654 |
+
language_preference: gr.update(interactive=True),
|
| 655 |
+
learning_mode: gr.update(interactive=True),
|
| 656 |
+
model_name: gr.update(interactive=False),
|
| 657 |
+
docs_btn: gr.update(interactive=True),
|
| 658 |
+
thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"),
|
| 659 |
+
thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"),
|
| 660 |
+
feedback_toggle_btn: gr.update(interactive=True),
|
| 661 |
+
feedback_text: gr.update(visible=False, value=""),
|
| 662 |
+
feedback_submit_btn: gr.update(interactive=True, visible=False),
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
login_confirm_btn.click(
|
| 666 |
+
confirm_login,
|
| 667 |
+
inputs=[name_input, id_input],
|
| 668 |
+
outputs=[
|
| 669 |
+
login_state_1,
|
| 670 |
+
login_state_2,
|
| 671 |
+
login_state_3,
|
| 672 |
+
student_info_html,
|
| 673 |
+
user_name_state,
|
| 674 |
+
user_id_state,
|
| 675 |
+
feedback_used_state,
|
| 676 |
+
user_input,
|
| 677 |
+
clear_btn,
|
| 678 |
+
export_btn,
|
| 679 |
+
quiz_btn,
|
| 680 |
+
summary_btn,
|
| 681 |
+
syllabus_file,
|
| 682 |
+
doc_type,
|
| 683 |
+
review_btn,
|
| 684 |
+
language_preference,
|
| 685 |
+
learning_mode,
|
| 686 |
+
model_name,
|
| 687 |
+
docs_btn,
|
| 688 |
+
thumb_up_btn,
|
| 689 |
+
thumb_down_btn,
|
| 690 |
+
feedback_toggle_btn,
|
| 691 |
+
feedback_text,
|
| 692 |
+
feedback_submit_btn,
|
| 693 |
+
],
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
def logout():
|
| 697 |
+
return {
|
| 698 |
+
login_state_1: gr.update(visible=True),
|
| 699 |
+
login_state_2: gr.update(visible=False),
|
| 700 |
+
login_state_3: gr.update(visible=False),
|
| 701 |
+
name_input: gr.update(value=""),
|
| 702 |
+
id_input: gr.update(value=""),
|
| 703 |
+
user_name_state: "",
|
| 704 |
+
user_id_state: "",
|
| 705 |
+
feedback_used_state: False,
|
| 706 |
+
student_info_html: gr.update(value=""),
|
| 707 |
+
user_input: gr.update(
|
| 708 |
+
value="",
|
| 709 |
+
interactive=False,
|
| 710 |
+
placeholder="Please log in on the right before asking Clare anything...",
|
| 711 |
+
),
|
| 712 |
+
clear_btn: gr.update(interactive=False),
|
| 713 |
+
export_btn: gr.update(interactive=False),
|
| 714 |
+
quiz_btn: gr.update(interactive=False),
|
| 715 |
+
summary_btn: gr.update(interactive=False),
|
| 716 |
+
syllabus_file: gr.update(interactive=False),
|
| 717 |
+
doc_type: gr.update(interactive=False),
|
| 718 |
+
review_btn: gr.update(interactive=False),
|
| 719 |
+
language_preference: gr.update(interactive=False),
|
| 720 |
+
learning_mode: gr.update(interactive=False),
|
| 721 |
+
docs_btn: gr.update(interactive=False),
|
| 722 |
+
thumb_up_btn: gr.update(interactive=False, value="👍 Helpful"),
|
| 723 |
+
thumb_down_btn: gr.update(interactive=False, value="👎 Not helpful"),
|
| 724 |
+
feedback_toggle_btn: gr.update(interactive=False),
|
| 725 |
+
feedback_text: gr.update(visible=False, value=""),
|
| 726 |
+
feedback_submit_btn: gr.update(interactive=False, visible=False),
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
logout_btn.click(
|
| 730 |
+
logout,
|
| 731 |
+
outputs=[
|
| 732 |
+
login_state_1,
|
| 733 |
+
login_state_2,
|
| 734 |
+
login_state_3,
|
| 735 |
+
name_input,
|
| 736 |
+
id_input,
|
| 737 |
+
user_name_state,
|
| 738 |
+
user_id_state,
|
| 739 |
+
feedback_used_state,
|
| 740 |
+
student_info_html,
|
| 741 |
+
user_input,
|
| 742 |
+
clear_btn,
|
| 743 |
+
export_btn,
|
| 744 |
+
quiz_btn,
|
| 745 |
+
summary_btn,
|
| 746 |
+
syllabus_file,
|
| 747 |
+
doc_type,
|
| 748 |
+
review_btn,
|
| 749 |
+
language_preference,
|
| 750 |
+
learning_mode,
|
| 751 |
+
docs_btn,
|
| 752 |
+
thumb_up_btn,
|
| 753 |
+
thumb_down_btn,
|
| 754 |
+
feedback_toggle_btn,
|
| 755 |
+
feedback_text,
|
| 756 |
+
feedback_submit_btn,
|
| 757 |
+
],
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
# ================== Main Logic ==================
|
| 761 |
+
def update_course_and_rag(file, doc_type_val):
|
| 762 |
+
local_topics = preloaded_topics or []
|
| 763 |
+
local_chunks = preloaded_chunks or []
|
| 764 |
+
|
| 765 |
+
if file is not None:
|
| 766 |
+
try:
|
| 767 |
+
topics = extract_course_topics_from_file(file, doc_type_val)
|
| 768 |
+
except Exception:
|
| 769 |
+
topics = []
|
| 770 |
+
try:
|
| 771 |
+
chunks = build_rag_chunks_from_file(file, doc_type_val)
|
| 772 |
+
except Exception:
|
| 773 |
+
chunks = []
|
| 774 |
+
|
| 775 |
+
local_topics = (preloaded_topics or []) + (topics or [])
|
| 776 |
+
local_chunks = (preloaded_chunks or []) + (chunks or [])
|
| 777 |
+
|
| 778 |
+
status_md = (
|
| 779 |
+
f"✅ **Loaded Module 10 base reading + uploaded {doc_type_val} file.**\n\n"
|
| 780 |
+
"Both will be used for explanations and quizzes."
|
| 781 |
+
)
|
| 782 |
+
else:
|
| 783 |
+
status_md = (
|
| 784 |
+
"✅ **Using pre-loaded Module 10 reading only.**\n\n"
|
| 785 |
+
"You may optionally upload additional Module 10 materials."
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
return local_topics, local_chunks, status_md
|
| 789 |
+
|
| 790 |
+
syllabus_file.change(
|
| 791 |
+
update_course_and_rag,
|
| 792 |
+
[syllabus_file, doc_type],
|
| 793 |
+
[course_outline_state, rag_chunks_state, session_status],
|
| 794 |
+
)
|
| 795 |
+
|
| 796 |
+
def show_loaded_docs(doc_type_val):
|
| 797 |
+
gr.Info(
|
| 798 |
+
f"For this experiment, Clare always includes the pre-loaded Module 10 reading.\n"
|
| 799 |
+
f"Additional uploaded {doc_type_val} files will be used as supplementary context.",
|
| 800 |
+
title="Loaded Documents",
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
docs_btn.click(show_loaded_docs, inputs=[doc_type])
|
| 804 |
+
|
| 805 |
+
def respond(
|
| 806 |
+
message,
|
| 807 |
+
chat_history,
|
| 808 |
+
course_outline,
|
| 809 |
+
weaknesses,
|
| 810 |
+
cognitive_state,
|
| 811 |
+
rag_chunks,
|
| 812 |
+
model_name_val,
|
| 813 |
+
lang_pref,
|
| 814 |
+
mode_val,
|
| 815 |
+
doc_type_val,
|
| 816 |
+
user_id_val,
|
| 817 |
+
feedback_used,
|
| 818 |
+
):
|
| 819 |
+
if not user_id_val:
|
| 820 |
+
out_msg = (
|
| 821 |
+
"🔒 Please log in with your Student Name and Email/ID on the right "
|
| 822 |
+
"before using Clare."
|
| 823 |
+
)
|
| 824 |
+
new_history = (chat_history or []) + [[message, out_msg]]
|
| 825 |
+
new_status = render_session_status(
|
| 826 |
+
mode_val or "Concept Explainer",
|
| 827 |
+
weaknesses or [],
|
| 828 |
+
cognitive_state or {"confusion": 0, "mastery": 0},
|
| 829 |
+
)
|
| 830 |
+
return (
|
| 831 |
+
"",
|
| 832 |
+
new_history,
|
| 833 |
+
weaknesses,
|
| 834 |
+
cognitive_state,
|
| 835 |
+
new_status,
|
| 836 |
+
"",
|
| 837 |
+
"",
|
| 838 |
+
feedback_used,
|
| 839 |
+
gr.update(interactive=False, value="👍 Helpful"),
|
| 840 |
+
gr.update(interactive=False, value="👎 Not helpful"),
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
lang_pref_norm = normalize_lang_pref(lang_pref)
|
| 844 |
+
resolved_lang = detect_language(message or "", lang_pref_norm)
|
| 845 |
+
|
| 846 |
+
if not message or not message.strip():
|
| 847 |
+
new_status = render_session_status(
|
| 848 |
+
mode_val or "Concept Explainer",
|
| 849 |
+
weaknesses or [],
|
| 850 |
+
cognitive_state or {"confusion": 0, "mastery": 0},
|
| 851 |
+
)
|
| 852 |
+
return (
|
| 853 |
+
"",
|
| 854 |
+
chat_history,
|
| 855 |
+
weaknesses,
|
| 856 |
+
cognitive_state,
|
| 857 |
+
new_status,
|
| 858 |
+
"",
|
| 859 |
+
"",
|
| 860 |
+
feedback_used,
|
| 861 |
+
gr.update(),
|
| 862 |
+
gr.update(),
|
| 863 |
+
)
|
| 864 |
+
|
| 865 |
+
weaknesses = update_weaknesses_from_message(message, weaknesses or [])
|
| 866 |
+
cognitive_state = update_cognitive_state_from_message(message, cognitive_state)
|
| 867 |
+
|
| 868 |
+
if is_academic_query(message):
|
| 869 |
+
rag_context_text, rag_used_chunks = retrieve_relevant_chunks(message, rag_chunks or [])
|
| 870 |
+
else:
|
| 871 |
+
rag_context_text, rag_used_chunks = "", []
|
| 872 |
+
|
| 873 |
+
start_ts = time.time()
|
| 874 |
+
answer, new_history = chat_with_clare(
|
| 875 |
+
message=message,
|
| 876 |
+
history=chat_history,
|
| 877 |
+
model_name=model_name_val,
|
| 878 |
+
language_preference=resolved_lang,
|
| 879 |
+
learning_mode=mode_val,
|
| 880 |
+
doc_type=doc_type_val,
|
| 881 |
+
course_outline=course_outline,
|
| 882 |
+
weaknesses=weaknesses,
|
| 883 |
+
cognitive_state=cognitive_state,
|
| 884 |
+
rag_context=rag_context_text,
|
| 885 |
+
)
|
| 886 |
+
end_ts = time.time()
|
| 887 |
+
latency_ms = (end_ts - start_ts) * 1000.0
|
| 888 |
+
|
| 889 |
+
# Always be explicit about sources for academic-style queries
|
| 890 |
+
ref_text = format_references(rag_used_chunks) if is_academic_query(message) else ""
|
| 891 |
+
if ref_text and new_history:
|
| 892 |
+
last_user, last_assistant = new_history[-1]
|
| 893 |
+
if "References (RAG context used):" not in (last_assistant or ""):
|
| 894 |
+
last_assistant = f"{last_assistant}\n\n{ref_text}"
|
| 895 |
+
new_history[-1] = [last_user, last_assistant]
|
| 896 |
+
answer = last_assistant
|
| 897 |
+
|
| 898 |
+
student_id = user_id_val or "ANON"
|
| 899 |
+
experiment_id = "RESP_AI_W10"
|
| 900 |
+
try:
|
| 901 |
+
log_event(
|
| 902 |
+
{
|
| 903 |
+
"experiment_id": experiment_id,
|
| 904 |
+
"student_id": student_id,
|
| 905 |
+
"event_type": "chat_turn",
|
| 906 |
+
"timestamp": end_ts,
|
| 907 |
+
"latency_ms": latency_ms,
|
| 908 |
+
"question": message,
|
| 909 |
+
"answer": answer,
|
| 910 |
+
"model_name": model_name_val,
|
| 911 |
+
"language": resolved_lang,
|
| 912 |
+
"learning_mode": mode_val,
|
| 913 |
+
}
|
| 914 |
+
)
|
| 915 |
+
except Exception as e:
|
| 916 |
+
print("log_event error:", e)
|
| 917 |
+
|
| 918 |
+
new_status = render_session_status(mode_val, weaknesses, cognitive_state)
|
| 919 |
+
|
| 920 |
+
return (
|
| 921 |
+
"",
|
| 922 |
+
new_history,
|
| 923 |
+
weaknesses,
|
| 924 |
+
cognitive_state,
|
| 925 |
+
new_status,
|
| 926 |
+
message,
|
| 927 |
+
answer,
|
| 928 |
+
False,
|
| 929 |
+
gr.update(interactive=True, value="👍 Helpful"),
|
| 930 |
+
gr.update(interactive=True, value="👎 Not helpful"),
|
| 931 |
+
)
|
| 932 |
+
|
| 933 |
+
user_input.submit(
|
| 934 |
+
respond,
|
| 935 |
+
[
|
| 936 |
+
user_input,
|
| 937 |
+
chatbot,
|
| 938 |
+
course_outline_state,
|
| 939 |
+
weakness_state,
|
| 940 |
+
cognitive_state_state,
|
| 941 |
+
rag_chunks_state,
|
| 942 |
+
model_name,
|
| 943 |
+
language_preference,
|
| 944 |
+
learning_mode,
|
| 945 |
+
doc_type,
|
| 946 |
+
user_id_state,
|
| 947 |
+
feedback_used_state,
|
| 948 |
+
],
|
| 949 |
+
[
|
| 950 |
+
user_input,
|
| 951 |
+
chatbot,
|
| 952 |
+
weakness_state,
|
| 953 |
+
cognitive_state_state,
|
| 954 |
+
session_status,
|
| 955 |
+
last_question_state,
|
| 956 |
+
last_answer_state,
|
| 957 |
+
feedback_used_state,
|
| 958 |
+
thumb_up_btn,
|
| 959 |
+
thumb_down_btn,
|
| 960 |
+
],
|
| 961 |
+
)
|
| 962 |
+
|
| 963 |
+
# ===== Micro-Quiz =====
|
| 964 |
+
def start_micro_quiz(
|
| 965 |
+
chat_history,
|
| 966 |
+
course_outline,
|
| 967 |
+
weaknesses,
|
| 968 |
+
cognitive_state,
|
| 969 |
+
rag_chunks,
|
| 970 |
+
model_name_val,
|
| 971 |
+
lang_pref,
|
| 972 |
+
mode_val,
|
| 973 |
+
doc_type_val,
|
| 974 |
+
user_id_val,
|
| 975 |
+
):
|
| 976 |
+
if not user_id_val:
|
| 977 |
+
gr.Info("Please log in first to start a micro-quiz.", title="Login required")
|
| 978 |
+
return (
|
| 979 |
+
chat_history,
|
| 980 |
+
weaknesses,
|
| 981 |
+
cognitive_state,
|
| 982 |
+
render_session_status(
|
| 983 |
+
mode_val or "Concept Explainer",
|
| 984 |
+
weaknesses or [],
|
| 985 |
+
cognitive_state or {"confusion": 0, "mastery": 0},
|
| 986 |
+
),
|
| 987 |
+
)
|
| 988 |
+
|
| 989 |
+
quiz_instruction = (
|
| 990 |
+
"We are running a short micro-quiz session based ONLY on **Module 10 – "
|
| 991 |
+
"Responsible AI (Alto, 2024, Chapter 12)** and the pre-loaded materials.\n\n"
|
| 992 |
+
"Step 1 – Before asking any content question:\n"
|
| 993 |
+
"• First ask me which quiz style I prefer right now:\n"
|
| 994 |
+
" - (1) Multiple-choice questions\n"
|
| 995 |
+
" - (2) Short-answer / open-ended questions\n"
|
| 996 |
+
"• Ask me explicitly: \"Which quiz style do you prefer now: 1) Multiple-choice or 2) Short-answer? "
|
| 997 |
+
"Please reply with 1 or 2.\"\n"
|
| 998 |
+
"• Do NOT start a content question until I have answered 1 or 2.\n\n"
|
| 999 |
+
"Step 2 – After I choose the style:\n"
|
| 1000 |
+
"• If I choose 1 (multiple-choice):\n"
|
| 1001 |
+
" - Ask ONE multiple-choice question at a time, based on Module 10 concepts.\n"
|
| 1002 |
+
" - Provide 3–4 options (A, B, C, D) and make only one option clearly correct.\n"
|
| 1003 |
+
"• If I choose 2 (short-answer):\n"
|
| 1004 |
+
" - Ask ONE short-answer question at a time, also based on Module 10 concepts.\n"
|
| 1005 |
+
" - Do NOT show the answer when you ask the question.\n\n"
|
| 1006 |
+
"Step 3 – For each answer I give:\n"
|
| 1007 |
+
"• Grade my answer (correct / partially correct / incorrect).\n"
|
| 1008 |
+
"• Give a brief explanation and the correct answer.\n"
|
| 1009 |
+
"• Then ask if I want another question of the SAME style.\n"
|
| 1010 |
+
"• Continue this pattern until I explicitly say to stop.\n\n"
|
| 1011 |
+
"Please start by asking me which quiz style I prefer (1 = multiple-choice, 2 = short-answer). "
|
| 1012 |
+
"Do not ask any content question before I choose."
|
| 1013 |
+
)
|
| 1014 |
+
|
| 1015 |
+
resolved_lang = normalize_lang_pref(lang_pref)
|
| 1016 |
+
|
| 1017 |
+
start_ts = time.time()
|
| 1018 |
+
quiz_ctx_text, _ = retrieve_relevant_chunks("Module 10 quiz", rag_chunks or [])
|
| 1019 |
+
answer, new_history = chat_with_clare(
|
| 1020 |
+
message=quiz_instruction,
|
| 1021 |
+
history=chat_history,
|
| 1022 |
+
model_name=model_name_val,
|
| 1023 |
+
language_preference=resolved_lang,
|
| 1024 |
+
learning_mode=mode_val,
|
| 1025 |
+
doc_type=doc_type_val,
|
| 1026 |
+
course_outline=course_outline,
|
| 1027 |
+
weaknesses=weaknesses,
|
| 1028 |
+
cognitive_state=cognitive_state,
|
| 1029 |
+
rag_context=quiz_ctx_text,
|
| 1030 |
+
)
|
| 1031 |
+
end_ts = time.time()
|
| 1032 |
+
latency_ms = (end_ts - start_ts) * 1000.0
|
| 1033 |
+
|
| 1034 |
+
student_id = user_id_val or "ANON"
|
| 1035 |
+
experiment_id = "RESP_AI_W10"
|
| 1036 |
+
|
| 1037 |
+
try:
|
| 1038 |
+
log_event(
|
| 1039 |
+
{
|
| 1040 |
+
"experiment_id": experiment_id,
|
| 1041 |
+
"student_id": student_id,
|
| 1042 |
+
"event_type": "micro_quiz_start",
|
| 1043 |
+
"timestamp": end_ts,
|
| 1044 |
+
"latency_ms": latency_ms,
|
| 1045 |
+
"question": quiz_instruction,
|
| 1046 |
+
"answer": answer,
|
| 1047 |
+
"model_name": model_name_val,
|
| 1048 |
+
"language": resolved_lang,
|
| 1049 |
+
"learning_mode": mode_val,
|
| 1050 |
+
}
|
| 1051 |
+
)
|
| 1052 |
+
except Exception as e:
|
| 1053 |
+
print("log_event error:", e)
|
| 1054 |
+
|
| 1055 |
+
new_status = render_session_status(mode_val, weaknesses, cognitive_state)
|
| 1056 |
+
return new_history, weaknesses, cognitive_state, new_status
|
| 1057 |
+
|
| 1058 |
+
quiz_btn.click(
|
| 1059 |
+
start_micro_quiz,
|
| 1060 |
+
[
|
| 1061 |
+
chatbot,
|
| 1062 |
+
course_outline_state,
|
| 1063 |
+
weakness_state,
|
| 1064 |
+
cognitive_state_state,
|
| 1065 |
+
rag_chunks_state,
|
| 1066 |
+
model_name,
|
| 1067 |
+
language_preference,
|
| 1068 |
+
learning_mode,
|
| 1069 |
+
doc_type,
|
| 1070 |
+
user_id_state,
|
| 1071 |
+
],
|
| 1072 |
+
[chatbot, weakness_state, cognitive_state_state, session_status],
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
# ===== Feedback UI =====
|
| 1076 |
+
def show_feedback_box():
|
| 1077 |
+
return {
|
| 1078 |
+
feedback_text: gr.update(visible=True),
|
| 1079 |
+
feedback_submit_btn: gr.update(visible=True),
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
feedback_toggle_btn.click(show_feedback_box, None, [feedback_text, feedback_submit_btn])
|
| 1083 |
+
|
| 1084 |
+
def send_thumb_up(last_q, last_a, user_id_val, mode_val, model_name_val, lang_pref, feedback_used):
|
| 1085 |
+
if not last_q and not last_a:
|
| 1086 |
+
return (
|
| 1087 |
+
feedback_used,
|
| 1088 |
+
gr.update(interactive=False, value="👍 Helpful"),
|
| 1089 |
+
gr.update(interactive=False, value="👎 Not helpful"),
|
| 1090 |
+
)
|
| 1091 |
+
if feedback_used:
|
| 1092 |
+
return (feedback_used, gr.update(interactive=False), gr.update(interactive=False))
|
| 1093 |
+
|
| 1094 |
+
try:
|
| 1095 |
+
log_event(
|
| 1096 |
+
{
|
| 1097 |
+
"experiment_id": "RESP_AI_W10",
|
| 1098 |
+
"student_id": user_id_val or "ANON",
|
| 1099 |
+
"event_type": "like",
|
| 1100 |
+
"timestamp": time.time(),
|
| 1101 |
+
"question": last_q,
|
| 1102 |
+
"answer": last_a,
|
| 1103 |
+
"model_name": model_name_val,
|
| 1104 |
+
"language": normalize_lang_pref(lang_pref),
|
| 1105 |
+
"learning_mode": mode_val,
|
| 1106 |
+
}
|
| 1107 |
+
)
|
| 1108 |
+
except Exception as e:
|
| 1109 |
+
print("thumb_up log error:", e)
|
| 1110 |
+
|
| 1111 |
+
return (True, gr.update(interactive=False, value="👍 Helpful (sent)"), gr.update(interactive=False))
|
| 1112 |
+
|
| 1113 |
+
def send_thumb_down(last_q, last_a, user_id_val, mode_val, model_name_val, lang_pref, feedback_used):
|
| 1114 |
+
if not last_q and not last_a:
|
| 1115 |
+
return (
|
| 1116 |
+
feedback_used,
|
| 1117 |
+
gr.update(interactive=False, value="👍 Helpful"),
|
| 1118 |
+
gr.update(interactive=False, value="👎 Not helpful"),
|
| 1119 |
+
)
|
| 1120 |
+
if feedback_used:
|
| 1121 |
+
return (feedback_used, gr.update(interactive=False), gr.update(interactive=False))
|
| 1122 |
+
|
| 1123 |
+
try:
|
| 1124 |
+
log_event(
|
| 1125 |
+
{
|
| 1126 |
+
"experiment_id": "RESP_AI_W10",
|
| 1127 |
+
"student_id": user_id_val or "ANON",
|
| 1128 |
+
"event_type": "dislike",
|
| 1129 |
+
"timestamp": time.time(),
|
| 1130 |
+
"question": last_q,
|
| 1131 |
+
"answer": last_a,
|
| 1132 |
+
"model_name": model_name_val,
|
| 1133 |
+
"language": normalize_lang_pref(lang_pref),
|
| 1134 |
+
"learning_mode": mode_val,
|
| 1135 |
+
}
|
| 1136 |
+
)
|
| 1137 |
+
except Exception as e:
|
| 1138 |
+
print("thumb_down log error:", e)
|
| 1139 |
+
|
| 1140 |
+
return (True, gr.update(interactive=False), gr.update(interactive=False, value="👎 Not helpful (sent)"))
|
| 1141 |
+
|
| 1142 |
+
thumb_up_btn.click(
|
| 1143 |
+
send_thumb_up,
|
| 1144 |
+
[
|
| 1145 |
+
last_question_state,
|
| 1146 |
+
last_answer_state,
|
| 1147 |
+
user_id_state,
|
| 1148 |
+
learning_mode,
|
| 1149 |
+
model_name,
|
| 1150 |
+
language_preference,
|
| 1151 |
+
feedback_used_state,
|
| 1152 |
+
],
|
| 1153 |
+
[feedback_used_state, thumb_up_btn, thumb_down_btn],
|
| 1154 |
+
)
|
| 1155 |
+
|
| 1156 |
+
thumb_down_btn.click(
|
| 1157 |
+
send_thumb_down,
|
| 1158 |
+
[
|
| 1159 |
+
last_question_state,
|
| 1160 |
+
last_answer_state,
|
| 1161 |
+
user_id_state,
|
| 1162 |
+
learning_mode,
|
| 1163 |
+
model_name,
|
| 1164 |
+
language_preference,
|
| 1165 |
+
feedback_used_state,
|
| 1166 |
+
],
|
| 1167 |
+
[feedback_used_state, thumb_up_btn, thumb_down_btn],
|
| 1168 |
+
)
|
| 1169 |
+
|
| 1170 |
+
def submit_detailed_feedback(text, last_q, last_a, user_id_val, mode_val, model_name_val, lang_pref):
|
| 1171 |
+
if not text or not text.strip():
|
| 1172 |
+
return gr.update(value="", placeholder="Please enter some feedback before submitting.")
|
| 1173 |
+
|
| 1174 |
+
try:
|
| 1175 |
+
log_event(
|
| 1176 |
+
{
|
| 1177 |
+
"experiment_id": "RESP_AI_W10",
|
| 1178 |
+
"student_id": user_id_val or "ANON",
|
| 1179 |
+
"event_type": "detailed_feedback",
|
| 1180 |
+
"timestamp": time.time(),
|
| 1181 |
+
"question": last_q,
|
| 1182 |
+
"answer": last_a,
|
| 1183 |
+
"feedback_text": text.strip(),
|
| 1184 |
+
"model_name": model_name_val,
|
| 1185 |
+
"language": normalize_lang_pref(lang_pref),
|
| 1186 |
+
"learning_mode": mode_val,
|
| 1187 |
+
}
|
| 1188 |
+
)
|
| 1189 |
+
except Exception as e:
|
| 1190 |
+
print("detailed_feedback log error:", e)
|
| 1191 |
+
|
| 1192 |
+
return gr.update(value="", placeholder="Thanks! Your feedback has been recorded.")
|
| 1193 |
+
|
| 1194 |
+
feedback_submit_btn.click(
|
| 1195 |
+
submit_detailed_feedback,
|
| 1196 |
+
[
|
| 1197 |
+
feedback_text,
|
| 1198 |
+
last_question_state,
|
| 1199 |
+
last_answer_state,
|
| 1200 |
+
user_id_state,
|
| 1201 |
+
learning_mode,
|
| 1202 |
+
model_name,
|
| 1203 |
+
language_preference,
|
| 1204 |
+
],
|
| 1205 |
+
[feedback_text],
|
| 1206 |
+
)
|
| 1207 |
+
|
| 1208 |
+
# ===== Export / Summary =====
|
| 1209 |
+
export_btn.click(
|
| 1210 |
+
lambda h, c, m, w, cog: export_conversation(h, c, m, w, cog),
|
| 1211 |
+
[chatbot, course_outline_state, learning_mode, weakness_state, cognitive_state_state],
|
| 1212 |
+
[result_display],
|
| 1213 |
+
)
|
| 1214 |
+
|
| 1215 |
+
summary_btn.click(
|
| 1216 |
+
lambda h, c, w, cog, m, l: summarize_conversation(h, c, w, cog, m, normalize_lang_pref(l)),
|
| 1217 |
+
[chatbot, course_outline_state, weakness_state, cognitive_state_state, model_name, language_preference],
|
| 1218 |
+
[result_display],
|
| 1219 |
+
)
|
| 1220 |
+
|
| 1221 |
+
# ===== Reset Conversation =====
|
| 1222 |
+
def clear_all():
|
| 1223 |
+
empty_state = {"confusion": 0, "mastery": 0}
|
| 1224 |
+
default_status = render_session_status("Concept Explainer", [], empty_state)
|
| 1225 |
+
return (
|
| 1226 |
+
[],
|
| 1227 |
+
[],
|
| 1228 |
+
empty_state,
|
| 1229 |
+
[],
|
| 1230 |
+
"",
|
| 1231 |
+
default_status,
|
| 1232 |
+
"",
|
| 1233 |
+
"",
|
| 1234 |
+
False,
|
| 1235 |
+
gr.update(interactive=False, value="👍 Helpful"),
|
| 1236 |
+
gr.update(interactive=False, value="👎 Not helpful"),
|
| 1237 |
+
)
|
| 1238 |
+
|
| 1239 |
+
clear_btn.click(
|
| 1240 |
+
clear_all,
|
| 1241 |
+
None,
|
| 1242 |
+
[
|
| 1243 |
+
chatbot,
|
| 1244 |
+
weakness_state,
|
| 1245 |
+
cognitive_state_state,
|
| 1246 |
+
rag_chunks_state,
|
| 1247 |
+
result_display,
|
| 1248 |
+
session_status,
|
| 1249 |
+
last_question_state,
|
| 1250 |
+
last_answer_state,
|
| 1251 |
+
feedback_used_state,
|
| 1252 |
+
thumb_up_btn,
|
| 1253 |
+
thumb_down_btn,
|
| 1254 |
+
],
|
| 1255 |
+
queue=False,
|
| 1256 |
+
)
|
| 1257 |
+
|
| 1258 |
+
if __name__ == "__main__":
|
| 1259 |
+
demo.launch(
|
| 1260 |
+
share=True,
|
| 1261 |
+
server_name="0.0.0.0",
|
| 1262 |
+
server_port=7860,
|
| 1263 |
+
)
|
assets/hanbridge_logo.png
ADDED
|
hf_space/ClareVoice
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 004491d962625dfa20c049e24cdcc95d62487d9d
|
hf_space/GenAICoursesDB
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 601b149ca7e4dfb10c7aff6181e7715bb9c14de4
|
hf_space/GenAICoursesDB_remote
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 601b149ca7e4dfb10c7aff6181e7715bb9c14de4
|
hf_space/GenAICoursesDB_space
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 4af5c8cf189a404ced0a8010bb4a681f704b8e0f
|
hf_space/test_AI_Agent
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 266ce23a148cf98a3a85ff96c0cc8ac8b19ded95
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
|
| 4 |
+
openai>=1.44.0
|
| 5 |
+
tiktoken
|
| 6 |
+
langsmith>=0.1.0
|
| 7 |
+
langchain-core
|
| 8 |
+
langchain-openai
|
| 9 |
+
|
| 10 |
+
python-docx
|
| 11 |
+
pypdf
|
| 12 |
+
python-pptx
|
| 13 |
+
|
| 14 |
+
python-multipart>=0.0.9
|
| 15 |
+
|
| 16 |
+
numpy
|
| 17 |
+
python-dotenv>=1.0.0
|
| 18 |
+
|
| 19 |
+
# 方案三:Clare 调用 GenAICoursesDB 向量知识库
|
| 20 |
+
gradio_client>=1.0.0
|
| 21 |
+
|
| 22 |
+
# Vector database & better PDF parsing
|
| 23 |
+
faiss-cpu>=1.7.4
|
| 24 |
+
unstructured[pdf]>=0.12.0
|
| 25 |
+
# Optional dependencies for unstructured (if needed)
|
| 26 |
+
# unstructured[local-inference]>=0.12.0 # Uncomment if using local models
|
run_web.sh
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# 一键启动 Clare 产品版 UI(React + FastAPI)
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")"
|
| 5 |
+
|
| 6 |
+
echo ">>> Building web frontend..."
|
| 7 |
+
if [ ! -d "web/node_modules" ]; then
|
| 8 |
+
(cd web && npm install)
|
| 9 |
+
fi
|
| 10 |
+
(cd web && npm run build)
|
| 11 |
+
|
| 12 |
+
echo ">>> Starting API server (serves Web UI at http://0.0.0.0:8000)..."
|
| 13 |
+
exec python -m uvicorn api.server:app --host 0.0.0.0 --port 8000
|
script/langsmith_smoketest.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# langsmith_smoketest.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from langsmith import traceable
|
| 5 |
+
|
| 6 |
+
# 这个只是打印一下当前的 LangSmith 配置,方便 debug
|
| 7 |
+
def print_langsmith_env():
|
| 8 |
+
keys = ["LANGSMITH_TRACING", "LANGSMITH_API_KEY", "LANGSMITH_PROJECT", "LANGSMITH_ENDPOINT"]
|
| 9 |
+
print("=== LangSmith env check ===")
|
| 10 |
+
for k in keys:
|
| 11 |
+
v = os.getenv(k)
|
| 12 |
+
print(f"{k} = {bool(v)} ({'set' if v else 'NOT set'})")
|
| 13 |
+
print("===========================")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@traceable(run_type="chain", name="langsmith_smoke_test_chain")
|
| 17 |
+
def fake_chain(x: int, y: int) -> int:
|
| 18 |
+
"""
|
| 19 |
+
一个假的小链:只做加法,但会被 LangSmith 记录。
|
| 20 |
+
"""
|
| 21 |
+
z = x + y
|
| 22 |
+
return z
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
print_langsmith_env()
|
| 27 |
+
result = fake_chain(1, 2)
|
| 28 |
+
print(f"Result of fake_chain(1, 2) = {result}")
|
web/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Clare AI Tutor UI Redesign (Copy)
|
| 3 |
+
|
| 4 |
+
This is a code bundle for Clare AI Tutor UI Redesign (Copy). The original project is available at https://www.figma.com/design/HqMobz3bHHtqpujwWJOPyM/Clare-AI-Tutor-UI-Redesign--Copy-.
|
| 5 |
+
|
| 6 |
+
## Running the code
|
| 7 |
+
|
| 8 |
+
Run `npm i` to install the dependencies.
|
| 9 |
+
|
| 10 |
+
Run `npm run dev` to start the development server.
|
| 11 |
+
|
web/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
<!DOCTYPE html>
|
| 3 |
+
<html lang="en">
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
| 10 |
+
<title>Clare AI Tutor UI Redesign (Copy)</title>
|
| 11 |
+
</head>
|
| 12 |
+
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
| 18 |
+
|
web/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "clare-ai-tutor-ui-redesign-copy",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"dependencies": {
|
| 6 |
+
"@radix-ui/react-accordion": "^1.2.3",
|
| 7 |
+
"@radix-ui/react-alert-dialog": "^1.1.6",
|
| 8 |
+
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
| 9 |
+
"@radix-ui/react-avatar": "^1.1.3",
|
| 10 |
+
"@radix-ui/react-checkbox": "^1.1.4",
|
| 11 |
+
"@radix-ui/react-collapsible": "^1.1.3",
|
| 12 |
+
"@radix-ui/react-context-menu": "^2.2.6",
|
| 13 |
+
"@radix-ui/react-dialog": "^1.1.6",
|
| 14 |
+
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
| 15 |
+
"@radix-ui/react-hover-card": "^1.1.6",
|
| 16 |
+
"@radix-ui/react-label": "^2.1.2",
|
| 17 |
+
"@radix-ui/react-menubar": "^1.1.6",
|
| 18 |
+
"@radix-ui/react-navigation-menu": "^1.2.5",
|
| 19 |
+
"@radix-ui/react-popover": "^1.1.6",
|
| 20 |
+
"@radix-ui/react-progress": "^1.1.2",
|
| 21 |
+
"@radix-ui/react-radio-group": "^1.2.3",
|
| 22 |
+
"@radix-ui/react-scroll-area": "^1.2.3",
|
| 23 |
+
"@radix-ui/react-select": "^2.1.6",
|
| 24 |
+
"@radix-ui/react-separator": "^1.1.2",
|
| 25 |
+
"@radix-ui/react-slider": "^1.2.3",
|
| 26 |
+
"@radix-ui/react-slot": "^1.1.2",
|
| 27 |
+
"@radix-ui/react-switch": "^1.1.3",
|
| 28 |
+
"@radix-ui/react-tabs": "^1.1.3",
|
| 29 |
+
"@radix-ui/react-toggle": "^1.1.2",
|
| 30 |
+
"@radix-ui/react-toggle-group": "^1.1.2",
|
| 31 |
+
"@radix-ui/react-tooltip": "^1.1.8",
|
| 32 |
+
"class-variance-authority": "^0.7.1",
|
| 33 |
+
"clsx": "*",
|
| 34 |
+
"cmdk": "^1.1.1",
|
| 35 |
+
"docx": "^8.5.0",
|
| 36 |
+
"embla-carousel-react": "^8.6.0",
|
| 37 |
+
"input-otp": "^1.4.2",
|
| 38 |
+
"jspdf": "^3.0.4",
|
| 39 |
+
"lucide-react": "^0.263.1",
|
| 40 |
+
"next-themes": "^0.4.6",
|
| 41 |
+
"react": "^18.3.1",
|
| 42 |
+
"react-day-picker": "^8.10.1",
|
| 43 |
+
"react-dom": "^18.3.1",
|
| 44 |
+
"react-hook-form": "^7.55.0",
|
| 45 |
+
"react-resizable-panels": "^2.1.7",
|
| 46 |
+
"react-markdown": "^9.0.0",
|
| 47 |
+
"remark-gfm": "^4.0.0",
|
| 48 |
+
"recharts": "^2.15.2",
|
| 49 |
+
"sonner": "^2.0.3",
|
| 50 |
+
"tailwind-merge": "*",
|
| 51 |
+
"vaul": "^1.1.2"
|
| 52 |
+
},
|
| 53 |
+
"devDependencies": {
|
| 54 |
+
"@types/node": "^20.10.0",
|
| 55 |
+
"@vitejs/plugin-react-swc": "^3.10.2",
|
| 56 |
+
"vite": "6.3.5"
|
| 57 |
+
},
|
| 58 |
+
"scripts": {
|
| 59 |
+
"dev": "vite",
|
| 60 |
+
"build": "vite build"
|
| 61 |
+
},
|
| 62 |
+
"optionalDependencies": {
|
| 63 |
+
"@rollup/rollup-darwin-arm64": "^4.54.0"
|
| 64 |
+
}
|
| 65 |
+
}
|
web/src/App.tsx
ADDED
|
@@ -0,0 +1,1537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/App.tsx
|
| 2 |
+
import React, { useState, useEffect, useRef, useMemo } from "react";
|
| 3 |
+
import { Header } from "./components/Header";
|
| 4 |
+
import { ChatArea } from "./components/ChatArea";
|
| 5 |
+
import { LoginScreen } from "./components/LoginScreen";
|
| 6 |
+
import { ProfileEditor } from "./components/ProfileEditor";
|
| 7 |
+
import { ReviewBanner } from "./components/ReviewBanner";
|
| 8 |
+
import { Onboarding } from "./components/Onboarding";
|
| 9 |
+
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
| 10 |
+
import { Button } from "./components/ui/button";
|
| 11 |
+
import { Toaster } from "./components/ui/sonner";
|
| 12 |
+
import { toast } from "sonner";
|
| 13 |
+
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
| 14 |
+
|
| 15 |
+
// backend API bindings
|
| 16 |
+
import { apiChat, apiUpload, apiMemoryline, apiQuizStart } from "./lib/api";
|
| 17 |
+
|
| 18 |
+
// NEW: review-star logic
|
| 19 |
+
import {
|
| 20 |
+
type ReviewStarState,
|
| 21 |
+
type ReviewEventType,
|
| 22 |
+
markReviewActive,
|
| 23 |
+
normalizeToday,
|
| 24 |
+
starOpacity,
|
| 25 |
+
energyPct,
|
| 26 |
+
} from "./lib/reviewStar";
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
export type MessageAttachmentKind = "pdf" | "ppt" | "doc" | "image" | "other";
|
| 30 |
+
|
| 31 |
+
export interface MessageAttachment {
|
| 32 |
+
name: string;
|
| 33 |
+
kind: MessageAttachmentKind;
|
| 34 |
+
size: number;
|
| 35 |
+
// 这两个只是展示用,不影响后端
|
| 36 |
+
fileType?: FileType; // syllabus / lecture-slides / ...
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export interface Message {
|
| 40 |
+
id: string;
|
| 41 |
+
role: "user" | "assistant";
|
| 42 |
+
content: string;
|
| 43 |
+
timestamp: Date;
|
| 44 |
+
references?: string[];
|
| 45 |
+
sender?: GroupMember;
|
| 46 |
+
showNextButton?: boolean;
|
| 47 |
+
|
| 48 |
+
// ✅ NEW: show files “with” the user message (metadata only)
|
| 49 |
+
attachments?: MessageAttachment[];
|
| 50 |
+
|
| 51 |
+
questionData?: {
|
| 52 |
+
type: "multiple-choice" | "fill-in-blank" | "open-ended";
|
| 53 |
+
question: string;
|
| 54 |
+
options?: string[];
|
| 55 |
+
correctAnswer: string;
|
| 56 |
+
explanation: string;
|
| 57 |
+
sampleAnswer?: string;
|
| 58 |
+
};
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
export interface User {
|
| 64 |
+
// required identity
|
| 65 |
+
name: string;
|
| 66 |
+
email: string;
|
| 67 |
+
|
| 68 |
+
// profile fields
|
| 69 |
+
studentId?: string;
|
| 70 |
+
department?: string;
|
| 71 |
+
yearLevel?: string;
|
| 72 |
+
major?: string;
|
| 73 |
+
bio?: string; // may be generated by Clare, then user can edit in ProfileEditor
|
| 74 |
+
|
| 75 |
+
// learning preferences
|
| 76 |
+
learningStyle?: string; // "visual" | "auditory" | ...
|
| 77 |
+
learningPace?: string; // "slow" | "moderate" | "fast"
|
| 78 |
+
|
| 79 |
+
// avatar
|
| 80 |
+
avatarUrl?: string;
|
| 81 |
+
|
| 82 |
+
// control flags
|
| 83 |
+
onboardingCompleted?: boolean;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export interface GroupMember {
|
| 87 |
+
id: string;
|
| 88 |
+
name: string;
|
| 89 |
+
email: string;
|
| 90 |
+
avatar?: string;
|
| 91 |
+
isAI?: boolean;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export type SpaceType = "individual" | "group";
|
| 95 |
+
|
| 96 |
+
export interface CourseInfo {
|
| 97 |
+
id: string;
|
| 98 |
+
name: string;
|
| 99 |
+
instructor: { name: string; email: string };
|
| 100 |
+
teachingAssistant: { name: string; email: string };
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export interface Workspace {
|
| 104 |
+
id: string;
|
| 105 |
+
name: string;
|
| 106 |
+
type: SpaceType;
|
| 107 |
+
avatar: string;
|
| 108 |
+
members?: GroupMember[];
|
| 109 |
+
category?: "course" | "personal";
|
| 110 |
+
courseName?: string;
|
| 111 |
+
courseInfo?: CourseInfo;
|
| 112 |
+
isEditable?: boolean;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export type FileType = "syllabus" | "lecture-slides" | "literature-review" | "other";
|
| 116 |
+
|
| 117 |
+
export interface UploadedFile {
|
| 118 |
+
file: File;
|
| 119 |
+
type: FileType;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export type LearningMode = "general" | "concept" | "socratic" | "exam" | "assignment" | "summary";
|
| 123 |
+
export type Language = "auto" | "en" | "zh";
|
| 124 |
+
export type ChatMode = "ask" | "review" | "quiz";
|
| 125 |
+
|
| 126 |
+
export interface SavedItem {
|
| 127 |
+
id: string;
|
| 128 |
+
title: string;
|
| 129 |
+
content: string;
|
| 130 |
+
type: "export" | "quiz" | "summary";
|
| 131 |
+
timestamp: Date;
|
| 132 |
+
isSaved: boolean;
|
| 133 |
+
format?: "pdf" | "text";
|
| 134 |
+
workspaceId: string;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export interface SavedChat {
|
| 138 |
+
id: string;
|
| 139 |
+
title: string;
|
| 140 |
+
messages: Message[];
|
| 141 |
+
chatMode: ChatMode;
|
| 142 |
+
timestamp: Date;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const DOC_TYPE_MAP: Record<FileType, string> = {
|
| 146 |
+
syllabus: "Syllabus",
|
| 147 |
+
"lecture-slides": "Lecture Slides / PPT",
|
| 148 |
+
"literature-review": "Literature Review / Paper",
|
| 149 |
+
other: "Other Course Document",
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
function mapLanguagePref(lang: Language): string {
|
| 153 |
+
if (lang === "zh") return "中文";
|
| 154 |
+
if (lang === "en") return "English";
|
| 155 |
+
return "Auto";
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// ✅ localStorage helpers for saved chats
|
| 159 |
+
function savedChatsStorageKey(email: string) {
|
| 160 |
+
return `saved_chats::${email}`;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function hydrateSavedChats(raw: any): SavedChat[] {
|
| 164 |
+
if (!Array.isArray(raw)) return [];
|
| 165 |
+
return raw
|
| 166 |
+
.map((c: any) => {
|
| 167 |
+
try {
|
| 168 |
+
return {
|
| 169 |
+
...c,
|
| 170 |
+
timestamp: c?.timestamp ? new Date(c.timestamp) : new Date(),
|
| 171 |
+
messages: Array.isArray(c?.messages)
|
| 172 |
+
? c.messages.map((m: any) => ({
|
| 173 |
+
...m,
|
| 174 |
+
timestamp: m?.timestamp ? new Date(m.timestamp) : new Date(),
|
| 175 |
+
}))
|
| 176 |
+
: [],
|
| 177 |
+
} as SavedChat;
|
| 178 |
+
} catch {
|
| 179 |
+
return null;
|
| 180 |
+
}
|
| 181 |
+
})
|
| 182 |
+
.filter(Boolean) as SavedChat[];
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// ✅ localStorage helpers for user profile
|
| 186 |
+
function profileStorageKey(email: string) {
|
| 187 |
+
return `user_profile::${email}`;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function hydrateUserFromStorage(base: User): User {
|
| 191 |
+
try {
|
| 192 |
+
const raw = localStorage.getItem(profileStorageKey(base.email));
|
| 193 |
+
if (!raw) return base;
|
| 194 |
+
const saved = JSON.parse(raw) as Partial<User>;
|
| 195 |
+
return { ...base, ...saved };
|
| 196 |
+
} catch {
|
| 197 |
+
return base;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function App() {
|
| 202 |
+
const [isDarkMode, setIsDarkMode] = useState(() => {
|
| 203 |
+
const saved = localStorage.getItem("theme");
|
| 204 |
+
return saved === "dark" || (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
const [user, setUser] = useState<User | null>(null);
|
| 208 |
+
|
| 209 |
+
// ✅ unified user update helpers
|
| 210 |
+
const updateUser = (patch: Partial<User>) => {
|
| 211 |
+
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const handleUserSave = (next: User) => {
|
| 215 |
+
setUser((prev) => {
|
| 216 |
+
if (!prev) return next;
|
| 217 |
+
return {
|
| 218 |
+
...prev,
|
| 219 |
+
...next,
|
| 220 |
+
onboardingCompleted: next.onboardingCompleted ?? prev.onboardingCompleted,
|
| 221 |
+
};
|
| 222 |
+
});
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// ✅ persist user profile whenever it changes (per-email)
|
| 226 |
+
useEffect(() => {
|
| 227 |
+
if (!user?.email) return;
|
| 228 |
+
try {
|
| 229 |
+
localStorage.setItem(profileStorageKey(user.email), JSON.stringify(user));
|
| 230 |
+
} catch {
|
| 231 |
+
// ignore
|
| 232 |
+
}
|
| 233 |
+
}, [user]);
|
| 234 |
+
|
| 235 |
+
// -------------------------
|
| 236 |
+
// ✅ Course selection (stable)
|
| 237 |
+
// -------------------------
|
| 238 |
+
const MYSPACE_COURSE_KEY = "myspace_selected_course";
|
| 239 |
+
|
| 240 |
+
const [currentCourseId, setCurrentCourseId] = useState<string>(() => {
|
| 241 |
+
return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1";
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
const availableCourses: CourseInfo[] = [
|
| 245 |
+
{
|
| 246 |
+
id: "course1",
|
| 247 |
+
name: "Introduction to AI",
|
| 248 |
+
instructor: { name: "Dr. Sarah Johnson", email: "sarah.johnson@university.edu" },
|
| 249 |
+
teachingAssistant: { name: "Michael Chen", email: "michael.chen@university.edu" },
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
id: "course2",
|
| 253 |
+
name: "Machine Learning",
|
| 254 |
+
instructor: { name: "Prof. David Lee", email: "david.lee@university.edu" },
|
| 255 |
+
teachingAssistant: { name: "Emily Zhang", email: "emily.zhang@university.edu" },
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
id: "course3",
|
| 259 |
+
name: "Data Structures",
|
| 260 |
+
instructor: { name: "Dr. Robert Smith", email: "robert.smith@university.edu" },
|
| 261 |
+
teachingAssistant: { name: "Lisa Wang", email: "lisa.wang@university.edu" },
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
id: "course4",
|
| 265 |
+
name: "Web Development",
|
| 266 |
+
instructor: { name: "Prof. Maria Garcia", email: "maria.garcia@university.edu" },
|
| 267 |
+
teachingAssistant: { name: "James Brown", email: "james.brown@university.edu" },
|
| 268 |
+
},
|
| 269 |
+
];
|
| 270 |
+
|
| 271 |
+
const [askMessages, setAskMessages] = useState<Message[]>([
|
| 272 |
+
{
|
| 273 |
+
id: "1",
|
| 274 |
+
role: "assistant",
|
| 275 |
+
content:
|
| 276 |
+
"👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 277 |
+
timestamp: new Date(),
|
| 278 |
+
},
|
| 279 |
+
]);
|
| 280 |
+
|
| 281 |
+
const [reviewMessages, setReviewMessages] = useState<Message[]>([
|
| 282 |
+
{
|
| 283 |
+
id: "review-1",
|
| 284 |
+
role: "assistant",
|
| 285 |
+
content:
|
| 286 |
+
"📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 287 |
+
timestamp: new Date(),
|
| 288 |
+
},
|
| 289 |
+
]);
|
| 290 |
+
|
| 291 |
+
const [quizMessages, setQuizMessages] = useState<Message[]>([
|
| 292 |
+
{
|
| 293 |
+
id: "quiz-1",
|
| 294 |
+
role: "assistant",
|
| 295 |
+
content:
|
| 296 |
+
"🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 297 |
+
timestamp: new Date(),
|
| 298 |
+
},
|
| 299 |
+
]);
|
| 300 |
+
|
| 301 |
+
const [learningMode, setLearningMode] = useState<LearningMode>("concept");
|
| 302 |
+
const [language, setLanguage] = useState<Language>("auto");
|
| 303 |
+
const [chatMode, setChatMode] = useState<ChatMode>("ask");
|
| 304 |
+
|
| 305 |
+
const messages = chatMode === "ask" ? askMessages : chatMode === "review" ? reviewMessages : quizMessages;
|
| 306 |
+
|
| 307 |
+
const prevChatModeRef = useRef<ChatMode>(chatMode);
|
| 308 |
+
|
| 309 |
+
useEffect(() => {
|
| 310 |
+
let currentMessages: Message[];
|
| 311 |
+
let setCurrentMessages: (messages: Message[]) => void;
|
| 312 |
+
|
| 313 |
+
if (chatMode === "ask") {
|
| 314 |
+
currentMessages = askMessages;
|
| 315 |
+
setCurrentMessages = setAskMessages;
|
| 316 |
+
} else if (chatMode === "review") {
|
| 317 |
+
currentMessages = reviewMessages;
|
| 318 |
+
setCurrentMessages = setReviewMessages;
|
| 319 |
+
} else {
|
| 320 |
+
currentMessages = quizMessages;
|
| 321 |
+
setCurrentMessages = setQuizMessages;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
const hasUserMessages = currentMessages.some((msg) => msg.role === "user");
|
| 325 |
+
const expectedWelcomeId = chatMode === "ask" ? "1" : chatMode === "review" ? "review-1" : "quiz-1";
|
| 326 |
+
const hasWelcomeMessage = currentMessages.some((msg) => msg.id === expectedWelcomeId && msg.role === "assistant");
|
| 327 |
+
const modeChanged = prevChatModeRef.current !== chatMode;
|
| 328 |
+
|
| 329 |
+
if ((modeChanged || currentMessages.length === 0 || !hasWelcomeMessage) && !hasUserMessages) {
|
| 330 |
+
const initialMessages: Record<ChatMode, Message[]> = {
|
| 331 |
+
ask: [
|
| 332 |
+
{
|
| 333 |
+
id: "1",
|
| 334 |
+
role: "assistant",
|
| 335 |
+
content:
|
| 336 |
+
"👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 337 |
+
timestamp: new Date(),
|
| 338 |
+
},
|
| 339 |
+
],
|
| 340 |
+
review: [
|
| 341 |
+
{
|
| 342 |
+
id: "review-1",
|
| 343 |
+
role: "assistant",
|
| 344 |
+
content:
|
| 345 |
+
"📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 346 |
+
timestamp: new Date(),
|
| 347 |
+
},
|
| 348 |
+
],
|
| 349 |
+
quiz: [
|
| 350 |
+
{
|
| 351 |
+
id: "quiz-1",
|
| 352 |
+
role: "assistant",
|
| 353 |
+
content:
|
| 354 |
+
"🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 355 |
+
timestamp: new Date(),
|
| 356 |
+
},
|
| 357 |
+
],
|
| 358 |
+
};
|
| 359 |
+
|
| 360 |
+
setCurrentMessages(initialMessages[chatMode]);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
prevChatModeRef.current = chatMode;
|
| 364 |
+
}, [chatMode, askMessages.length, reviewMessages.length, quizMessages.length]);
|
| 365 |
+
|
| 366 |
+
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
| 367 |
+
const [memoryProgress, setMemoryProgress] = useState(36);
|
| 368 |
+
|
| 369 |
+
const [quizState, setQuizState] = useState<{
|
| 370 |
+
currentQuestion: number;
|
| 371 |
+
waitingForAnswer: boolean;
|
| 372 |
+
showNextButton: boolean;
|
| 373 |
+
}>({
|
| 374 |
+
currentQuestion: 0,
|
| 375 |
+
waitingForAnswer: false,
|
| 376 |
+
showNextButton: false,
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 380 |
+
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
|
| 381 |
+
const [leftPanelVisible, setLeftPanelVisible] = useState(true);
|
| 382 |
+
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 383 |
+
const [showOnboarding, setShowOnboarding] = useState(false);
|
| 384 |
+
|
| 385 |
+
const [showReviewBanner, setShowReviewBanner] = useState(() => true);
|
| 386 |
+
const [showClearDialog, setShowClearDialog] = useState(false);
|
| 387 |
+
|
| 388 |
+
const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
|
| 389 |
+
const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
|
| 390 |
+
|
| 391 |
+
const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
|
| 392 |
+
|
| 393 |
+
// ✅ load saved chats after login
|
| 394 |
+
useEffect(() => {
|
| 395 |
+
if (!user?.email) return;
|
| 396 |
+
try {
|
| 397 |
+
const raw = localStorage.getItem(savedChatsStorageKey(user.email));
|
| 398 |
+
if (!raw) {
|
| 399 |
+
setSavedChats([]);
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
const parsed = JSON.parse(raw);
|
| 403 |
+
setSavedChats(hydrateSavedChats(parsed));
|
| 404 |
+
} catch {
|
| 405 |
+
setSavedChats([]);
|
| 406 |
+
}
|
| 407 |
+
}, [user?.email]);
|
| 408 |
+
|
| 409 |
+
// ✅ persist saved chats whenever changed
|
| 410 |
+
useEffect(() => {
|
| 411 |
+
if (!user?.email) return;
|
| 412 |
+
try {
|
| 413 |
+
localStorage.setItem(savedChatsStorageKey(user.email), JSON.stringify(savedChats));
|
| 414 |
+
} catch {
|
| 415 |
+
// ignore
|
| 416 |
+
}
|
| 417 |
+
}, [savedChats, user?.email]);
|
| 418 |
+
|
| 419 |
+
const [groupMembers] = useState<GroupMember[]>([
|
| 420 |
+
{ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true },
|
| 421 |
+
{ id: "1", name: "Sarah Johnson", email: "sarah.j@university.edu" },
|
| 422 |
+
{ id: "2", name: "Michael Chen", email: "michael.c@university.edu" },
|
| 423 |
+
{ id: "3", name: "Emma Williams", email: "emma.w@university.edu" },
|
| 424 |
+
]);
|
| 425 |
+
|
| 426 |
+
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
| 427 |
+
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>("individual");
|
| 428 |
+
|
| 429 |
+
// ✅ used to prevent duplicate upload per file fingerprint
|
| 430 |
+
const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
|
| 431 |
+
|
| 432 |
+
useEffect(() => {
|
| 433 |
+
if (user) {
|
| 434 |
+
const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
|
| 435 |
+
const course1Info = availableCourses.find((c) => c.id === "course1");
|
| 436 |
+
const course2Info = availableCourses.find((c) => c.name === "AI Ethics"); // may be undefined, that's OK
|
| 437 |
+
|
| 438 |
+
setWorkspaces([
|
| 439 |
+
{ id: "individual", name: "My Space", type: "individual", avatar: userAvatar },
|
| 440 |
+
{
|
| 441 |
+
id: "group-1",
|
| 442 |
+
name: "CS 101 Study Group",
|
| 443 |
+
type: "group",
|
| 444 |
+
avatar: "https://api.dicebear.com/7.x/shapes/svg?seed=cs101group",
|
| 445 |
+
members: groupMembers,
|
| 446 |
+
category: "course",
|
| 447 |
+
courseName: course1Info?.name || "CS 101",
|
| 448 |
+
courseInfo: course1Info,
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
id: "group-2",
|
| 452 |
+
name: "AI Ethics Team",
|
| 453 |
+
type: "group",
|
| 454 |
+
avatar: "https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam",
|
| 455 |
+
members: groupMembers,
|
| 456 |
+
category: "course",
|
| 457 |
+
courseName: course2Info?.name || "AI Ethics",
|
| 458 |
+
courseInfo: course2Info,
|
| 459 |
+
},
|
| 460 |
+
]);
|
| 461 |
+
}
|
| 462 |
+
}, [user, groupMembers, availableCourses]);
|
| 463 |
+
|
| 464 |
+
const fallbackWorkspace: Workspace = {
|
| 465 |
+
id: "individual",
|
| 466 |
+
name: "My Space",
|
| 467 |
+
type: "individual",
|
| 468 |
+
avatar: user ? `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}` : "",
|
| 469 |
+
};
|
| 470 |
+
|
| 471 |
+
const currentWorkspace: Workspace =
|
| 472 |
+
workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0] || fallbackWorkspace;
|
| 473 |
+
|
| 474 |
+
const spaceType: SpaceType = currentWorkspace?.type || "individual";
|
| 475 |
+
|
| 476 |
+
// =========================
|
| 477 |
+
// ✅ Scheme 1: "My Space" uses Group-like sidebar view model
|
| 478 |
+
// =========================
|
| 479 |
+
const mySpaceCourseInfo = useMemo(() => {
|
| 480 |
+
return availableCourses.find((c) => c.id === currentCourseId);
|
| 481 |
+
}, [availableCourses, currentCourseId]);
|
| 482 |
+
|
| 483 |
+
const mySpaceUserMember: GroupMember | null = useMemo(() => {
|
| 484 |
+
if (!user) return null;
|
| 485 |
+
return {
|
| 486 |
+
id: user.email,
|
| 487 |
+
name: user.name,
|
| 488 |
+
email: user.email,
|
| 489 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 490 |
+
};
|
| 491 |
+
}, [user]);
|
| 492 |
+
|
| 493 |
+
const clareMember: GroupMember = useMemo(
|
| 494 |
+
() => ({ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true }),
|
| 495 |
+
[]
|
| 496 |
+
);
|
| 497 |
+
|
| 498 |
+
const sidebarWorkspaces: Workspace[] = useMemo(() => {
|
| 499 |
+
if (!workspaces?.length) return workspaces;
|
| 500 |
+
if (!mySpaceUserMember) return workspaces;
|
| 501 |
+
|
| 502 |
+
return workspaces.map((w) => {
|
| 503 |
+
if (w.id !== "individual") return w;
|
| 504 |
+
|
| 505 |
+
return {
|
| 506 |
+
...w,
|
| 507 |
+
category: "course",
|
| 508 |
+
courseName: mySpaceCourseInfo?.name || w.courseName || "My Course",
|
| 509 |
+
courseInfo: mySpaceCourseInfo,
|
| 510 |
+
members: [clareMember, mySpaceUserMember],
|
| 511 |
+
};
|
| 512 |
+
});
|
| 513 |
+
}, [workspaces, mySpaceCourseInfo, mySpaceUserMember, clareMember]);
|
| 514 |
+
|
| 515 |
+
const sidebarSpaceType: SpaceType = useMemo(() => {
|
| 516 |
+
return currentWorkspaceId === "individual" ? "group" : spaceType;
|
| 517 |
+
}, [currentWorkspaceId, spaceType]);
|
| 518 |
+
|
| 519 |
+
const sidebarGroupMembers: GroupMember[] = useMemo(() => {
|
| 520 |
+
if (currentWorkspaceId === "individual" && mySpaceUserMember) {
|
| 521 |
+
return [clareMember, mySpaceUserMember];
|
| 522 |
+
}
|
| 523 |
+
return groupMembers;
|
| 524 |
+
}, [currentWorkspaceId, mySpaceUserMember, clareMember, groupMembers]);
|
| 525 |
+
|
| 526 |
+
// =========================
|
| 527 |
+
// ✅ Stable course switching logic
|
| 528 |
+
// =========================
|
| 529 |
+
const didHydrateMySpaceRef = useRef(false);
|
| 530 |
+
|
| 531 |
+
const handleCourseChange = (nextCourseId: string) => {
|
| 532 |
+
if (!nextCourseId) return;
|
| 533 |
+
|
| 534 |
+
if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
|
| 535 |
+
return;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
setCurrentCourseId(nextCourseId);
|
| 539 |
+
try {
|
| 540 |
+
localStorage.setItem(MYSPACE_COURSE_KEY, nextCourseId);
|
| 541 |
+
} catch {
|
| 542 |
+
// ignore
|
| 543 |
+
}
|
| 544 |
+
};
|
| 545 |
+
|
| 546 |
+
useEffect(() => {
|
| 547 |
+
if (!currentWorkspace) return;
|
| 548 |
+
|
| 549 |
+
if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
|
| 550 |
+
const cid = currentWorkspace.courseInfo?.id;
|
| 551 |
+
if (cid && cid !== currentCourseId) setCurrentCourseId(cid);
|
| 552 |
+
didHydrateMySpaceRef.current = false;
|
| 553 |
+
return;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
if (currentWorkspace.type === "individual") {
|
| 557 |
+
if (!didHydrateMySpaceRef.current) {
|
| 558 |
+
didHydrateMySpaceRef.current = true;
|
| 559 |
+
|
| 560 |
+
const saved = localStorage.getItem(MYSPACE_COURSE_KEY);
|
| 561 |
+
const valid = saved && availableCourses.some((c) => c.id === saved) ? saved : undefined;
|
| 562 |
+
|
| 563 |
+
const next = valid || currentCourseId || "course1";
|
| 564 |
+
if (next !== currentCourseId) setCurrentCourseId(next);
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
}, [
|
| 568 |
+
currentWorkspaceId,
|
| 569 |
+
currentWorkspace?.type,
|
| 570 |
+
currentWorkspace?.category,
|
| 571 |
+
currentWorkspace?.courseInfo?.id,
|
| 572 |
+
availableCourses,
|
| 573 |
+
currentCourseId,
|
| 574 |
+
currentWorkspace,
|
| 575 |
+
]);
|
| 576 |
+
|
| 577 |
+
useEffect(() => {
|
| 578 |
+
if (currentWorkspace?.type !== "individual") return;
|
| 579 |
+
try {
|
| 580 |
+
const prev = localStorage.getItem(MYSPACE_COURSE_KEY);
|
| 581 |
+
if (prev !== currentCourseId) localStorage.setItem(MYSPACE_COURSE_KEY, currentCourseId);
|
| 582 |
+
} catch {
|
| 583 |
+
// ignore
|
| 584 |
+
}
|
| 585 |
+
}, [currentCourseId, currentWorkspace?.type]);
|
| 586 |
+
|
| 587 |
+
useEffect(() => {
|
| 588 |
+
document.documentElement.classList.toggle("dark", isDarkMode);
|
| 589 |
+
localStorage.setItem("theme", isDarkMode ? "dark" : "light");
|
| 590 |
+
}, [isDarkMode]);
|
| 591 |
+
|
| 592 |
+
useEffect(() => {
|
| 593 |
+
const prev = document.body.style.overflow;
|
| 594 |
+
document.body.style.overflow = "hidden";
|
| 595 |
+
return () => {
|
| 596 |
+
document.body.style.overflow = prev;
|
| 597 |
+
};
|
| 598 |
+
}, []);
|
| 599 |
+
|
| 600 |
+
useEffect(() => {
|
| 601 |
+
if (!user) return;
|
| 602 |
+
|
| 603 |
+
(async () => {
|
| 604 |
+
try {
|
| 605 |
+
const r = await apiMemoryline(user.email);
|
| 606 |
+
const pct = Math.round((r.progress_pct ?? 0) * 100);
|
| 607 |
+
setMemoryProgress(pct);
|
| 608 |
+
} catch {
|
| 609 |
+
// silent
|
| 610 |
+
}
|
| 611 |
+
})();
|
| 612 |
+
}, [user]);
|
| 613 |
+
|
| 614 |
+
// =========================
|
| 615 |
+
// ✅ Review Star (按天) state
|
| 616 |
+
// =========================
|
| 617 |
+
const reviewStarKey = useMemo(() => {
|
| 618 |
+
if (!user) return "";
|
| 619 |
+
return `review_star::${user.email}::${currentWorkspaceId}`;
|
| 620 |
+
}, [user, currentWorkspaceId]);
|
| 621 |
+
|
| 622 |
+
const [reviewStarState, setReviewStarState] = useState<ReviewStarState | null>(null);
|
| 623 |
+
|
| 624 |
+
useEffect(() => {
|
| 625 |
+
if (!user || !reviewStarKey) return;
|
| 626 |
+
if (chatMode !== "review") return;
|
| 627 |
+
|
| 628 |
+
const next = normalizeToday(reviewStarKey);
|
| 629 |
+
setReviewStarState(next);
|
| 630 |
+
}, [chatMode, reviewStarKey, user]);
|
| 631 |
+
|
| 632 |
+
const handleReviewActivity = (event: ReviewEventType) => {
|
| 633 |
+
if (!user || !reviewStarKey) return;
|
| 634 |
+
const next = markReviewActive(reviewStarKey, event);
|
| 635 |
+
setReviewStarState(next);
|
| 636 |
+
};
|
| 637 |
+
|
| 638 |
+
const reviewStarOpacity = starOpacity(reviewStarState);
|
| 639 |
+
const reviewEnergyPct = energyPct(reviewStarState);
|
| 640 |
+
|
| 641 |
+
const getCurrentDocTypeForChat = (): string => {
|
| 642 |
+
if (uploadedFiles.length > 0) {
|
| 643 |
+
const last = uploadedFiles[uploadedFiles.length - 1];
|
| 644 |
+
return DOC_TYPE_MAP[last.type] || "Syllabus";
|
| 645 |
+
}
|
| 646 |
+
return "Syllabus";
|
| 647 |
+
};
|
| 648 |
+
|
| 649 |
+
const handleSendMessage = async (content: string) => {
|
| 650 |
+
if (!user) return;
|
| 651 |
+
|
| 652 |
+
const hasText = !!content.trim();
|
| 653 |
+
const hasFiles = uploadedFiles.length > 0;
|
| 654 |
+
|
| 655 |
+
if (!hasText && !hasFiles) return;
|
| 656 |
+
|
| 657 |
+
const fileNames = hasFiles ? uploadedFiles.map((f) => f.file.name) : [];
|
| 658 |
+
const fileLine = fileNames.length ? `Uploaded files: ${fileNames.join(", ")}` : "";
|
| 659 |
+
|
| 660 |
+
const effectiveContent = hasText
|
| 661 |
+
? content
|
| 662 |
+
: `I've uploaded file(s). Please read them and help me based on their content.\n${fileLine}`.trim();
|
| 663 |
+
|
| 664 |
+
const sender: GroupMember = {
|
| 665 |
+
id: user.email,
|
| 666 |
+
name: user.name,
|
| 667 |
+
email: user.email,
|
| 668 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 669 |
+
};
|
| 670 |
+
|
| 671 |
+
const userVisibleContent = hasText
|
| 672 |
+
? content
|
| 673 |
+
: `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
|
| 674 |
+
|
| 675 |
+
// ✅ snapshot attachments at send-time
|
| 676 |
+
const attachmentsSnapshot: MessageAttachment[] =
|
| 677 |
+
uploadedFiles.map((uf) => {
|
| 678 |
+
const lower = uf.file.name.toLowerCase();
|
| 679 |
+
const kind: MessageAttachmentKind =
|
| 680 |
+
lower.endsWith(".pdf")
|
| 681 |
+
? "pdf"
|
| 682 |
+
: lower.endsWith(".ppt") || lower.endsWith(".pptx")
|
| 683 |
+
? "ppt"
|
| 684 |
+
: lower.endsWith(".doc") || lower.endsWith(".docx")
|
| 685 |
+
? "doc"
|
| 686 |
+
: [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => lower.endsWith(e))
|
| 687 |
+
? "image"
|
| 688 |
+
: "other";
|
| 689 |
+
|
| 690 |
+
return {
|
| 691 |
+
name: uf.file.name,
|
| 692 |
+
size: uf.file.size,
|
| 693 |
+
kind,
|
| 694 |
+
fileType: uf.type,
|
| 695 |
+
};
|
| 696 |
+
});
|
| 697 |
+
|
| 698 |
+
const userMessage: Message = {
|
| 699 |
+
id: Date.now().toString(),
|
| 700 |
+
role: "user",
|
| 701 |
+
content: userVisibleContent,
|
| 702 |
+
timestamp: new Date(),
|
| 703 |
+
sender,
|
| 704 |
+
attachments: attachmentsSnapshot.length ? attachmentsSnapshot : undefined,
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]);
|
| 708 |
+
else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
|
| 709 |
+
else setQuizMessages((prev) => [...prev, userMessage]);
|
| 710 |
+
|
| 711 |
+
if (chatMode === "quiz") {
|
| 712 |
+
setIsTyping(true);
|
| 713 |
+
|
| 714 |
+
try {
|
| 715 |
+
const docType = getCurrentDocTypeForChat();
|
| 716 |
+
|
| 717 |
+
const r = await apiChat({
|
| 718 |
+
user_id: user.email,
|
| 719 |
+
message: effectiveContent,
|
| 720 |
+
learning_mode: "quiz",
|
| 721 |
+
language_preference: mapLanguagePref(language),
|
| 722 |
+
doc_type: docType,
|
| 723 |
+
});
|
| 724 |
+
|
| 725 |
+
const normalizeRefs = (raw: any): string[] => {
|
| 726 |
+
const arr = Array.isArray(raw) ? raw : [];
|
| 727 |
+
return arr
|
| 728 |
+
.map((x) => {
|
| 729 |
+
if (typeof x === "string") {
|
| 730 |
+
const s = x.trim();
|
| 731 |
+
return s ? s : null;
|
| 732 |
+
}
|
| 733 |
+
const a = x?.source_file ? String(x.source_file) : "";
|
| 734 |
+
const b = x?.section ? String(x.section) : "";
|
| 735 |
+
const s = `${a}${a && b ? " — " : ""}${b}`.trim();
|
| 736 |
+
return s || null;
|
| 737 |
+
})
|
| 738 |
+
.filter(Boolean) as string[];
|
| 739 |
+
};
|
| 740 |
+
|
| 741 |
+
const refs = normalizeRefs((r as any).refs ?? (r as any).references);
|
| 742 |
+
|
| 743 |
+
const assistantMessage: Message = {
|
| 744 |
+
id: (Date.now() + 1).toString(),
|
| 745 |
+
role: "assistant",
|
| 746 |
+
content: r.reply || "",
|
| 747 |
+
timestamp: new Date(),
|
| 748 |
+
references: refs.length ? refs : undefined,
|
| 749 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 750 |
+
showNextButton: false,
|
| 751 |
+
};
|
| 752 |
+
|
| 753 |
+
setIsTyping(false);
|
| 754 |
+
|
| 755 |
+
setTimeout(() => {
|
| 756 |
+
setQuizMessages((prev) => [...prev, assistantMessage]);
|
| 757 |
+
setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
|
| 758 |
+
}, 50);
|
| 759 |
+
} catch (e: any) {
|
| 760 |
+
setIsTyping(false);
|
| 761 |
+
toast.error(e?.message || "Quiz failed");
|
| 762 |
+
|
| 763 |
+
const assistantMessage: Message = {
|
| 764 |
+
id: (Date.now() + 1).toString(),
|
| 765 |
+
role: "assistant",
|
| 766 |
+
content: "Sorry — quiz request failed. Please try again.",
|
| 767 |
+
timestamp: new Date(),
|
| 768 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 769 |
+
};
|
| 770 |
+
|
| 771 |
+
setTimeout(() => {
|
| 772 |
+
setQuizMessages((prev) => [...prev, assistantMessage]);
|
| 773 |
+
}, 50);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
return;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
setIsTyping(true);
|
| 780 |
+
try {
|
| 781 |
+
const docType = getCurrentDocTypeForChat();
|
| 782 |
+
|
| 783 |
+
const r = await apiChat({
|
| 784 |
+
user_id: user.email,
|
| 785 |
+
message: effectiveContent,
|
| 786 |
+
learning_mode: learningMode,
|
| 787 |
+
language_preference: mapLanguagePref(language),
|
| 788 |
+
doc_type: docType,
|
| 789 |
+
});
|
| 790 |
+
|
| 791 |
+
const refs = (r.refs || [])
|
| 792 |
+
.map((x: any) => {
|
| 793 |
+
const a = x?.source_file ? String(x.source_file) : "";
|
| 794 |
+
const b = x?.section ? String(x.section) : "";
|
| 795 |
+
const s = `${a}${a && b ? " — " : ""}${b}`.trim();
|
| 796 |
+
return s || null;
|
| 797 |
+
})
|
| 798 |
+
.filter(Boolean) as string[];
|
| 799 |
+
|
| 800 |
+
const assistantMessage: Message = {
|
| 801 |
+
id: (Date.now() + 1).toString(),
|
| 802 |
+
role: "assistant",
|
| 803 |
+
content: r.reply || "",
|
| 804 |
+
timestamp: new Date(),
|
| 805 |
+
references: refs.length ? refs : undefined,
|
| 806 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 807 |
+
};
|
| 808 |
+
|
| 809 |
+
setIsTyping(false);
|
| 810 |
+
|
| 811 |
+
setTimeout(() => {
|
| 812 |
+
if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
|
| 813 |
+
else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
|
| 814 |
+
}, 50);
|
| 815 |
+
|
| 816 |
+
try {
|
| 817 |
+
const ml = await apiMemoryline(user.email);
|
| 818 |
+
setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
|
| 819 |
+
} catch {
|
| 820 |
+
// ignore
|
| 821 |
+
}
|
| 822 |
+
} catch (e: any) {
|
| 823 |
+
setIsTyping(false);
|
| 824 |
+
toast.error(e?.message || "Chat failed");
|
| 825 |
+
|
| 826 |
+
const assistantMessage: Message = {
|
| 827 |
+
id: (Date.now() + 1).toString(),
|
| 828 |
+
role: "assistant",
|
| 829 |
+
content: "Sorry — chat request failed. Please try again.",
|
| 830 |
+
timestamp: new Date(),
|
| 831 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 832 |
+
};
|
| 833 |
+
|
| 834 |
+
setTimeout(() => {
|
| 835 |
+
if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
|
| 836 |
+
if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
|
| 837 |
+
}, 50);
|
| 838 |
+
}
|
| 839 |
+
};
|
| 840 |
+
|
| 841 |
+
const handleNextQuestion = async () => {
|
| 842 |
+
if (!user) return;
|
| 843 |
+
|
| 844 |
+
const prompt = "Please give me another question of the same quiz style.";
|
| 845 |
+
const sender: GroupMember = {
|
| 846 |
+
id: user.email,
|
| 847 |
+
name: user.name,
|
| 848 |
+
email: user.email,
|
| 849 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 850 |
+
};
|
| 851 |
+
|
| 852 |
+
const userMessage: Message = {
|
| 853 |
+
id: Date.now().toString(),
|
| 854 |
+
role: "user",
|
| 855 |
+
content: prompt,
|
| 856 |
+
timestamp: new Date(),
|
| 857 |
+
sender,
|
| 858 |
+
};
|
| 859 |
+
|
| 860 |
+
setQuizMessages((prev) => [...prev, userMessage]);
|
| 861 |
+
setIsTyping(true);
|
| 862 |
+
|
| 863 |
+
try {
|
| 864 |
+
const docType = getCurrentDocTypeForChat();
|
| 865 |
+
const r = await apiChat({
|
| 866 |
+
user_id: user.email,
|
| 867 |
+
message: prompt,
|
| 868 |
+
learning_mode: "quiz",
|
| 869 |
+
language_preference: mapLanguagePref(language),
|
| 870 |
+
doc_type: docType,
|
| 871 |
+
});
|
| 872 |
+
|
| 873 |
+
const refs = (r.refs || [])
|
| 874 |
+
.map((x: any) => {
|
| 875 |
+
const a = x?.source_file ? String(x.source_file) : "";
|
| 876 |
+
const b = x?.section ? String(x.section) : "";
|
| 877 |
+
const s = `${a}${a && b ? " — " : ""}${b}`.trim();
|
| 878 |
+
return s || null;
|
| 879 |
+
})
|
| 880 |
+
.filter(Boolean) as string[];
|
| 881 |
+
|
| 882 |
+
const assistantMessage: Message = {
|
| 883 |
+
id: (Date.now() + 1).toString(),
|
| 884 |
+
role: "assistant",
|
| 885 |
+
content: r.reply || "",
|
| 886 |
+
timestamp: new Date(),
|
| 887 |
+
references: refs.length ? refs : undefined,
|
| 888 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 889 |
+
showNextButton: false,
|
| 890 |
+
};
|
| 891 |
+
|
| 892 |
+
setIsTyping(false);
|
| 893 |
+
|
| 894 |
+
setTimeout(() => {
|
| 895 |
+
setQuizMessages((prev) => [...prev, assistantMessage]);
|
| 896 |
+
setQuizState((prev) => ({
|
| 897 |
+
...prev,
|
| 898 |
+
currentQuestion: prev.currentQuestion + 1,
|
| 899 |
+
waitingForAnswer: true,
|
| 900 |
+
showNextButton: false,
|
| 901 |
+
}));
|
| 902 |
+
}, 50);
|
| 903 |
+
} catch (e: any) {
|
| 904 |
+
setIsTyping(false);
|
| 905 |
+
toast.error(e?.message || "Quiz failed");
|
| 906 |
+
|
| 907 |
+
const assistantMessage: Message = {
|
| 908 |
+
id: (Date.now() + 1).toString(),
|
| 909 |
+
role: "assistant",
|
| 910 |
+
content: "Sorry — quiz request failed. Please try again.",
|
| 911 |
+
timestamp: new Date(),
|
| 912 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 913 |
+
};
|
| 914 |
+
|
| 915 |
+
setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
|
| 916 |
+
}
|
| 917 |
+
};
|
| 918 |
+
|
| 919 |
+
const handleStartQuiz = async () => {
|
| 920 |
+
if (!user) return;
|
| 921 |
+
|
| 922 |
+
setIsTyping(true);
|
| 923 |
+
try {
|
| 924 |
+
const docType = getCurrentDocTypeForChat();
|
| 925 |
+
|
| 926 |
+
const r = await apiQuizStart({
|
| 927 |
+
user_id: user.email,
|
| 928 |
+
language_preference: mapLanguagePref(language),
|
| 929 |
+
doc_type: docType,
|
| 930 |
+
learning_mode: "quiz",
|
| 931 |
+
});
|
| 932 |
+
|
| 933 |
+
const refs = (r.refs || [])
|
| 934 |
+
.map((x: any) => {
|
| 935 |
+
const a = x?.source_file ? String(x.source_file) : "";
|
| 936 |
+
const b = x?.section ? String(x.section) : "";
|
| 937 |
+
const s = `${a}${a && b ? " — " : ""}${b}`.trim();
|
| 938 |
+
return s || null;
|
| 939 |
+
})
|
| 940 |
+
.filter(Boolean) as string[];
|
| 941 |
+
|
| 942 |
+
const assistantMessage: Message = {
|
| 943 |
+
id: Date.now().toString(),
|
| 944 |
+
role: "assistant",
|
| 945 |
+
content: r.reply || "",
|
| 946 |
+
timestamp: new Date(),
|
| 947 |
+
references: refs.length ? refs : undefined,
|
| 948 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 949 |
+
showNextButton: false,
|
| 950 |
+
};
|
| 951 |
+
|
| 952 |
+
setIsTyping(false);
|
| 953 |
+
|
| 954 |
+
setTimeout(() => {
|
| 955 |
+
setQuizMessages((prev) => [...prev, assistantMessage]);
|
| 956 |
+
setQuizState({ currentQuestion: 0, waitingForAnswer: true, showNextButton: false });
|
| 957 |
+
}, 50);
|
| 958 |
+
} catch (e: any) {
|
| 959 |
+
setIsTyping(false);
|
| 960 |
+
toast.error(e?.message || "Start quiz failed");
|
| 961 |
+
|
| 962 |
+
const assistantMessage: Message = {
|
| 963 |
+
id: Date.now().toString(),
|
| 964 |
+
role: "assistant",
|
| 965 |
+
content: "Sorry — could not start the quiz. Please try again.",
|
| 966 |
+
timestamp: new Date(),
|
| 967 |
+
sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
|
| 968 |
+
};
|
| 969 |
+
|
| 970 |
+
setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
|
| 971 |
+
}
|
| 972 |
+
};
|
| 973 |
+
|
| 974 |
+
// =========================
|
| 975 |
+
// File Upload (FIXED)
|
| 976 |
+
// =========================
|
| 977 |
+
|
| 978 |
+
const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
|
| 979 |
+
const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
|
| 980 |
+
if (!files.length) return;
|
| 981 |
+
|
| 982 |
+
const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
|
| 983 |
+
|
| 984 |
+
setUploadedFiles((prev) => [...prev, ...newFiles]);
|
| 985 |
+
|
| 986 |
+
if (!user) return;
|
| 987 |
+
|
| 988 |
+
for (const f of files) {
|
| 989 |
+
const fp = `${f.name}::${f.size}::${f.lastModified}`;
|
| 990 |
+
if (uploadedFingerprintsRef.current.has(fp)) continue;
|
| 991 |
+
uploadedFingerprintsRef.current.add(fp);
|
| 992 |
+
|
| 993 |
+
try {
|
| 994 |
+
await apiUpload({
|
| 995 |
+
user_id: user.email,
|
| 996 |
+
doc_type: DOC_TYPE_MAP["other"] || "Other Course Document",
|
| 997 |
+
file: f,
|
| 998 |
+
});
|
| 999 |
+
toast.success(`File uploaded: ${f.name}`);
|
| 1000 |
+
} catch (e: any) {
|
| 1001 |
+
toast.error(e?.message || `Upload failed: ${f.name}`);
|
| 1002 |
+
uploadedFingerprintsRef.current.delete(fp);
|
| 1003 |
+
}
|
| 1004 |
+
}
|
| 1005 |
+
};
|
| 1006 |
+
|
| 1007 |
+
const handleRemoveFile = (arg: any) => {
|
| 1008 |
+
setUploadedFiles((prev) => {
|
| 1009 |
+
if (!prev.length) return prev;
|
| 1010 |
+
|
| 1011 |
+
let idx = -1;
|
| 1012 |
+
|
| 1013 |
+
if (typeof arg === "number") {
|
| 1014 |
+
idx = arg;
|
| 1015 |
+
} else {
|
| 1016 |
+
const file =
|
| 1017 |
+
arg?.file instanceof File
|
| 1018 |
+
? (arg as UploadedFile).file
|
| 1019 |
+
: arg instanceof File
|
| 1020 |
+
? (arg as File)
|
| 1021 |
+
: null;
|
| 1022 |
+
|
| 1023 |
+
if (file) {
|
| 1024 |
+
idx = prev.findIndex(
|
| 1025 |
+
(x) =>
|
| 1026 |
+
x.file.name === file.name && x.file.size === file.size && x.file.lastModified === file.lastModified
|
| 1027 |
+
);
|
| 1028 |
+
}
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
if (idx < 0 || idx >= prev.length) return prev;
|
| 1032 |
+
|
| 1033 |
+
const removed = prev[idx]?.file;
|
| 1034 |
+
const next = prev.filter((_, i) => i !== idx);
|
| 1035 |
+
|
| 1036 |
+
if (removed) {
|
| 1037 |
+
const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
|
| 1038 |
+
uploadedFingerprintsRef.current.delete(fp);
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
return next;
|
| 1042 |
+
});
|
| 1043 |
+
};
|
| 1044 |
+
|
| 1045 |
+
const handleFileTypeChange = async (index: number, type: FileType) => {
|
| 1046 |
+
if (!user) return;
|
| 1047 |
+
|
| 1048 |
+
const target = uploadedFiles[index]?.file;
|
| 1049 |
+
if (!target) return;
|
| 1050 |
+
|
| 1051 |
+
setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f)));
|
| 1052 |
+
|
| 1053 |
+
const fp = `${target.name}::${target.size}::${target.lastModified}`;
|
| 1054 |
+
if (uploadedFingerprintsRef.current.has(fp)) return;
|
| 1055 |
+
uploadedFingerprintsRef.current.add(fp);
|
| 1056 |
+
|
| 1057 |
+
try {
|
| 1058 |
+
await apiUpload({
|
| 1059 |
+
user_id: user.email,
|
| 1060 |
+
doc_type: DOC_TYPE_MAP[type] || "Other Course Document",
|
| 1061 |
+
file: target,
|
| 1062 |
+
});
|
| 1063 |
+
toast.success("File uploaded to backend");
|
| 1064 |
+
} catch (e: any) {
|
| 1065 |
+
toast.error(e?.message || "Upload failed");
|
| 1066 |
+
uploadedFingerprintsRef.current.delete(fp);
|
| 1067 |
+
}
|
| 1068 |
+
};
|
| 1069 |
+
|
| 1070 |
+
const isCurrentChatSaved = (): SavedChat | null => {
|
| 1071 |
+
if (messages.length <= 1) return null;
|
| 1072 |
+
|
| 1073 |
+
return (
|
| 1074 |
+
savedChats.find((chat) => {
|
| 1075 |
+
if (chat.chatMode !== chatMode) return false;
|
| 1076 |
+
if (chat.messages.length !== messages.length) return false;
|
| 1077 |
+
|
| 1078 |
+
return chat.messages.every((savedMsg, idx) => {
|
| 1079 |
+
const currentMsg = messages[idx];
|
| 1080 |
+
return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
|
| 1081 |
+
});
|
| 1082 |
+
}) || null
|
| 1083 |
+
);
|
| 1084 |
+
};
|
| 1085 |
+
|
| 1086 |
+
const handleDeleteSavedChat = (id: string) => {
|
| 1087 |
+
setSavedChats((prev) => prev.filter((chat) => chat.id !== id));
|
| 1088 |
+
toast.success("Chat deleted");
|
| 1089 |
+
};
|
| 1090 |
+
|
| 1091 |
+
const handleRenameSavedChat = (id: string, newTitle: string) => {
|
| 1092 |
+
setSavedChats((prev) => prev.map((chat) => (chat.id === id ? { ...chat, title: newTitle } : chat)));
|
| 1093 |
+
toast.success("Chat renamed");
|
| 1094 |
+
};
|
| 1095 |
+
|
| 1096 |
+
const handleSaveChat = () => {
|
| 1097 |
+
if (messages.length <= 1) {
|
| 1098 |
+
toast.info("No conversation to save");
|
| 1099 |
+
return;
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
const existingChat = isCurrentChatSaved();
|
| 1103 |
+
if (existingChat) {
|
| 1104 |
+
handleDeleteSavedChat(existingChat.id);
|
| 1105 |
+
toast.success("Chat unsaved");
|
| 1106 |
+
return;
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
const title = `Chat - ${chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"} - ${new Date().toLocaleDateString()}`;
|
| 1110 |
+
|
| 1111 |
+
const newChat: SavedChat = {
|
| 1112 |
+
id: Date.now().toString(),
|
| 1113 |
+
title,
|
| 1114 |
+
messages: [...messages],
|
| 1115 |
+
chatMode,
|
| 1116 |
+
timestamp: new Date(),
|
| 1117 |
+
};
|
| 1118 |
+
|
| 1119 |
+
setSavedChats((prev) => [newChat, ...prev]);
|
| 1120 |
+
setLeftPanelVisible(true);
|
| 1121 |
+
toast.success("Chat saved!");
|
| 1122 |
+
};
|
| 1123 |
+
|
| 1124 |
+
const handleLoadChat = (savedChat: SavedChat) => {
|
| 1125 |
+
setChatMode(savedChat.chatMode);
|
| 1126 |
+
|
| 1127 |
+
if (savedChat.chatMode === "ask") setAskMessages(savedChat.messages);
|
| 1128 |
+
else if (savedChat.chatMode === "review") setReviewMessages(savedChat.messages);
|
| 1129 |
+
else {
|
| 1130 |
+
setQuizMessages(savedChat.messages);
|
| 1131 |
+
setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
toast.success("Chat loaded!");
|
| 1135 |
+
};
|
| 1136 |
+
|
| 1137 |
+
const handleClearConversation = (shouldSave: boolean = false) => {
|
| 1138 |
+
if (shouldSave) handleSaveChat();
|
| 1139 |
+
|
| 1140 |
+
const initialMessages: Record<ChatMode, Message[]> = {
|
| 1141 |
+
ask: [
|
| 1142 |
+
{
|
| 1143 |
+
id: "1",
|
| 1144 |
+
role: "assistant",
|
| 1145 |
+
content:
|
| 1146 |
+
"👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 1147 |
+
timestamp: new Date(),
|
| 1148 |
+
},
|
| 1149 |
+
],
|
| 1150 |
+
review: [
|
| 1151 |
+
{
|
| 1152 |
+
id: "review-1",
|
| 1153 |
+
role: "assistant",
|
| 1154 |
+
content:
|
| 1155 |
+
"📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
|
| 1156 |
+
timestamp: new Date(),
|
| 1157 |
+
},
|
| 1158 |
+
],
|
| 1159 |
+
quiz: [
|
| 1160 |
+
{
|
| 1161 |
+
id: "quiz-1",
|
| 1162 |
+
role: "assistant",
|
| 1163 |
+
content:
|
| 1164 |
+
"🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
|
| 1165 |
+
timestamp: new Date(),
|
| 1166 |
+
},
|
| 1167 |
+
],
|
| 1168 |
+
};
|
| 1169 |
+
|
| 1170 |
+
if (chatMode === "ask") setAskMessages(initialMessages.ask);
|
| 1171 |
+
else if (chatMode === "review") setReviewMessages(initialMessages.review);
|
| 1172 |
+
else {
|
| 1173 |
+
setQuizMessages(initialMessages.quiz);
|
| 1174 |
+
setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
|
| 1175 |
+
}
|
| 1176 |
+
};
|
| 1177 |
+
|
| 1178 |
+
const handleSave = (
|
| 1179 |
+
content: string,
|
| 1180 |
+
type: "export" | "quiz" | "summary",
|
| 1181 |
+
saveAsChat: boolean = false,
|
| 1182 |
+
format: "pdf" | "text" = "text",
|
| 1183 |
+
workspaceId?: string
|
| 1184 |
+
) => {
|
| 1185 |
+
if (!content.trim()) return;
|
| 1186 |
+
|
| 1187 |
+
if (saveAsChat && type !== "summary") {
|
| 1188 |
+
const chatMessages: Message[] = [
|
| 1189 |
+
{
|
| 1190 |
+
id: "1",
|
| 1191 |
+
role: "assistant",
|
| 1192 |
+
content:
|
| 1193 |
+
"👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
|
| 1194 |
+
timestamp: new Date(),
|
| 1195 |
+
},
|
| 1196 |
+
{ id: Date.now().toString(), role: "assistant", content, timestamp: new Date() },
|
| 1197 |
+
];
|
| 1198 |
+
|
| 1199 |
+
const title = type === "export" ? "Exported Conversation" : "Micro-Quiz";
|
| 1200 |
+
const newChat: SavedChat = {
|
| 1201 |
+
id: Date.now().toString(),
|
| 1202 |
+
title: `${title} - ${new Date().toLocaleDateString()}`,
|
| 1203 |
+
messages: chatMessages,
|
| 1204 |
+
chatMode: "ask",
|
| 1205 |
+
timestamp: new Date(),
|
| 1206 |
+
};
|
| 1207 |
+
|
| 1208 |
+
setSavedChats((prev) => [newChat, ...prev]);
|
| 1209 |
+
setLeftPanelVisible(true);
|
| 1210 |
+
toast.success("Chat saved!");
|
| 1211 |
+
return;
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
const existingItem = savedItems.find((item) => item.content === content && item.type === type);
|
| 1215 |
+
if (existingItem) {
|
| 1216 |
+
handleUnsave(existingItem.id);
|
| 1217 |
+
return;
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
const title = type === "export" ? "Exported Conversation" : type === "quiz" ? "Micro-Quiz" : "Summarization";
|
| 1221 |
+
const newItem: SavedItem = {
|
| 1222 |
+
id: Date.now().toString(),
|
| 1223 |
+
title: `${title} - ${new Date().toLocaleDateString()}`,
|
| 1224 |
+
content,
|
| 1225 |
+
type,
|
| 1226 |
+
timestamp: new Date(),
|
| 1227 |
+
isSaved: true,
|
| 1228 |
+
format,
|
| 1229 |
+
workspaceId: workspaceId || currentWorkspaceId,
|
| 1230 |
+
};
|
| 1231 |
+
|
| 1232 |
+
setSavedItems((prev) => [newItem, ...prev]);
|
| 1233 |
+
setRecentlySavedId(newItem.id);
|
| 1234 |
+
setLeftPanelVisible(true);
|
| 1235 |
+
|
| 1236 |
+
setTimeout(() => setRecentlySavedId(null), 2000);
|
| 1237 |
+
toast.success("Saved for later!");
|
| 1238 |
+
};
|
| 1239 |
+
|
| 1240 |
+
const handleUnsave = (id: string) => {
|
| 1241 |
+
setSavedItems((prev) => prev.filter((item) => item.id !== id));
|
| 1242 |
+
toast.success("Removed from saved items");
|
| 1243 |
+
};
|
| 1244 |
+
|
| 1245 |
+
const handleCreateWorkspace = (payload: { name: string; category: "course" | "personal"; courseId?: string; invites: string[] }) => {
|
| 1246 |
+
const id = `group-${Date.now()}`;
|
| 1247 |
+
const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
|
| 1248 |
+
|
| 1249 |
+
const creatorMember: GroupMember = user
|
| 1250 |
+
? {
|
| 1251 |
+
id: user.email,
|
| 1252 |
+
name: user.name,
|
| 1253 |
+
email: user.email,
|
| 1254 |
+
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
|
| 1255 |
+
}
|
| 1256 |
+
: { id: "unknown", name: "Unknown", email: "unknown@email.com" };
|
| 1257 |
+
|
| 1258 |
+
const members: GroupMember[] = [
|
| 1259 |
+
creatorMember,
|
| 1260 |
+
...payload.invites.map((email) => ({
|
| 1261 |
+
id: email,
|
| 1262 |
+
name: email.split("@")[0] || email,
|
| 1263 |
+
email,
|
| 1264 |
+
})),
|
| 1265 |
+
];
|
| 1266 |
+
|
| 1267 |
+
let newWorkspace: Workspace;
|
| 1268 |
+
|
| 1269 |
+
if (payload.category === "course") {
|
| 1270 |
+
const courseInfo = availableCourses.find((c) => c.id === payload.courseId);
|
| 1271 |
+
newWorkspace = {
|
| 1272 |
+
id,
|
| 1273 |
+
name: payload.name,
|
| 1274 |
+
type: "group",
|
| 1275 |
+
avatar,
|
| 1276 |
+
members,
|
| 1277 |
+
category: "course",
|
| 1278 |
+
courseName: courseInfo?.name || "Untitled Course",
|
| 1279 |
+
courseInfo,
|
| 1280 |
+
};
|
| 1281 |
+
} else {
|
| 1282 |
+
newWorkspace = {
|
| 1283 |
+
id,
|
| 1284 |
+
name: payload.name,
|
| 1285 |
+
type: "group",
|
| 1286 |
+
avatar,
|
| 1287 |
+
members,
|
| 1288 |
+
category: "personal",
|
| 1289 |
+
isEditable: true,
|
| 1290 |
+
};
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
setWorkspaces((prev) => [...prev, newWorkspace]);
|
| 1294 |
+
setCurrentWorkspaceId(id);
|
| 1295 |
+
|
| 1296 |
+
if (payload.category === "course" && payload.courseId) {
|
| 1297 |
+
setCurrentCourseId(payload.courseId);
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
toast.success("New group workspace created");
|
| 1301 |
+
};
|
| 1302 |
+
|
| 1303 |
+
const handleReviewClick = () => {
|
| 1304 |
+
setChatMode("review");
|
| 1305 |
+
setShowReviewBanner(false);
|
| 1306 |
+
localStorage.setItem("reviewBannerDismissed", "true");
|
| 1307 |
+
};
|
| 1308 |
+
|
| 1309 |
+
const handleDismissReviewBanner = () => {
|
| 1310 |
+
setShowReviewBanner(false);
|
| 1311 |
+
localStorage.setItem("reviewBannerDismissed", "true");
|
| 1312 |
+
};
|
| 1313 |
+
|
| 1314 |
+
// ✅ login: hydrate profile and only show onboarding if not completed
|
| 1315 |
+
const handleLogin = (newUser: User) => {
|
| 1316 |
+
const hydrated = hydrateUserFromStorage(newUser);
|
| 1317 |
+
setUser(hydrated);
|
| 1318 |
+
setShowOnboarding(!hydrated.onboardingCompleted);
|
| 1319 |
+
};
|
| 1320 |
+
|
| 1321 |
+
const handleOnboardingComplete = (updatedUser: User) => {
|
| 1322 |
+
handleUserSave({ ...updatedUser, onboardingCompleted: true });
|
| 1323 |
+
setShowOnboarding(false);
|
| 1324 |
+
};
|
| 1325 |
+
|
| 1326 |
+
const handleOnboardingSkip = () => {
|
| 1327 |
+
updateUser({ onboardingCompleted: true });
|
| 1328 |
+
setShowOnboarding(false);
|
| 1329 |
+
};
|
| 1330 |
+
|
| 1331 |
+
if (!user) return <LoginScreen onLogin={handleLogin} />;
|
| 1332 |
+
|
| 1333 |
+
if (showOnboarding && user)
|
| 1334 |
+
return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
|
| 1335 |
+
|
| 1336 |
+
return (
|
| 1337 |
+
<div className="fixed inset-0 w-full bg-background overflow-hidden">
|
| 1338 |
+
<Toaster />
|
| 1339 |
+
|
| 1340 |
+
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
| 1341 |
+
<div className="flex-shrink-0">
|
| 1342 |
+
<Header
|
| 1343 |
+
user={user}
|
| 1344 |
+
onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
| 1345 |
+
onUserClick={() => setShowProfileEditor(true)}
|
| 1346 |
+
isDarkMode={isDarkMode}
|
| 1347 |
+
onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
|
| 1348 |
+
language={language}
|
| 1349 |
+
onLanguageChange={setLanguage}
|
| 1350 |
+
workspaces={workspaces}
|
| 1351 |
+
currentWorkspace={currentWorkspace}
|
| 1352 |
+
onWorkspaceChange={setCurrentWorkspaceId}
|
| 1353 |
+
onCreateWorkspace={handleCreateWorkspace}
|
| 1354 |
+
onLogout={() => setUser(null)}
|
| 1355 |
+
availableCourses={availableCourses}
|
| 1356 |
+
onUserUpdate={handleUserSave}
|
| 1357 |
+
reviewStarOpacity={reviewStarOpacity}
|
| 1358 |
+
reviewEnergyPct={reviewEnergyPct}
|
| 1359 |
+
onStarClick={() => {
|
| 1360 |
+
setChatMode("review");
|
| 1361 |
+
setShowReviewBanner(false);
|
| 1362 |
+
localStorage.setItem("reviewBannerDismissed", "true");
|
| 1363 |
+
}}
|
| 1364 |
+
/>
|
| 1365 |
+
</div>
|
| 1366 |
+
|
| 1367 |
+
{showProfileEditor && user && (
|
| 1368 |
+
<ProfileEditor user={user} onSave={handleUserSave} onClose={() => setShowProfileEditor(false)} />
|
| 1369 |
+
)}
|
| 1370 |
+
|
| 1371 |
+
{showReviewBanner && (
|
| 1372 |
+
<div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50">
|
| 1373 |
+
<ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} />
|
| 1374 |
+
</div>
|
| 1375 |
+
)}
|
| 1376 |
+
|
| 1377 |
+
<div className="flex flex-1 min-h-0 min-w-0 overflow-hidden relative">
|
| 1378 |
+
{!leftPanelVisible && (
|
| 1379 |
+
<Button
|
| 1380 |
+
variant="secondary"
|
| 1381 |
+
size="icon"
|
| 1382 |
+
onClick={() => setLeftPanelVisible(true)}
|
| 1383 |
+
className="hidden lg:flex absolute z-[100] h-8 w-5 shadow-lg rounded-full bg-card border border-border transition-all duration-200 ease-in-out hover:translate-x-[10px]"
|
| 1384 |
+
style={{ left: "-5px", top: "1rem" }}
|
| 1385 |
+
title="Open panel"
|
| 1386 |
+
>
|
| 1387 |
+
<ChevronRight className="h-3 w-3" />
|
| 1388 |
+
</Button>
|
| 1389 |
+
)}
|
| 1390 |
+
|
| 1391 |
+
{leftSidebarOpen && (
|
| 1392 |
+
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />
|
| 1393 |
+
)}
|
| 1394 |
+
|
| 1395 |
+
{leftPanelVisible ? (
|
| 1396 |
+
<aside className="hidden lg:flex w-80 h-full min-h-0 min-w-0 bg-card border-r border-border overflow-hidden relative flex-col">
|
| 1397 |
+
<Button
|
| 1398 |
+
variant="secondary"
|
| 1399 |
+
size="icon"
|
| 1400 |
+
onClick={() => setLeftPanelVisible(false)}
|
| 1401 |
+
className="absolute z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
|
| 1402 |
+
style={{ right: "-10px", top: "1rem" }}
|
| 1403 |
+
title="Close panel"
|
| 1404 |
+
>
|
| 1405 |
+
<ChevronLeft className="h-3 w-3" />
|
| 1406 |
+
</Button>
|
| 1407 |
+
|
| 1408 |
+
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
|
| 1409 |
+
<LeftSidebar
|
| 1410 |
+
learningMode={learningMode}
|
| 1411 |
+
language={language}
|
| 1412 |
+
onLearningModeChange={setLearningMode}
|
| 1413 |
+
onLanguageChange={setLanguage}
|
| 1414 |
+
spaceType={sidebarSpaceType}
|
| 1415 |
+
groupMembers={sidebarGroupMembers}
|
| 1416 |
+
user={user}
|
| 1417 |
+
onLogin={setUser}
|
| 1418 |
+
onLogout={() => setUser(null)}
|
| 1419 |
+
isLoggedIn={!!user}
|
| 1420 |
+
onEditProfile={() => setShowProfileEditor(true)}
|
| 1421 |
+
savedItems={savedItems}
|
| 1422 |
+
recentlySavedId={recentlySavedId}
|
| 1423 |
+
onUnsave={handleUnsave}
|
| 1424 |
+
onSave={handleSave}
|
| 1425 |
+
savedChats={savedChats}
|
| 1426 |
+
onLoadChat={handleLoadChat}
|
| 1427 |
+
onDeleteSavedChat={handleDeleteSavedChat}
|
| 1428 |
+
onRenameSavedChat={handleRenameSavedChat}
|
| 1429 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1430 |
+
workspaces={sidebarWorkspaces}
|
| 1431 |
+
selectedCourse={currentCourseId}
|
| 1432 |
+
availableCourses={availableCourses}
|
| 1433 |
+
/>
|
| 1434 |
+
</div>
|
| 1435 |
+
</aside>
|
| 1436 |
+
) : null}
|
| 1437 |
+
|
| 1438 |
+
<aside
|
| 1439 |
+
className={[
|
| 1440 |
+
"fixed lg:hidden z-50",
|
| 1441 |
+
"left-0 top-0 bottom-0",
|
| 1442 |
+
"w-80 bg-card border-r border-border",
|
| 1443 |
+
"transform transition-transform duration-300 ease-in-out",
|
| 1444 |
+
leftSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
| 1445 |
+
"overflow-hidden flex flex-col",
|
| 1446 |
+
].join(" ")}
|
| 1447 |
+
>
|
| 1448 |
+
<div className="p-4 border-b border-border flex justify-between items-center flex-shrink-0">
|
| 1449 |
+
<h3>Settings & Guide</h3>
|
| 1450 |
+
<Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}>
|
| 1451 |
+
<X className="h-5 w-5" />
|
| 1452 |
+
</Button>
|
| 1453 |
+
</div>
|
| 1454 |
+
|
| 1455 |
+
<div className="flex-1 min-h-0 overflow-hidden">
|
| 1456 |
+
<LeftSidebar
|
| 1457 |
+
learningMode={learningMode}
|
| 1458 |
+
language={language}
|
| 1459 |
+
onLearningModeChange={setLearningMode}
|
| 1460 |
+
onLanguageChange={setLanguage}
|
| 1461 |
+
spaceType={sidebarSpaceType}
|
| 1462 |
+
groupMembers={sidebarGroupMembers}
|
| 1463 |
+
user={user}
|
| 1464 |
+
onLogin={setUser}
|
| 1465 |
+
onLogout={() => setUser(null)}
|
| 1466 |
+
isLoggedIn={!!user}
|
| 1467 |
+
onEditProfile={() => setShowProfileEditor(true)}
|
| 1468 |
+
savedItems={savedItems}
|
| 1469 |
+
recentlySavedId={recentlySavedId}
|
| 1470 |
+
onUnsave={handleUnsave}
|
| 1471 |
+
onSave={handleSave}
|
| 1472 |
+
savedChats={savedChats}
|
| 1473 |
+
onLoadChat={handleLoadChat}
|
| 1474 |
+
onDeleteSavedChat={handleDeleteSavedChat}
|
| 1475 |
+
onRenameSavedChat={handleRenameSavedChat}
|
| 1476 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1477 |
+
workspaces={sidebarWorkspaces}
|
| 1478 |
+
selectedCourse={currentCourseId}
|
| 1479 |
+
availableCourses={availableCourses}
|
| 1480 |
+
/>
|
| 1481 |
+
</div>
|
| 1482 |
+
</aside>
|
| 1483 |
+
|
| 1484 |
+
<main className="flex flex-1 min-w-0 min-h-0 overflow-hidden flex-col">
|
| 1485 |
+
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
|
| 1486 |
+
<ChatArea
|
| 1487 |
+
messages={messages}
|
| 1488 |
+
onSendMessage={handleSendMessage}
|
| 1489 |
+
uploadedFiles={uploadedFiles}
|
| 1490 |
+
onFileUpload={handleFileUpload}
|
| 1491 |
+
onRemoveFile={handleRemoveFile}
|
| 1492 |
+
onFileTypeChange={handleFileTypeChange}
|
| 1493 |
+
memoryProgress={memoryProgress}
|
| 1494 |
+
isLoggedIn={!!user}
|
| 1495 |
+
learningMode={learningMode}
|
| 1496 |
+
onClearConversation={() => setShowClearDialog(true)}
|
| 1497 |
+
onSaveChat={handleSaveChat}
|
| 1498 |
+
onLearningModeChange={setLearningMode}
|
| 1499 |
+
spaceType={spaceType}
|
| 1500 |
+
chatMode={chatMode}
|
| 1501 |
+
onChatModeChange={setChatMode}
|
| 1502 |
+
onNextQuestion={handleNextQuestion}
|
| 1503 |
+
onStartQuiz={handleStartQuiz}
|
| 1504 |
+
quizState={quizState}
|
| 1505 |
+
isTyping={isTyping}
|
| 1506 |
+
showClearDialog={showClearDialog}
|
| 1507 |
+
onConfirmClear={(shouldSave) => {
|
| 1508 |
+
handleClearConversation(shouldSave);
|
| 1509 |
+
setShowClearDialog(false);
|
| 1510 |
+
}}
|
| 1511 |
+
onCancelClear={() => setShowClearDialog(false)}
|
| 1512 |
+
savedChats={savedChats}
|
| 1513 |
+
workspaces={workspaces}
|
| 1514 |
+
currentWorkspaceId={currentWorkspaceId}
|
| 1515 |
+
onSaveFile={(content, type, _format, targetWorkspaceId) =>
|
| 1516 |
+
handleSave(content, type, false, (_format ?? "text") as "pdf" | "text", targetWorkspaceId)
|
| 1517 |
+
}
|
| 1518 |
+
leftPanelVisible={leftPanelVisible}
|
| 1519 |
+
currentCourseId={currentCourseId}
|
| 1520 |
+
onCourseChange={handleCourseChange}
|
| 1521 |
+
availableCourses={availableCourses}
|
| 1522 |
+
showReviewBanner={showReviewBanner}
|
| 1523 |
+
onReviewActivity={handleReviewActivity}
|
| 1524 |
+
currentUserId={user?.email}
|
| 1525 |
+
docType={"Syllabus"}
|
| 1526 |
+
// ✅ bio is still allowed to be updated by chat/Clare
|
| 1527 |
+
onProfileBioUpdate={(bio) => updateUser({ bio })}
|
| 1528 |
+
/>
|
| 1529 |
+
</div>
|
| 1530 |
+
</main>
|
| 1531 |
+
</div>
|
| 1532 |
+
</div>
|
| 1533 |
+
</div>
|
| 1534 |
+
);
|
| 1535 |
+
}
|
| 1536 |
+
|
| 1537 |
+
export default App;
|
web/src/Attributions.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
| 2 |
+
|
| 3 |
+
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
|
web/src/assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png
ADDED
|
web/src/assets/file-icons/pdf.png
ADDED
|
|
web/src/assets/file-icons/ppt.png
ADDED
|
|
web/src/components/ChatArea.tsx
ADDED
|
@@ -0,0 +1,1639 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/components/ChatArea.tsx
|
| 2 |
+
import pdfIcon from "../assets/file-icons/pdf.png";
|
| 3 |
+
import pptIcon from "../assets/file-icons/ppt.png";
|
| 4 |
+
import otherIcon from "../assets/file-icons/other_format.png";
|
| 5 |
+
|
| 6 |
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
| 7 |
+
|
| 8 |
+
import { Button } from "./ui/button";
|
| 9 |
+
import { Textarea } from "./ui/textarea";
|
| 10 |
+
import { Input } from "./ui/input";
|
| 11 |
+
import { Label } from "./ui/label";
|
| 12 |
+
import {
|
| 13 |
+
Send,
|
| 14 |
+
ArrowDown,
|
| 15 |
+
Share2,
|
| 16 |
+
Upload,
|
| 17 |
+
X,
|
| 18 |
+
Trash2,
|
| 19 |
+
File,
|
| 20 |
+
FileText,
|
| 21 |
+
Presentation,
|
| 22 |
+
Image as ImageIcon,
|
| 23 |
+
Bookmark,
|
| 24 |
+
Plus,
|
| 25 |
+
Download,
|
| 26 |
+
Copy,
|
| 27 |
+
} from "lucide-react";
|
| 28 |
+
|
| 29 |
+
import { Message } from "./Message";
|
| 30 |
+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
| 31 |
+
import type {
|
| 32 |
+
Message as MessageType,
|
| 33 |
+
LearningMode,
|
| 34 |
+
UploadedFile,
|
| 35 |
+
FileType,
|
| 36 |
+
SpaceType,
|
| 37 |
+
ChatMode,
|
| 38 |
+
SavedChat,
|
| 39 |
+
Workspace,
|
| 40 |
+
} from "../App";
|
| 41 |
+
import { toast } from "sonner";
|
| 42 |
+
import { jsPDF } from "jspdf";
|
| 43 |
+
import {
|
| 44 |
+
DropdownMenu,
|
| 45 |
+
DropdownMenuContent,
|
| 46 |
+
DropdownMenuItem,
|
| 47 |
+
DropdownMenuTrigger,
|
| 48 |
+
} from "./ui/dropdown-menu";
|
| 49 |
+
import {
|
| 50 |
+
Dialog,
|
| 51 |
+
DialogContent,
|
| 52 |
+
DialogDescription,
|
| 53 |
+
DialogFooter,
|
| 54 |
+
DialogHeader,
|
| 55 |
+
DialogTitle,
|
| 56 |
+
} from "./ui/dialog";
|
| 57 |
+
import { Checkbox } from "./ui/checkbox";
|
| 58 |
+
import {
|
| 59 |
+
AlertDialog,
|
| 60 |
+
AlertDialogAction,
|
| 61 |
+
AlertDialogCancel,
|
| 62 |
+
AlertDialogContent,
|
| 63 |
+
AlertDialogDescription,
|
| 64 |
+
AlertDialogFooter,
|
| 65 |
+
AlertDialogHeader,
|
| 66 |
+
AlertDialogTitle,
|
| 67 |
+
} from "./ui/alert-dialog";
|
| 68 |
+
import {
|
| 69 |
+
Select,
|
| 70 |
+
SelectContent,
|
| 71 |
+
SelectItem,
|
| 72 |
+
SelectTrigger,
|
| 73 |
+
SelectValue,
|
| 74 |
+
} from "./ui/select";
|
| 75 |
+
import { SmartReview } from "./SmartReview";
|
| 76 |
+
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 77 |
+
|
| 78 |
+
// NEW
|
| 79 |
+
import { useObjectUrlCache } from "../lib/useObjectUrlCache";
|
| 80 |
+
|
| 81 |
+
type ReviewEventType = "send_message" | "review_topic" | "review_all";
|
| 82 |
+
|
| 83 |
+
interface ChatAreaProps {
|
| 84 |
+
messages: MessageType[];
|
| 85 |
+
onSendMessage: (content: string) => void;
|
| 86 |
+
uploadedFiles: UploadedFile[];
|
| 87 |
+
onFileUpload: (files: File[]) => void;
|
| 88 |
+
onRemoveFile: (index: number) => void;
|
| 89 |
+
|
| 90 |
+
onProfileBioUpdate?: (bio: string) => void; // still allowed (ProfileEditor / future AI updates)
|
| 91 |
+
|
| 92 |
+
onFileTypeChange: (index: number, type: FileType) => void;
|
| 93 |
+
memoryProgress: number;
|
| 94 |
+
isLoggedIn: boolean;
|
| 95 |
+
learningMode: LearningMode;
|
| 96 |
+
onClearConversation: () => void;
|
| 97 |
+
onSaveChat: () => void;
|
| 98 |
+
onLearningModeChange: (mode: LearningMode) => void;
|
| 99 |
+
spaceType: SpaceType;
|
| 100 |
+
chatMode: ChatMode;
|
| 101 |
+
onChatModeChange: (mode: ChatMode) => void;
|
| 102 |
+
onNextQuestion: () => void;
|
| 103 |
+
onStartQuiz: () => void;
|
| 104 |
+
quizState: {
|
| 105 |
+
currentQuestion: number;
|
| 106 |
+
waitingForAnswer: boolean;
|
| 107 |
+
showNextButton: boolean;
|
| 108 |
+
};
|
| 109 |
+
isTyping: boolean;
|
| 110 |
+
showClearDialog: boolean;
|
| 111 |
+
onConfirmClear: (shouldSave: boolean) => void;
|
| 112 |
+
onCancelClear: () => void;
|
| 113 |
+
savedChats: SavedChat[];
|
| 114 |
+
workspaces: Workspace[];
|
| 115 |
+
currentWorkspaceId: string;
|
| 116 |
+
onSaveFile?: (
|
| 117 |
+
content: string,
|
| 118 |
+
type: "export" | "summary",
|
| 119 |
+
format?: "pdf" | "text",
|
| 120 |
+
workspaceId?: string
|
| 121 |
+
) => void;
|
| 122 |
+
leftPanelVisible?: boolean;
|
| 123 |
+
currentCourseId?: string;
|
| 124 |
+
onCourseChange?: (courseId: string) => void;
|
| 125 |
+
availableCourses?: Array<{ id: string; name: string }>;
|
| 126 |
+
showReviewBanner?: boolean;
|
| 127 |
+
|
| 128 |
+
onReviewActivity?: (event: ReviewEventType) => void;
|
| 129 |
+
currentUserId?: string; // backend user_id
|
| 130 |
+
docType?: string; // backend doc_type (optional)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
interface PendingFile {
|
| 134 |
+
file: File;
|
| 135 |
+
type: FileType;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// File viewer content (image full preview + pdf iframe; others download)
|
| 139 |
+
function isImageFile(name: string) {
|
| 140 |
+
const n = name.toLowerCase();
|
| 141 |
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => n.endsWith(e));
|
| 142 |
+
}
|
| 143 |
+
function isPdfFile(name: string) {
|
| 144 |
+
return name.toLowerCase().endsWith(".pdf");
|
| 145 |
+
}
|
| 146 |
+
function isDocFile(name: string) {
|
| 147 |
+
const n = name.toLowerCase();
|
| 148 |
+
return n.endsWith(".doc") || n.endsWith(".docx");
|
| 149 |
+
}
|
| 150 |
+
function isPptFile(name: string) {
|
| 151 |
+
const n = name.toLowerCase();
|
| 152 |
+
return n.endsWith(".ppt") || n.endsWith(".pptx");
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function FileViewerContent({ file }: { file: File }) {
|
| 156 |
+
const [url, setUrl] = React.useState<string>("");
|
| 157 |
+
|
| 158 |
+
React.useEffect(() => {
|
| 159 |
+
const u = URL.createObjectURL(file);
|
| 160 |
+
setUrl(u);
|
| 161 |
+
return () => URL.revokeObjectURL(u);
|
| 162 |
+
}, [file]);
|
| 163 |
+
|
| 164 |
+
if (isImageFile(file.name)) {
|
| 165 |
+
return (
|
| 166 |
+
<div className="w-full">
|
| 167 |
+
<img
|
| 168 |
+
src={url}
|
| 169 |
+
alt={file.name}
|
| 170 |
+
className="w-full h-auto rounded-lg border"
|
| 171 |
+
draggable={false}
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (isPdfFile(file.name)) {
|
| 178 |
+
const pdfBlob = new Blob([file], { type: "application/pdf" });
|
| 179 |
+
const pdfUrl = URL.createObjectURL(pdfBlob);
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<div className="w-full h-[70vh] border rounded-lg overflow-hidden">
|
| 183 |
+
<object data={pdfUrl} type="application/pdf" className="w-full h-full">
|
| 184 |
+
<div className="p-3 space-y-2">
|
| 185 |
+
<div className="text-sm text-muted-foreground">
|
| 186 |
+
PDF preview is blocked by your browser. Please open it in a new tab or download.
|
| 187 |
+
</div>
|
| 188 |
+
<div className="flex gap-2">
|
| 189 |
+
<a
|
| 190 |
+
href={pdfUrl}
|
| 191 |
+
target="_blank"
|
| 192 |
+
rel="noreferrer"
|
| 193 |
+
className="inline-flex items-center justify-center h-9 px-3 rounded-md border hover:bg-muted"
|
| 194 |
+
>
|
| 195 |
+
Open in new tab
|
| 196 |
+
</a>
|
| 197 |
+
<a
|
| 198 |
+
href={pdfUrl}
|
| 199 |
+
download={file.name}
|
| 200 |
+
className="inline-flex items-center justify-center h-9 px-3 rounded-md border hover:bg-muted"
|
| 201 |
+
>
|
| 202 |
+
Download
|
| 203 |
+
</a>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</object>
|
| 207 |
+
</div>
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const kind = isDocFile(file.name)
|
| 212 |
+
? "Word document"
|
| 213 |
+
: isPptFile(file.name)
|
| 214 |
+
? "PowerPoint"
|
| 215 |
+
: "File";
|
| 216 |
+
|
| 217 |
+
return (
|
| 218 |
+
<div className="space-y-3">
|
| 219 |
+
<div className="text-sm text-muted-foreground">
|
| 220 |
+
Preview is not available for this {kind} format in the browser without conversion.
|
| 221 |
+
</div>
|
| 222 |
+
<a
|
| 223 |
+
href={url}
|
| 224 |
+
download={file.name}
|
| 225 |
+
className="inline-flex items-center justify-center h-9 px-3 rounded-md border hover:bg-muted"
|
| 226 |
+
>
|
| 227 |
+
Download to view
|
| 228 |
+
</a>
|
| 229 |
+
</div>
|
| 230 |
+
);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
export function ChatArea({
|
| 234 |
+
messages,
|
| 235 |
+
onSendMessage,
|
| 236 |
+
uploadedFiles,
|
| 237 |
+
onFileUpload,
|
| 238 |
+
onRemoveFile,
|
| 239 |
+
onFileTypeChange,
|
| 240 |
+
memoryProgress,
|
| 241 |
+
isLoggedIn,
|
| 242 |
+
learningMode,
|
| 243 |
+
onClearConversation,
|
| 244 |
+
onSaveChat,
|
| 245 |
+
onLearningModeChange,
|
| 246 |
+
spaceType,
|
| 247 |
+
chatMode,
|
| 248 |
+
onChatModeChange,
|
| 249 |
+
onNextQuestion,
|
| 250 |
+
onStartQuiz,
|
| 251 |
+
quizState,
|
| 252 |
+
isTyping: isAppTyping,
|
| 253 |
+
showClearDialog,
|
| 254 |
+
onConfirmClear,
|
| 255 |
+
onCancelClear,
|
| 256 |
+
savedChats,
|
| 257 |
+
workspaces,
|
| 258 |
+
currentWorkspaceId,
|
| 259 |
+
onSaveFile,
|
| 260 |
+
leftPanelVisible = false,
|
| 261 |
+
currentCourseId,
|
| 262 |
+
onCourseChange,
|
| 263 |
+
availableCourses = [],
|
| 264 |
+
showReviewBanner = false,
|
| 265 |
+
onReviewActivity,
|
| 266 |
+
onProfileBioUpdate,
|
| 267 |
+
currentUserId,
|
| 268 |
+
docType,
|
| 269 |
+
}: ChatAreaProps) {
|
| 270 |
+
const [input, setInput] = useState("");
|
| 271 |
+
const [showScrollButton, setShowScrollButton] = useState(false);
|
| 272 |
+
const [showTopBorder, setShowTopBorder] = useState(false);
|
| 273 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 274 |
+
|
| 275 |
+
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 276 |
+
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
| 277 |
+
|
| 278 |
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
| 279 |
+
const [fileToDelete, setFileToDelete] = useState<number | null>(null);
|
| 280 |
+
|
| 281 |
+
const [selectedFile, setSelectedFile] = useState<{
|
| 282 |
+
file: File;
|
| 283 |
+
index: number;
|
| 284 |
+
} | null>(null);
|
| 285 |
+
const [showFileViewer, setShowFileViewer] = useState(false);
|
| 286 |
+
|
| 287 |
+
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
| 288 |
+
const [downloadPreview, setDownloadPreview] = useState("");
|
| 289 |
+
const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat");
|
| 290 |
+
const [downloadOptions, setDownloadOptions] = useState({
|
| 291 |
+
chat: true,
|
| 292 |
+
summary: false,
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
const [showShareDialog, setShowShareDialog] = useState(false);
|
| 296 |
+
const [shareLink, setShareLink] = useState("");
|
| 297 |
+
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 298 |
+
|
| 299 |
+
const courses =
|
| 300 |
+
availableCourses.length > 0
|
| 301 |
+
? availableCourses
|
| 302 |
+
: [
|
| 303 |
+
{ id: "course1", name: "Introduction to AI" },
|
| 304 |
+
{ id: "course2", name: "Machine Learning" },
|
| 305 |
+
{ id: "course3", name: "Data Structures" },
|
| 306 |
+
{ id: "course4", name: "Web Development" },
|
| 307 |
+
];
|
| 308 |
+
|
| 309 |
+
// Scroll refs
|
| 310 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 311 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 312 |
+
|
| 313 |
+
// Composer measured height (dynamic) to reserve bottom padding for messages
|
| 314 |
+
const composerRef = useRef<HTMLDivElement>(null);
|
| 315 |
+
const [composerHeight, setComposerHeight] = useState<number>(160);
|
| 316 |
+
|
| 317 |
+
useLayoutEffect(() => {
|
| 318 |
+
const el = composerRef.current;
|
| 319 |
+
if (!el) return;
|
| 320 |
+
|
| 321 |
+
const update = () => setComposerHeight(el.getBoundingClientRect().height);
|
| 322 |
+
update();
|
| 323 |
+
|
| 324 |
+
const ro = new ResizeObserver(() => update());
|
| 325 |
+
ro.observe(el);
|
| 326 |
+
|
| 327 |
+
return () => ro.disconnect();
|
| 328 |
+
}, []);
|
| 329 |
+
|
| 330 |
+
const isInitialMount = useRef(true);
|
| 331 |
+
const previousMessagesLength = useRef(messages.length);
|
| 332 |
+
|
| 333 |
+
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
| 334 |
+
const el = scrollContainerRef.current;
|
| 335 |
+
if (!el) return;
|
| 336 |
+
|
| 337 |
+
const top = el.scrollHeight - el.clientHeight;
|
| 338 |
+
if (behavior === "auto") {
|
| 339 |
+
el.scrollTop = top;
|
| 340 |
+
return;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
el.scrollTo({ top, behavior });
|
| 344 |
+
};
|
| 345 |
+
|
| 346 |
+
useEffect(() => {
|
| 347 |
+
if (isInitialMount.current) {
|
| 348 |
+
isInitialMount.current = false;
|
| 349 |
+
previousMessagesLength.current = messages.length;
|
| 350 |
+
|
| 351 |
+
const el = scrollContainerRef.current;
|
| 352 |
+
if (el) el.scrollTop = 0;
|
| 353 |
+
return;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
if (messages.length > previousMessagesLength.current) {
|
| 357 |
+
const el = scrollContainerRef.current;
|
| 358 |
+
if (el) {
|
| 359 |
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
|
| 360 |
+
if (nearBottom) scrollToBottom("smooth");
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
previousMessagesLength.current = messages.length;
|
| 364 |
+
}, [messages]);
|
| 365 |
+
|
| 366 |
+
useEffect(() => {
|
| 367 |
+
const container = scrollContainerRef.current;
|
| 368 |
+
if (!container) return;
|
| 369 |
+
|
| 370 |
+
const handleScroll = () => {
|
| 371 |
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
| 372 |
+
const isAtBottom = scrollHeight - scrollTop - clientHeight < 120;
|
| 373 |
+
setShowScrollButton(!isAtBottom);
|
| 374 |
+
setShowTopBorder(scrollTop > 0);
|
| 375 |
+
};
|
| 376 |
+
|
| 377 |
+
handleScroll();
|
| 378 |
+
container.addEventListener("scroll", handleScroll, { passive: true });
|
| 379 |
+
return () => container.removeEventListener("scroll", handleScroll);
|
| 380 |
+
}, []);
|
| 381 |
+
|
| 382 |
+
const handleSubmit = async (e: React.FormEvent | React.KeyboardEvent) => {
|
| 383 |
+
e.preventDefault();
|
| 384 |
+
if (!isLoggedIn) return;
|
| 385 |
+
|
| 386 |
+
const hasText = !!input.trim();
|
| 387 |
+
const hasFiles = uploadedFiles.length > 0;
|
| 388 |
+
|
| 389 |
+
if (!hasText && !hasFiles) return;
|
| 390 |
+
|
| 391 |
+
if (chatMode === "review") onReviewActivity?.("send_message");
|
| 392 |
+
|
| 393 |
+
onSendMessage(input);
|
| 394 |
+
|
| 395 |
+
setInput("");
|
| 396 |
+
setPendingFiles([]);
|
| 397 |
+
|
| 398 |
+
for (let i = uploadedFiles.length - 1; i >= 0; i--) {
|
| 399 |
+
onRemoveFile(i);
|
| 400 |
+
}
|
| 401 |
+
};
|
| 402 |
+
|
| 403 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 404 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 405 |
+
e.preventDefault();
|
| 406 |
+
handleSubmit(e);
|
| 407 |
+
}
|
| 408 |
+
};
|
| 409 |
+
|
| 410 |
+
const modeLabels: Record<LearningMode, string> = {
|
| 411 |
+
general: "General",
|
| 412 |
+
concept: "Concept Explainer",
|
| 413 |
+
socratic: "Socratic Tutor",
|
| 414 |
+
exam: "Exam Prep",
|
| 415 |
+
assignment: "Assignment Helper",
|
| 416 |
+
summary: "Quick Summary",
|
| 417 |
+
};
|
| 418 |
+
|
| 419 |
+
const handleReviewTopic = (item: {
|
| 420 |
+
title: string;
|
| 421 |
+
previousQuestion: string;
|
| 422 |
+
memoryRetention: number;
|
| 423 |
+
schedule: string;
|
| 424 |
+
status: string;
|
| 425 |
+
weight: number;
|
| 426 |
+
lastReviewed: string;
|
| 427 |
+
}) => {
|
| 428 |
+
onReviewActivity?.("review_topic");
|
| 429 |
+
|
| 430 |
+
const userMessage = `Please help me review: ${item.title}`;
|
| 431 |
+
const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`;
|
| 432 |
+
(window as any).__lastReviewData = reviewData;
|
| 433 |
+
onSendMessage(userMessage);
|
| 434 |
+
};
|
| 435 |
+
|
| 436 |
+
const handleReviewAll = () => {
|
| 437 |
+
onReviewActivity?.("review_all");
|
| 438 |
+
(window as any).__lastReviewData = "REVIEW_ALL";
|
| 439 |
+
onSendMessage("Please help me review all topics that need attention.");
|
| 440 |
+
};
|
| 441 |
+
|
| 442 |
+
const buildPreviewContent = () => {
|
| 443 |
+
if (!messages.length) return "";
|
| 444 |
+
return messages
|
| 445 |
+
.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`)
|
| 446 |
+
.join("\n\n");
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
const buildSummaryContent = () => {
|
| 450 |
+
if (!messages.length) return "No messages to summarize.";
|
| 451 |
+
|
| 452 |
+
const userMessages = messages.filter((msg) => msg.role === "user");
|
| 453 |
+
const assistantMessages = messages.filter((msg) => msg.role === "assistant");
|
| 454 |
+
|
| 455 |
+
let summary = `Chat Summary\n================\n\n`;
|
| 456 |
+
summary += `Total Messages: ${messages.length}\n`;
|
| 457 |
+
summary += `- User Messages: ${userMessages.length}\n`;
|
| 458 |
+
summary += `- Assistant Responses: ${assistantMessages.length}\n\n`;
|
| 459 |
+
|
| 460 |
+
summary += `Key Points:\n`;
|
| 461 |
+
userMessages.slice(0, 3).forEach((msg, idx) => {
|
| 462 |
+
const preview = msg.content.substring(0, 80);
|
| 463 |
+
summary += `${idx + 1}. ${preview}${msg.content.length > 80 ? "..." : ""}\n`;
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
return summary;
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
const handleOpenDownloadDialog = () => {
|
| 470 |
+
setDownloadTab("chat");
|
| 471 |
+
setDownloadOptions({ chat: true, summary: false });
|
| 472 |
+
setDownloadPreview(buildPreviewContent());
|
| 473 |
+
setShowDownloadDialog(true);
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
const handleCopyPreview = async () => {
|
| 477 |
+
try {
|
| 478 |
+
await navigator.clipboard.writeText(downloadPreview);
|
| 479 |
+
toast.success("Copied preview");
|
| 480 |
+
} catch {
|
| 481 |
+
toast.error("Copy failed");
|
| 482 |
+
}
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
const handleDownloadFile = async () => {
|
| 486 |
+
try {
|
| 487 |
+
let contentToPdf = "";
|
| 488 |
+
|
| 489 |
+
if (downloadOptions.chat) contentToPdf += buildPreviewContent();
|
| 490 |
+
|
| 491 |
+
if (downloadOptions.summary) {
|
| 492 |
+
if (downloadOptions.chat) contentToPdf += "\n\n================\n\n";
|
| 493 |
+
contentToPdf += buildSummaryContent();
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
if (!contentToPdf.trim()) {
|
| 497 |
+
toast.error("Please select at least one option");
|
| 498 |
+
return;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
const pdf = new jsPDF({
|
| 502 |
+
orientation: "portrait",
|
| 503 |
+
unit: "mm",
|
| 504 |
+
format: "a4",
|
| 505 |
+
});
|
| 506 |
+
|
| 507 |
+
pdf.setFontSize(14);
|
| 508 |
+
pdf.text("Chat Export", 10, 10);
|
| 509 |
+
pdf.setFontSize(11);
|
| 510 |
+
|
| 511 |
+
const pageHeight = pdf.internal.pageSize.getHeight();
|
| 512 |
+
const margin = 10;
|
| 513 |
+
const maxWidth = 190;
|
| 514 |
+
const lineHeight = 5;
|
| 515 |
+
let y = 20;
|
| 516 |
+
|
| 517 |
+
const lines = pdf.splitTextToSize(contentToPdf, maxWidth);
|
| 518 |
+
lines.forEach((line: string) => {
|
| 519 |
+
if (y > pageHeight - margin) {
|
| 520 |
+
pdf.addPage();
|
| 521 |
+
y = margin;
|
| 522 |
+
}
|
| 523 |
+
pdf.text(line, margin, y);
|
| 524 |
+
y += lineHeight;
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
+
pdf.save("chat-export.pdf");
|
| 528 |
+
setShowDownloadDialog(false);
|
| 529 |
+
toast.success("PDF downloaded successfully");
|
| 530 |
+
} catch (error) {
|
| 531 |
+
// eslint-disable-next-line no-console
|
| 532 |
+
console.error("PDF generation error:", error);
|
| 533 |
+
toast.error("Failed to generate PDF");
|
| 534 |
+
}
|
| 535 |
+
};
|
| 536 |
+
|
| 537 |
+
const isCurrentChatSaved = (): boolean => {
|
| 538 |
+
if (messages.length <= 1) return false;
|
| 539 |
+
|
| 540 |
+
return savedChats.some((chat) => {
|
| 541 |
+
if (chat.chatMode !== chatMode) return false;
|
| 542 |
+
if (chat.messages.length !== messages.length) return false;
|
| 543 |
+
|
| 544 |
+
return chat.messages.every((savedMsg, idx) => {
|
| 545 |
+
const currentMsg = messages[idx];
|
| 546 |
+
return (
|
| 547 |
+
savedMsg.id === currentMsg.id &&
|
| 548 |
+
savedMsg.role === currentMsg.role &&
|
| 549 |
+
savedMsg.content === currentMsg.content
|
| 550 |
+
);
|
| 551 |
+
});
|
| 552 |
+
});
|
| 553 |
+
};
|
| 554 |
+
|
| 555 |
+
const handleSaveClick = () => {
|
| 556 |
+
if (messages.length <= 1) {
|
| 557 |
+
toast.info("No conversation to save");
|
| 558 |
+
return;
|
| 559 |
+
}
|
| 560 |
+
onSaveChat();
|
| 561 |
+
};
|
| 562 |
+
|
| 563 |
+
const handleShareClick = () => {
|
| 564 |
+
if (messages.length <= 1) {
|
| 565 |
+
toast.info("No conversation to share");
|
| 566 |
+
return;
|
| 567 |
+
}
|
| 568 |
+
const conversationText = buildPreviewContent();
|
| 569 |
+
const blob = new Blob([conversationText], { type: "text/plain" });
|
| 570 |
+
const url = URL.createObjectURL(blob);
|
| 571 |
+
setShareLink(url);
|
| 572 |
+
setTargetWorkspaceId(currentWorkspaceId);
|
| 573 |
+
setShowShareDialog(true);
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
const handleCopyShareLink = async () => {
|
| 577 |
+
try {
|
| 578 |
+
await navigator.clipboard.writeText(shareLink);
|
| 579 |
+
toast.success("Link copied");
|
| 580 |
+
} catch {
|
| 581 |
+
toast.error("Failed to copy link");
|
| 582 |
+
}
|
| 583 |
+
};
|
| 584 |
+
|
| 585 |
+
const handleShareSendToWorkspace = () => {
|
| 586 |
+
const content = buildPreviewContent();
|
| 587 |
+
onSaveFile?.(content, "export", "text", targetWorkspaceId);
|
| 588 |
+
setShowShareDialog(false);
|
| 589 |
+
toast.success("Sent to workspace Saved Files");
|
| 590 |
+
};
|
| 591 |
+
|
| 592 |
+
const handleClearClick = () => {
|
| 593 |
+
const saved = isCurrentChatSaved();
|
| 594 |
+
|
| 595 |
+
if (saved) {
|
| 596 |
+
onConfirmClear(false as any);
|
| 597 |
+
return;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
const hasUserMessages = messages.some((m) => m.role === "user");
|
| 601 |
+
if (!hasUserMessages) {
|
| 602 |
+
onClearConversation();
|
| 603 |
+
return;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
onClearConversation();
|
| 607 |
+
};
|
| 608 |
+
|
| 609 |
+
// DnD
|
| 610 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 611 |
+
e.preventDefault();
|
| 612 |
+
e.stopPropagation();
|
| 613 |
+
if (!isLoggedIn) return;
|
| 614 |
+
setIsDragging(true);
|
| 615 |
+
};
|
| 616 |
+
|
| 617 |
+
const handleDragLeave = (e: React.DragEvent) => {
|
| 618 |
+
e.preventDefault();
|
| 619 |
+
e.stopPropagation();
|
| 620 |
+
setIsDragging(false);
|
| 621 |
+
};
|
| 622 |
+
|
| 623 |
+
const MAX_UPLOAD_FILES = 10;
|
| 624 |
+
|
| 625 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 626 |
+
e.preventDefault();
|
| 627 |
+
e.stopPropagation();
|
| 628 |
+
setIsDragging(false);
|
| 629 |
+
if (!isLoggedIn) return;
|
| 630 |
+
|
| 631 |
+
const fileList = e.dataTransfer.files;
|
| 632 |
+
const files: File[] = [];
|
| 633 |
+
for (let i = 0; i < fileList.length; i++) {
|
| 634 |
+
const f = fileList.item(i);
|
| 635 |
+
if (f) files.push(f);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
const validFiles = files.filter((file) => {
|
| 639 |
+
const ext = file.name.toLowerCase();
|
| 640 |
+
return [
|
| 641 |
+
".pdf",
|
| 642 |
+
".docx",
|
| 643 |
+
".pptx",
|
| 644 |
+
".jpg",
|
| 645 |
+
".jpeg",
|
| 646 |
+
".png",
|
| 647 |
+
".gif",
|
| 648 |
+
".webp",
|
| 649 |
+
".doc",
|
| 650 |
+
".ppt",
|
| 651 |
+
].some((allowed) => ext.endsWith(allowed));
|
| 652 |
+
});
|
| 653 |
+
|
| 654 |
+
if (validFiles.length === 0) {
|
| 655 |
+
toast.error("Please upload .pdf, .docx, .pptx, or image files");
|
| 656 |
+
return;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 660 |
+
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 661 |
+
|
| 662 |
+
if (remaining <= 0) {
|
| 663 |
+
toast.error(`Each conversation can upload up to ${MAX_UPLOAD_FILES} files.`);
|
| 664 |
+
return;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
const accepted = validFiles.slice(0, remaining);
|
| 668 |
+
const rejected = validFiles.length - accepted.length;
|
| 669 |
+
|
| 670 |
+
if (rejected > 0) {
|
| 671 |
+
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
setPendingFiles((prev) => [
|
| 675 |
+
...prev,
|
| 676 |
+
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
| 677 |
+
]);
|
| 678 |
+
|
| 679 |
+
setShowTypeDialog(true);
|
| 680 |
+
};
|
| 681 |
+
|
| 682 |
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 683 |
+
const files = Array.from(e.target.files || []) as File[];
|
| 684 |
+
|
| 685 |
+
if (files.length === 0) {
|
| 686 |
+
e.target.value = "";
|
| 687 |
+
return;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
const validFiles = files.filter((file) => {
|
| 691 |
+
const ext = file.name.toLowerCase();
|
| 692 |
+
return [
|
| 693 |
+
".pdf",
|
| 694 |
+
".docx",
|
| 695 |
+
".pptx",
|
| 696 |
+
".jpg",
|
| 697 |
+
".jpeg",
|
| 698 |
+
".png",
|
| 699 |
+
".gif",
|
| 700 |
+
".webp",
|
| 701 |
+
".doc",
|
| 702 |
+
".ppt",
|
| 703 |
+
].some((allowed) => ext.endsWith(allowed));
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
if (validFiles.length === 0) {
|
| 707 |
+
toast.error("Please upload .pdf, .docx, .pptx, or image files");
|
| 708 |
+
e.target.value = "";
|
| 709 |
+
return;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
const currentCount = uploadedFiles.length + pendingFiles.length;
|
| 713 |
+
const remaining = MAX_UPLOAD_FILES - currentCount;
|
| 714 |
+
|
| 715 |
+
if (remaining <= 0) {
|
| 716 |
+
toast.error(`Each conversation can upload up to ${MAX_UPLOAD_FILES} files.`);
|
| 717 |
+
e.target.value = "";
|
| 718 |
+
return;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
const accepted = validFiles.slice(0, remaining);
|
| 722 |
+
const rejected = validFiles.length - accepted.length;
|
| 723 |
+
|
| 724 |
+
if (rejected > 0) {
|
| 725 |
+
toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`);
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
setPendingFiles((prev) => [
|
| 729 |
+
...prev,
|
| 730 |
+
...accepted.map((file) => ({ file, type: "other" as FileType })),
|
| 731 |
+
]);
|
| 732 |
+
|
| 733 |
+
setShowTypeDialog(true);
|
| 734 |
+
e.target.value = "";
|
| 735 |
+
};
|
| 736 |
+
|
| 737 |
+
const handleConfirmUpload = () => {
|
| 738 |
+
onFileUpload(pendingFiles.map((pf) => pf.file));
|
| 739 |
+
const startIndex = uploadedFiles.length;
|
| 740 |
+
|
| 741 |
+
pendingFiles.forEach((pf, idx) => {
|
| 742 |
+
setTimeout(() => {
|
| 743 |
+
onFileTypeChange(startIndex + idx, pf.type);
|
| 744 |
+
}, 0);
|
| 745 |
+
});
|
| 746 |
+
|
| 747 |
+
const count = pendingFiles.length;
|
| 748 |
+
setPendingFiles([]);
|
| 749 |
+
setShowTypeDialog(false);
|
| 750 |
+
toast.success(`${count} file(s) uploaded successfully`);
|
| 751 |
+
};
|
| 752 |
+
|
| 753 |
+
const handleCancelUpload = () => {
|
| 754 |
+
setPendingFiles([]);
|
| 755 |
+
setShowTypeDialog(false);
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 759 |
+
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 760 |
+
};
|
| 761 |
+
|
| 762 |
+
const getFileIcon = (filename: string) => {
|
| 763 |
+
const ext = filename.toLowerCase();
|
| 764 |
+
if (ext.endsWith(".pdf")) return FileText;
|
| 765 |
+
if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File;
|
| 766 |
+
if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation;
|
| 767 |
+
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return ImageIcon;
|
| 768 |
+
return File;
|
| 769 |
+
};
|
| 770 |
+
|
| 771 |
+
const formatFileSize = (bytes: number) => {
|
| 772 |
+
if (bytes < 1024) return `${bytes} B`;
|
| 773 |
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 774 |
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 775 |
+
};
|
| 776 |
+
|
| 777 |
+
const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 778 |
+
|
| 779 |
+
const allThumbFiles = useMemo(() => {
|
| 780 |
+
return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)];
|
| 781 |
+
}, [uploadedFiles, pendingFiles]);
|
| 782 |
+
|
| 783 |
+
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 784 |
+
|
| 785 |
+
const FileChip = ({
|
| 786 |
+
file,
|
| 787 |
+
index,
|
| 788 |
+
source,
|
| 789 |
+
}: {
|
| 790 |
+
file: File;
|
| 791 |
+
index: number;
|
| 792 |
+
source: "uploaded" | "pending";
|
| 793 |
+
}) => {
|
| 794 |
+
const ext = file.name.toLowerCase();
|
| 795 |
+
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 796 |
+
|
| 797 |
+
const isPdf = ext.endsWith(".pdf");
|
| 798 |
+
const isPpt = ext.endsWith(".ppt") || ext.endsWith(".pptx");
|
| 799 |
+
const fileIcon = isPdf ? pdfIcon : isPpt ? pptIcon : otherIcon;
|
| 800 |
+
|
| 801 |
+
const label = isPdf
|
| 802 |
+
? "PDF"
|
| 803 |
+
: isPpt
|
| 804 |
+
? "Presentation"
|
| 805 |
+
: ext.endsWith(".docx") || ext.endsWith(".doc")
|
| 806 |
+
? "Document"
|
| 807 |
+
: isImage
|
| 808 |
+
? "Image"
|
| 809 |
+
: "File";
|
| 810 |
+
|
| 811 |
+
const thumbUrl = isImage ? getOrCreate(file) : null;
|
| 812 |
+
|
| 813 |
+
const handleRemove = () => {
|
| 814 |
+
if (source === "uploaded") onRemoveFile(index);
|
| 815 |
+
else setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
|
| 816 |
+
};
|
| 817 |
+
|
| 818 |
+
return (
|
| 819 |
+
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[320px] max-w-full">
|
| 820 |
+
<button
|
| 821 |
+
type="button"
|
| 822 |
+
onClick={(e) => {
|
| 823 |
+
e.preventDefault();
|
| 824 |
+
e.stopPropagation();
|
| 825 |
+
handleRemove();
|
| 826 |
+
}}
|
| 827 |
+
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
|
| 828 |
+
title="Remove"
|
| 829 |
+
>
|
| 830 |
+
<Trash2 className="h-4 w-4" />
|
| 831 |
+
</button>
|
| 832 |
+
|
| 833 |
+
<div className="min-w-0 flex-1">
|
| 834 |
+
<div className="text-sm font-medium truncate" title={file.name}>
|
| 835 |
+
{file.name}
|
| 836 |
+
</div>
|
| 837 |
+
<div className="text-xs text-muted-foreground">{label}</div>
|
| 838 |
+
</div>
|
| 839 |
+
|
| 840 |
+
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 841 |
+
{isImage ? (
|
| 842 |
+
thumbUrl ? (
|
| 843 |
+
<img
|
| 844 |
+
src={thumbUrl}
|
| 845 |
+
alt={file.name}
|
| 846 |
+
className="h-full w-full object-cover"
|
| 847 |
+
draggable={false}
|
| 848 |
+
/>
|
| 849 |
+
) : (
|
| 850 |
+
<div className="h-full w-full flex items-center justify-center">
|
| 851 |
+
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 852 |
+
</div>
|
| 853 |
+
)
|
| 854 |
+
) : (
|
| 855 |
+
<img
|
| 856 |
+
src={fileIcon}
|
| 857 |
+
alt={file.name}
|
| 858 |
+
className="h-full w-full object-contain p-1"
|
| 859 |
+
draggable={false}
|
| 860 |
+
/>
|
| 861 |
+
)}
|
| 862 |
+
</div>
|
| 863 |
+
</div>
|
| 864 |
+
);
|
| 865 |
+
};
|
| 866 |
+
|
| 867 |
+
const bottomPad = Math.max(24, composerHeight + 24);
|
| 868 |
+
|
| 869 |
+
return (
|
| 870 |
+
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
|
| 871 |
+
{/* Top Bar */}
|
| 872 |
+
<div
|
| 873 |
+
className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
|
| 874 |
+
showTopBorder ? "border-b border-border" : ""
|
| 875 |
+
}`}
|
| 876 |
+
style={{
|
| 877 |
+
height: "4.5rem",
|
| 878 |
+
margin: 0,
|
| 879 |
+
padding: "1rem 1rem",
|
| 880 |
+
boxSizing: "border-box",
|
| 881 |
+
}}
|
| 882 |
+
>
|
| 883 |
+
{/* Course Selector - Left */}
|
| 884 |
+
<div className="flex-shrink-0">
|
| 885 |
+
{(() => {
|
| 886 |
+
const current = workspaces.find((w) => w.id === currentWorkspaceId);
|
| 887 |
+
if (current?.type === "group") {
|
| 888 |
+
if (current.category === "course" && current.courseName) {
|
| 889 |
+
return (
|
| 890 |
+
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 891 |
+
{current.courseName}
|
| 892 |
+
</div>
|
| 893 |
+
);
|
| 894 |
+
}
|
| 895 |
+
return null;
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
return (
|
| 899 |
+
<Select
|
| 900 |
+
value={currentCourseId || "course1"}
|
| 901 |
+
onValueChange={(val) => onCourseChange && onCourseChange(val)}
|
| 902 |
+
>
|
| 903 |
+
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 904 |
+
<SelectValue placeholder="Select course" />
|
| 905 |
+
</SelectTrigger>
|
| 906 |
+
<SelectContent>
|
| 907 |
+
{courses.map((course) => (
|
| 908 |
+
<SelectItem key={course.id} value={course.id}>
|
| 909 |
+
{course.name}
|
| 910 |
+
</SelectItem>
|
| 911 |
+
))}
|
| 912 |
+
</SelectContent>
|
| 913 |
+
</Select>
|
| 914 |
+
);
|
| 915 |
+
})()}
|
| 916 |
+
</div>
|
| 917 |
+
|
| 918 |
+
{/* Tabs - Center */}
|
| 919 |
+
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 920 |
+
<Tabs
|
| 921 |
+
value={chatMode}
|
| 922 |
+
onValueChange={(value) => onChatModeChange(value as ChatMode)}
|
| 923 |
+
className="w-auto"
|
| 924 |
+
orientation="horizontal"
|
| 925 |
+
>
|
| 926 |
+
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 927 |
+
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
| 928 |
+
Ask
|
| 929 |
+
</TabsTrigger>
|
| 930 |
+
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
|
| 931 |
+
Review
|
| 932 |
+
<span
|
| 933 |
+
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
|
| 934 |
+
style={{
|
| 935 |
+
width: 10,
|
| 936 |
+
height: 10,
|
| 937 |
+
transform: "translate(25%, -25%)",
|
| 938 |
+
zIndex: 10,
|
| 939 |
+
borderColor: "var(--muted)",
|
| 940 |
+
}}
|
| 941 |
+
/>
|
| 942 |
+
</TabsTrigger>
|
| 943 |
+
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
|
| 944 |
+
Quiz
|
| 945 |
+
</TabsTrigger>
|
| 946 |
+
</TabsList>
|
| 947 |
+
</Tabs>
|
| 948 |
+
</div>
|
| 949 |
+
|
| 950 |
+
{/* Action Buttons - Right */}
|
| 951 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 952 |
+
<Button
|
| 953 |
+
variant="ghost"
|
| 954 |
+
size="icon"
|
| 955 |
+
onClick={handleSaveClick}
|
| 956 |
+
disabled={!isLoggedIn}
|
| 957 |
+
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
|
| 958 |
+
title={isCurrentChatSaved() ? "Unsave" : "Save"}
|
| 959 |
+
>
|
| 960 |
+
<Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
|
| 961 |
+
</Button>
|
| 962 |
+
|
| 963 |
+
<Button
|
| 964 |
+
variant="ghost"
|
| 965 |
+
size="icon"
|
| 966 |
+
onClick={handleOpenDownloadDialog}
|
| 967 |
+
disabled={!isLoggedIn}
|
| 968 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 969 |
+
title="Download"
|
| 970 |
+
>
|
| 971 |
+
<Download className="h-4 w-4" />
|
| 972 |
+
</Button>
|
| 973 |
+
|
| 974 |
+
<Button
|
| 975 |
+
variant="ghost"
|
| 976 |
+
size="icon"
|
| 977 |
+
onClick={handleShareClick}
|
| 978 |
+
disabled={!isLoggedIn}
|
| 979 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 980 |
+
title="Share"
|
| 981 |
+
>
|
| 982 |
+
<Share2 className="h-4 w-4" />
|
| 983 |
+
</Button>
|
| 984 |
+
|
| 985 |
+
<Button
|
| 986 |
+
variant="outline"
|
| 987 |
+
onClick={handleClearClick}
|
| 988 |
+
disabled={!isLoggedIn}
|
| 989 |
+
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
|
| 990 |
+
title="New Chat"
|
| 991 |
+
>
|
| 992 |
+
<Plus className="h-4 w-4" />
|
| 993 |
+
<span className="text-sm font-medium">New chat</span>
|
| 994 |
+
</Button>
|
| 995 |
+
</div>
|
| 996 |
+
</div>
|
| 997 |
+
|
| 998 |
+
{/* Scroll Container */}
|
| 999 |
+
<div
|
| 1000 |
+
ref={scrollContainerRef}
|
| 1001 |
+
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
|
| 1002 |
+
style={{ overscrollBehavior: "contain" }}
|
| 1003 |
+
>
|
| 1004 |
+
<div className="py-6" style={{ paddingBottom: bottomPad }}>
|
| 1005 |
+
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 1006 |
+
{messages.map((message) => (
|
| 1007 |
+
<React.Fragment key={message.id}>
|
| 1008 |
+
<Message
|
| 1009 |
+
message={message}
|
| 1010 |
+
showSenderInfo={spaceType === "group"}
|
| 1011 |
+
isFirstGreeting={
|
| 1012 |
+
(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
|
| 1013 |
+
message.role === "assistant"
|
| 1014 |
+
}
|
| 1015 |
+
showNextButton={message.showNextButton && !isAppTyping}
|
| 1016 |
+
onNextQuestion={onNextQuestion}
|
| 1017 |
+
chatMode={chatMode}
|
| 1018 |
+
currentUserId={currentUserId}
|
| 1019 |
+
learningMode={learningMode}
|
| 1020 |
+
docType={docType}
|
| 1021 |
+
/>
|
| 1022 |
+
|
| 1023 |
+
{chatMode === "review" && message.id === "review-1" && message.role === "assistant" && (
|
| 1024 |
+
<div className="flex gap-2 justify-start px-4">
|
| 1025 |
+
<div className="w-10 h-10 flex-shrink-0" />
|
| 1026 |
+
<div className="w-full" style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}>
|
| 1027 |
+
<SmartReview onReviewTopic={handleReviewTopic} onReviewAll={handleReviewAll} />
|
| 1028 |
+
</div>
|
| 1029 |
+
</div>
|
| 1030 |
+
)}
|
| 1031 |
+
|
| 1032 |
+
{chatMode === "quiz" &&
|
| 1033 |
+
message.id === "quiz-1" &&
|
| 1034 |
+
message.role === "assistant" &&
|
| 1035 |
+
quizState.currentQuestion === 0 &&
|
| 1036 |
+
!quizState.waitingForAnswer &&
|
| 1037 |
+
!isAppTyping && (
|
| 1038 |
+
<div className="flex justify-center py-4">
|
| 1039 |
+
<Button onClick={onStartQuiz} className="bg-red-500 hover:bg-red-600 text-white">
|
| 1040 |
+
Start Quiz
|
| 1041 |
+
</Button>
|
| 1042 |
+
</div>
|
| 1043 |
+
)}
|
| 1044 |
+
</React.Fragment>
|
| 1045 |
+
))}
|
| 1046 |
+
|
| 1047 |
+
{isAppTyping && (
|
| 1048 |
+
<div className="flex gap-2 justify-start px-4">
|
| 1049 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 1050 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 1051 |
+
</div>
|
| 1052 |
+
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 1053 |
+
<div className="flex gap-1">
|
| 1054 |
+
<div
|
| 1055 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 1056 |
+
style={{ animationDelay: "0ms" }}
|
| 1057 |
+
/>
|
| 1058 |
+
<div
|
| 1059 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 1060 |
+
style={{ animationDelay: "150ms" }}
|
| 1061 |
+
/>
|
| 1062 |
+
<div
|
| 1063 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 1064 |
+
style={{ animationDelay: "300ms" }}
|
| 1065 |
+
/>
|
| 1066 |
+
</div>
|
| 1067 |
+
</div>
|
| 1068 |
+
</div>
|
| 1069 |
+
)}
|
| 1070 |
+
</div>
|
| 1071 |
+
</div>
|
| 1072 |
+
</div>
|
| 1073 |
+
|
| 1074 |
+
{/* Scroll-to-bottom button */}
|
| 1075 |
+
{showScrollButton && (
|
| 1076 |
+
<div
|
| 1077 |
+
className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none"
|
| 1078 |
+
style={{ bottom: composerHeight + 16 }}
|
| 1079 |
+
>
|
| 1080 |
+
<Button
|
| 1081 |
+
variant="secondary"
|
| 1082 |
+
size="icon"
|
| 1083 |
+
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10"
|
| 1084 |
+
onClick={() => scrollToBottom("smooth")}
|
| 1085 |
+
title="Scroll to bottom"
|
| 1086 |
+
>
|
| 1087 |
+
<ArrowDown className="h-5 w-5" />
|
| 1088 |
+
</Button>
|
| 1089 |
+
</div>
|
| 1090 |
+
)}
|
| 1091 |
+
|
| 1092 |
+
{/* Composer */}
|
| 1093 |
+
<div ref={composerRef} className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
|
| 1094 |
+
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 1095 |
+
{/* Uploaded Files Preview */}
|
| 1096 |
+
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
|
| 1097 |
+
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 1098 |
+
{/* uploaded */}
|
| 1099 |
+
{uploadedFiles.map((uf, i) => {
|
| 1100 |
+
const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`;
|
| 1101 |
+
|
| 1102 |
+
const nameLower = uf.file.name.toLowerCase();
|
| 1103 |
+
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) =>
|
| 1104 |
+
nameLower.endsWith(e)
|
| 1105 |
+
);
|
| 1106 |
+
|
| 1107 |
+
const isPdf = nameLower.endsWith(".pdf");
|
| 1108 |
+
const isPpt = nameLower.endsWith(".ppt") || nameLower.endsWith(".pptx");
|
| 1109 |
+
const fileIcon = isPdf ? pdfIcon : isPpt ? pptIcon : otherIcon;
|
| 1110 |
+
|
| 1111 |
+
const thumbUrl = isImage ? getOrCreate(uf.file) : null;
|
| 1112 |
+
|
| 1113 |
+
return (
|
| 1114 |
+
<div
|
| 1115 |
+
key={key}
|
| 1116 |
+
role="button"
|
| 1117 |
+
tabIndex={0}
|
| 1118 |
+
onClick={() => {
|
| 1119 |
+
setSelectedFile({ file: uf.file, index: i });
|
| 1120 |
+
setShowFileViewer(true);
|
| 1121 |
+
}}
|
| 1122 |
+
onKeyDown={(e) => {
|
| 1123 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 1124 |
+
e.preventDefault();
|
| 1125 |
+
setSelectedFile({ file: uf.file, index: i });
|
| 1126 |
+
setShowFileViewer(true);
|
| 1127 |
+
}
|
| 1128 |
+
}}
|
| 1129 |
+
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-muted/40"
|
| 1130 |
+
title="Click to preview"
|
| 1131 |
+
>
|
| 1132 |
+
<div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
|
| 1133 |
+
{isImage ? (
|
| 1134 |
+
thumbUrl ? (
|
| 1135 |
+
<img
|
| 1136 |
+
src={thumbUrl}
|
| 1137 |
+
alt={uf.file.name}
|
| 1138 |
+
className="h-full w-full object-cover"
|
| 1139 |
+
draggable={false}
|
| 1140 |
+
/>
|
| 1141 |
+
) : (
|
| 1142 |
+
<div className="h-full w-full flex items-center justify-center">
|
| 1143 |
+
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
| 1144 |
+
</div>
|
| 1145 |
+
)
|
| 1146 |
+
) : (
|
| 1147 |
+
<img
|
| 1148 |
+
src={fileIcon}
|
| 1149 |
+
alt={uf.file.name}
|
| 1150 |
+
className="h-full w-full object-contain p-1"
|
| 1151 |
+
draggable={false}
|
| 1152 |
+
/>
|
| 1153 |
+
)}
|
| 1154 |
+
</div>
|
| 1155 |
+
|
| 1156 |
+
<div className="min-w-0 flex-1">
|
| 1157 |
+
<div className="truncate text-sm font-medium">{uf.file.name}</div>
|
| 1158 |
+
<div className="text-xs text-muted-foreground">{uf.type}</div>
|
| 1159 |
+
</div>
|
| 1160 |
+
|
| 1161 |
+
<Button
|
| 1162 |
+
variant="ghost"
|
| 1163 |
+
size="icon"
|
| 1164 |
+
onClick={(e) => {
|
| 1165 |
+
e.preventDefault();
|
| 1166 |
+
e.stopPropagation();
|
| 1167 |
+
onRemoveFile(i);
|
| 1168 |
+
}}
|
| 1169 |
+
title="Remove"
|
| 1170 |
+
>
|
| 1171 |
+
<Trash2 className="h-4 w-4" />
|
| 1172 |
+
</Button>
|
| 1173 |
+
</div>
|
| 1174 |
+
);
|
| 1175 |
+
})}
|
| 1176 |
+
|
| 1177 |
+
{/* pending */}
|
| 1178 |
+
{pendingFiles.map((p, idx) => (
|
| 1179 |
+
<FileChip
|
| 1180 |
+
key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`}
|
| 1181 |
+
file={p.file}
|
| 1182 |
+
index={idx}
|
| 1183 |
+
source="pending"
|
| 1184 |
+
/>
|
| 1185 |
+
))}
|
| 1186 |
+
</div>
|
| 1187 |
+
)}
|
| 1188 |
+
|
| 1189 |
+
<form
|
| 1190 |
+
onSubmit={handleSubmit as any}
|
| 1191 |
+
onDragOver={handleDragOver}
|
| 1192 |
+
onDragLeave={handleDragLeave}
|
| 1193 |
+
onDrop={handleDrop}
|
| 1194 |
+
className={isDragging ? "opacity-75" : ""}
|
| 1195 |
+
>
|
| 1196 |
+
<div className="relative">
|
| 1197 |
+
{/* Mode Selector + Upload */}
|
| 1198 |
+
<div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
|
| 1199 |
+
{chatMode === "ask" && (
|
| 1200 |
+
<DropdownMenu>
|
| 1201 |
+
<DropdownMenuTrigger asChild>
|
| 1202 |
+
<Button
|
| 1203 |
+
variant="ghost"
|
| 1204 |
+
size="sm"
|
| 1205 |
+
className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
|
| 1206 |
+
disabled={!isLoggedIn}
|
| 1207 |
+
type="button"
|
| 1208 |
+
>
|
| 1209 |
+
<span>{modeLabels[learningMode]}</span>
|
| 1210 |
+
<svg
|
| 1211 |
+
className="h-3 w-3 opacity-50"
|
| 1212 |
+
fill="none"
|
| 1213 |
+
stroke="currentColor"
|
| 1214 |
+
viewBox="0 0 24 24"
|
| 1215 |
+
>
|
| 1216 |
+
<path
|
| 1217 |
+
strokeLinecap="round"
|
| 1218 |
+
strokeLinejoin="round"
|
| 1219 |
+
strokeWidth={2}
|
| 1220 |
+
d="M19 9l-7 7-7-7"
|
| 1221 |
+
/>
|
| 1222 |
+
</svg>
|
| 1223 |
+
</Button>
|
| 1224 |
+
</DropdownMenuTrigger>
|
| 1225 |
+
<DropdownMenuContent align="start" className="w-56">
|
| 1226 |
+
<DropdownMenuItem
|
| 1227 |
+
onClick={() => onLearningModeChange("general")}
|
| 1228 |
+
className={learningMode === "general" ? "bg-accent" : ""}
|
| 1229 |
+
>
|
| 1230 |
+
<div className="flex flex-col">
|
| 1231 |
+
<span className="font-medium">General</span>
|
| 1232 |
+
<span className="text-xs text-muted-foreground">
|
| 1233 |
+
Answer various questions (context required)
|
| 1234 |
+
</span>
|
| 1235 |
+
</div>
|
| 1236 |
+
</DropdownMenuItem>
|
| 1237 |
+
|
| 1238 |
+
<DropdownMenuItem
|
| 1239 |
+
onClick={() => onLearningModeChange("concept")}
|
| 1240 |
+
className={learningMode === "concept" ? "bg-accent" : ""}
|
| 1241 |
+
>
|
| 1242 |
+
<div className="flex flex-col">
|
| 1243 |
+
<span className="font-medium">Concept Explainer</span>
|
| 1244 |
+
<span className="text-xs text-muted-foreground">
|
| 1245 |
+
Get detailed explanations of concepts
|
| 1246 |
+
</span>
|
| 1247 |
+
</div>
|
| 1248 |
+
</DropdownMenuItem>
|
| 1249 |
+
|
| 1250 |
+
<DropdownMenuItem
|
| 1251 |
+
onClick={() => onLearningModeChange("socratic")}
|
| 1252 |
+
className={learningMode === "socratic" ? "bg-accent" : ""}
|
| 1253 |
+
>
|
| 1254 |
+
<div className="flex flex-col">
|
| 1255 |
+
<span className="font-medium">Socratic Tutor</span>
|
| 1256 |
+
<span className="text-xs text-muted-foreground">
|
| 1257 |
+
Learn through guided questions
|
| 1258 |
+
</span>
|
| 1259 |
+
</div>
|
| 1260 |
+
</DropdownMenuItem>
|
| 1261 |
+
|
| 1262 |
+
<DropdownMenuItem
|
| 1263 |
+
onClick={() => onLearningModeChange("exam")}
|
| 1264 |
+
className={learningMode === "exam" ? "bg-accent" : ""}
|
| 1265 |
+
>
|
| 1266 |
+
<div className="flex flex-col">
|
| 1267 |
+
<span className="font-medium">Exam Prep</span>
|
| 1268 |
+
<span className="text-xs text-muted-foreground">
|
| 1269 |
+
Practice with quiz questions
|
| 1270 |
+
</span>
|
| 1271 |
+
</div>
|
| 1272 |
+
</DropdownMenuItem>
|
| 1273 |
+
|
| 1274 |
+
<DropdownMenuItem
|
| 1275 |
+
onClick={() => onLearningModeChange("assignment")}
|
| 1276 |
+
className={learningMode === "assignment" ? "bg-accent" : ""}
|
| 1277 |
+
>
|
| 1278 |
+
<div className="flex flex-col">
|
| 1279 |
+
<span className="font-medium">Assignment Helper</span>
|
| 1280 |
+
<span className="text-xs text-muted-foreground">
|
| 1281 |
+
Get help with assignments
|
| 1282 |
+
</span>
|
| 1283 |
+
</div>
|
| 1284 |
+
</DropdownMenuItem>
|
| 1285 |
+
|
| 1286 |
+
<DropdownMenuItem
|
| 1287 |
+
onClick={() => onLearningModeChange("summary")}
|
| 1288 |
+
className={learningMode === "summary" ? "bg-accent" : ""}
|
| 1289 |
+
>
|
| 1290 |
+
<div className="flex flex-col">
|
| 1291 |
+
<span className="font-medium">Quick Summary</span>
|
| 1292 |
+
<span className="text-xs text-muted-foreground">
|
| 1293 |
+
Get concise summaries
|
| 1294 |
+
</span>
|
| 1295 |
+
</div>
|
| 1296 |
+
</DropdownMenuItem>
|
| 1297 |
+
</DropdownMenuContent>
|
| 1298 |
+
</DropdownMenu>
|
| 1299 |
+
)}
|
| 1300 |
+
|
| 1301 |
+
<Button
|
| 1302 |
+
type="button"
|
| 1303 |
+
size="icon"
|
| 1304 |
+
variant="ghost"
|
| 1305 |
+
disabled={
|
| 1306 |
+
!isLoggedIn ||
|
| 1307 |
+
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1308 |
+
}
|
| 1309 |
+
className="h-8 w-8 hover:bg-muted/50"
|
| 1310 |
+
onClick={() => fileInputRef.current?.click()}
|
| 1311 |
+
title="Upload files"
|
| 1312 |
+
>
|
| 1313 |
+
<Upload className="h-4 w-4" />
|
| 1314 |
+
</Button>
|
| 1315 |
+
</div>
|
| 1316 |
+
|
| 1317 |
+
<Textarea
|
| 1318 |
+
value={input}
|
| 1319 |
+
onChange={(e) => setInput(e.target.value)}
|
| 1320 |
+
onKeyDown={handleKeyDown}
|
| 1321 |
+
placeholder={
|
| 1322 |
+
!isLoggedIn
|
| 1323 |
+
? "Please log in on the right to start chatting..."
|
| 1324 |
+
: chatMode === "quiz"
|
| 1325 |
+
? quizState.waitingForAnswer
|
| 1326 |
+
? "Type your answer here..."
|
| 1327 |
+
: quizState.currentQuestion > 0
|
| 1328 |
+
? "Click 'Next Question' to continue..."
|
| 1329 |
+
: "Click 'Start Quiz' to begin..."
|
| 1330 |
+
: spaceType === "group"
|
| 1331 |
+
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
|
| 1332 |
+
: learningMode === "general"
|
| 1333 |
+
? "Ask me anything! Please provide context about your question..."
|
| 1334 |
+
: "Ask Clare anything about the course or drag files here..."
|
| 1335 |
+
}
|
| 1336 |
+
disabled={
|
| 1337 |
+
!isLoggedIn ||
|
| 1338 |
+
(chatMode === "quiz" && !quizState.waitingForAnswer)
|
| 1339 |
+
}
|
| 1340 |
+
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
| 1341 |
+
isDragging ? "border-primary border-dashed" : "border-border"
|
| 1342 |
+
}`}
|
| 1343 |
+
/>
|
| 1344 |
+
|
| 1345 |
+
<div className="absolute bottom-2 right-2 flex gap-1">
|
| 1346 |
+
<Button
|
| 1347 |
+
type="submit"
|
| 1348 |
+
size="icon"
|
| 1349 |
+
disabled={
|
| 1350 |
+
(!input.trim() && uploadedFiles.length === 0) ||
|
| 1351 |
+
!isLoggedIn
|
| 1352 |
+
}
|
| 1353 |
+
className="h-8 w-8 rounded-full"
|
| 1354 |
+
>
|
| 1355 |
+
<Send className="h-4 w-4" />
|
| 1356 |
+
</Button>
|
| 1357 |
+
</div>
|
| 1358 |
+
|
| 1359 |
+
<input
|
| 1360 |
+
ref={fileInputRef}
|
| 1361 |
+
type="file"
|
| 1362 |
+
multiple
|
| 1363 |
+
accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp"
|
| 1364 |
+
onChange={handleFileSelect}
|
| 1365 |
+
className="hidden"
|
| 1366 |
+
disabled={!isLoggedIn}
|
| 1367 |
+
/>
|
| 1368 |
+
</div>
|
| 1369 |
+
</form>
|
| 1370 |
+
</div>
|
| 1371 |
+
</div>
|
| 1372 |
+
|
| 1373 |
+
{/* File Viewer Dialog */}
|
| 1374 |
+
<Dialog
|
| 1375 |
+
open={showFileViewer}
|
| 1376 |
+
onOpenChange={(open) => {
|
| 1377 |
+
setShowFileViewer(open);
|
| 1378 |
+
if (!open) setSelectedFile(null);
|
| 1379 |
+
}}
|
| 1380 |
+
>
|
| 1381 |
+
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
| 1382 |
+
<DialogHeader className="min-w-0 flex-shrink-0">
|
| 1383 |
+
<DialogTitle
|
| 1384 |
+
className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
|
| 1385 |
+
style={{
|
| 1386 |
+
wordBreak: "break-all",
|
| 1387 |
+
overflowWrap: "anywhere",
|
| 1388 |
+
maxWidth: "100%",
|
| 1389 |
+
lineHeight: "1.6",
|
| 1390 |
+
}}
|
| 1391 |
+
>
|
| 1392 |
+
{selectedFile?.file.name}
|
| 1393 |
+
</DialogTitle>
|
| 1394 |
+
<DialogDescription>
|
| 1395 |
+
File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}
|
| 1396 |
+
</DialogDescription>
|
| 1397 |
+
</DialogHeader>
|
| 1398 |
+
|
| 1399 |
+
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
| 1400 |
+
{selectedFile && <FileViewerContent file={selectedFile.file} />}
|
| 1401 |
+
</div>
|
| 1402 |
+
</DialogContent>
|
| 1403 |
+
</Dialog>
|
| 1404 |
+
|
| 1405 |
+
{/* Start New Conversation Confirmation Dialog */}
|
| 1406 |
+
<AlertDialog open={showClearDialog} onOpenChange={onCancelClear}>
|
| 1407 |
+
<AlertDialogContent>
|
| 1408 |
+
<AlertDialogHeader>
|
| 1409 |
+
<AlertDialogTitle>Start New Conversation</AlertDialogTitle>
|
| 1410 |
+
<AlertDialogDescription>
|
| 1411 |
+
Would you like to save the current chat before starting a new conversation?
|
| 1412 |
+
</AlertDialogDescription>
|
| 1413 |
+
|
| 1414 |
+
<Button
|
| 1415 |
+
variant="ghost"
|
| 1416 |
+
size="icon"
|
| 1417 |
+
className="absolute right-4 top-4 h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
| 1418 |
+
onClick={onCancelClear}
|
| 1419 |
+
>
|
| 1420 |
+
<X className="h-4 w-4" />
|
| 1421 |
+
<span className="sr-only">Close</span>
|
| 1422 |
+
</Button>
|
| 1423 |
+
</AlertDialogHeader>
|
| 1424 |
+
|
| 1425 |
+
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end">
|
| 1426 |
+
<Button variant="outline" onClick={() => onConfirmClear(false)} className="sm:flex-1 sm:max-w-[200px]">
|
| 1427 |
+
Start New (Don't Save)
|
| 1428 |
+
</Button>
|
| 1429 |
+
<AlertDialogAction onClick={() => onConfirmClear(true)} className="sm:flex-1 sm:max-w-[200px]">
|
| 1430 |
+
Save & Start New
|
| 1431 |
+
</AlertDialogAction>
|
| 1432 |
+
</AlertDialogFooter>
|
| 1433 |
+
</AlertDialogContent>
|
| 1434 |
+
</AlertDialog>
|
| 1435 |
+
|
| 1436 |
+
{/* Download Preview Dialog */}
|
| 1437 |
+
<Dialog open={showDownloadDialog} onOpenChange={setShowDownloadDialog}>
|
| 1438 |
+
<DialogContent className="max-w-3xl">
|
| 1439 |
+
<DialogHeader>
|
| 1440 |
+
<DialogTitle>Download this chat</DialogTitle>
|
| 1441 |
+
<DialogDescription>Preview and copy before downloading.</DialogDescription>
|
| 1442 |
+
</DialogHeader>
|
| 1443 |
+
|
| 1444 |
+
<Tabs
|
| 1445 |
+
value={downloadTab}
|
| 1446 |
+
onValueChange={(value) => {
|
| 1447 |
+
const v = value as "chat" | "summary";
|
| 1448 |
+
setDownloadTab(v);
|
| 1449 |
+
setDownloadPreview(v === "chat" ? buildPreviewContent() : buildSummaryContent());
|
| 1450 |
+
if (v === "summary") setDownloadOptions({ chat: false, summary: true });
|
| 1451 |
+
else setDownloadOptions({ chat: true, summary: false });
|
| 1452 |
+
}}
|
| 1453 |
+
className="w-full"
|
| 1454 |
+
>
|
| 1455 |
+
<TabsList className="grid w-full grid-cols-2">
|
| 1456 |
+
<TabsTrigger value="chat">Download chat</TabsTrigger>
|
| 1457 |
+
<TabsTrigger value="summary">Summary of the chat</TabsTrigger>
|
| 1458 |
+
</TabsList>
|
| 1459 |
+
</Tabs>
|
| 1460 |
+
|
| 1461 |
+
<div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
|
| 1462 |
+
<div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
|
| 1463 |
+
<span className="text-sm font-medium">Preview</span>
|
| 1464 |
+
<Button
|
| 1465 |
+
variant="outline"
|
| 1466 |
+
size="sm"
|
| 1467 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 1468 |
+
onClick={handleCopyPreview}
|
| 1469 |
+
title="Copy preview"
|
| 1470 |
+
>
|
| 1471 |
+
<Copy className="h-3 w-3" />
|
| 1472 |
+
Copy
|
| 1473 |
+
</Button>
|
| 1474 |
+
</div>
|
| 1475 |
+
<div className="text-sm text-foreground overflow-y-auto flex-1 p-4">
|
| 1476 |
+
<div className="whitespace-pre-wrap">{downloadPreview}</div>
|
| 1477 |
+
</div>
|
| 1478 |
+
</div>
|
| 1479 |
+
|
| 1480 |
+
<div className="space-y-3">
|
| 1481 |
+
<div className="flex items-center space-x-2">
|
| 1482 |
+
<Checkbox
|
| 1483 |
+
id="download-chat"
|
| 1484 |
+
checked={downloadOptions.chat}
|
| 1485 |
+
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })}
|
| 1486 |
+
/>
|
| 1487 |
+
<label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
|
| 1488 |
+
Download chat
|
| 1489 |
+
</label>
|
| 1490 |
+
</div>
|
| 1491 |
+
<div className="flex items-center space-x-2">
|
| 1492 |
+
<Checkbox
|
| 1493 |
+
id="download-summary"
|
| 1494 |
+
checked={downloadOptions.summary}
|
| 1495 |
+
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })}
|
| 1496 |
+
/>
|
| 1497 |
+
<label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
|
| 1498 |
+
Download summary
|
| 1499 |
+
</label>
|
| 1500 |
+
</div>
|
| 1501 |
+
</div>
|
| 1502 |
+
|
| 1503 |
+
<DialogFooter>
|
| 1504 |
+
<Button variant="outline" onClick={() => setShowDownloadDialog(false)}>
|
| 1505 |
+
Cancel
|
| 1506 |
+
</Button>
|
| 1507 |
+
<Button onClick={handleDownloadFile}>Download</Button>
|
| 1508 |
+
</DialogFooter>
|
| 1509 |
+
</DialogContent>
|
| 1510 |
+
</Dialog>
|
| 1511 |
+
|
| 1512 |
+
{/* Share Dialog */}
|
| 1513 |
+
<Dialog open={showShareDialog} onOpenChange={setShowShareDialog}>
|
| 1514 |
+
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]">
|
| 1515 |
+
<DialogHeader>
|
| 1516 |
+
<DialogTitle>Share Conversation</DialogTitle>
|
| 1517 |
+
<DialogDescription>Select how you want to share.</DialogDescription>
|
| 1518 |
+
</DialogHeader>
|
| 1519 |
+
<div className="space-y-4">
|
| 1520 |
+
<div className="space-y-2">
|
| 1521 |
+
<Label>Copy Link</Label>
|
| 1522 |
+
<div className="flex gap-2 items-center">
|
| 1523 |
+
<Input value={shareLink} readOnly className="flex-1" />
|
| 1524 |
+
<Button variant="secondary" onClick={handleCopyShareLink}>
|
| 1525 |
+
Copy
|
| 1526 |
+
</Button>
|
| 1527 |
+
</div>
|
| 1528 |
+
<p className="text-xs text-muted-foreground">Temporary link valid for this session.</p>
|
| 1529 |
+
</div>
|
| 1530 |
+
|
| 1531 |
+
<div className="space-y-2">
|
| 1532 |
+
<Label>Send to Workspace</Label>
|
| 1533 |
+
<Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}>
|
| 1534 |
+
<SelectTrigger className="w-full">
|
| 1535 |
+
<SelectValue placeholder="Choose a workspace" />
|
| 1536 |
+
</SelectTrigger>
|
| 1537 |
+
<SelectContent>
|
| 1538 |
+
{workspaces.map((w) => (
|
| 1539 |
+
<SelectItem key={w.id} value={w.id}>
|
| 1540 |
+
{w.name}
|
| 1541 |
+
</SelectItem>
|
| 1542 |
+
))}
|
| 1543 |
+
</SelectContent>
|
| 1544 |
+
</Select>
|
| 1545 |
+
<p className="text-xs text-muted-foreground">
|
| 1546 |
+
Sends this conversation to the selected workspace's Saved Files.
|
| 1547 |
+
</p>
|
| 1548 |
+
<Button onClick={handleShareSendToWorkspace} className="w-full">
|
| 1549 |
+
Send
|
| 1550 |
+
</Button>
|
| 1551 |
+
</div>
|
| 1552 |
+
</div>
|
| 1553 |
+
</DialogContent>
|
| 1554 |
+
</Dialog>
|
| 1555 |
+
|
| 1556 |
+
{/* Delete File Confirmation Dialog */}
|
| 1557 |
+
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
| 1558 |
+
<AlertDialogContent>
|
| 1559 |
+
<AlertDialogHeader>
|
| 1560 |
+
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1561 |
+
<AlertDialogDescription>
|
| 1562 |
+
Are you sure you want to delete "
|
| 1563 |
+
{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}
|
| 1564 |
+
"? This action cannot be undone.
|
| 1565 |
+
</AlertDialogDescription>
|
| 1566 |
+
</AlertDialogHeader>
|
| 1567 |
+
<AlertDialogFooter>
|
| 1568 |
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
| 1569 |
+
<AlertDialogAction
|
| 1570 |
+
onClick={() => {
|
| 1571 |
+
if (fileToDelete !== null) {
|
| 1572 |
+
onRemoveFile(fileToDelete);
|
| 1573 |
+
setFileToDelete(null);
|
| 1574 |
+
}
|
| 1575 |
+
setShowDeleteDialog(false);
|
| 1576 |
+
}}
|
| 1577 |
+
>
|
| 1578 |
+
Delete
|
| 1579 |
+
</AlertDialogAction>
|
| 1580 |
+
</AlertDialogFooter>
|
| 1581 |
+
</AlertDialogContent>
|
| 1582 |
+
</AlertDialog>
|
| 1583 |
+
|
| 1584 |
+
{/* File Type Selection Dialog */}
|
| 1585 |
+
{showTypeDialog && (
|
| 1586 |
+
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
|
| 1587 |
+
<DialogContent className="sm:max-w-[425px]" style={{ zIndex: 99999 }}>
|
| 1588 |
+
<DialogHeader>
|
| 1589 |
+
<DialogTitle>Select File Types</DialogTitle>
|
| 1590 |
+
<DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
|
| 1591 |
+
</DialogHeader>
|
| 1592 |
+
|
| 1593 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 1594 |
+
{pendingFiles.map((pendingFile, index) => {
|
| 1595 |
+
const Icon = getFileIcon(pendingFile.file.name);
|
| 1596 |
+
return (
|
| 1597 |
+
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
|
| 1598 |
+
<div className="flex items-center gap-2 group">
|
| 1599 |
+
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 1600 |
+
<div className="flex-1 min-w-0">
|
| 1601 |
+
<p className="text-sm truncate">{pendingFile.file.name}</p>
|
| 1602 |
+
<p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p>
|
| 1603 |
+
</div>
|
| 1604 |
+
</div>
|
| 1605 |
+
|
| 1606 |
+
<div className="space-y-1">
|
| 1607 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 1608 |
+
<Select
|
| 1609 |
+
value={pendingFile.type}
|
| 1610 |
+
onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
|
| 1611 |
+
>
|
| 1612 |
+
<SelectTrigger className="h-8 text-xs">
|
| 1613 |
+
<SelectValue />
|
| 1614 |
+
</SelectTrigger>
|
| 1615 |
+
<SelectContent className="!z-[100000] !bg-background !text-foreground" style={{ zIndex: 100000 }}>
|
| 1616 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 1617 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 1618 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 1619 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 1620 |
+
</SelectContent>
|
| 1621 |
+
</Select>
|
| 1622 |
+
</div>
|
| 1623 |
+
</div>
|
| 1624 |
+
);
|
| 1625 |
+
})}
|
| 1626 |
+
</div>
|
| 1627 |
+
|
| 1628 |
+
<DialogFooter>
|
| 1629 |
+
<Button variant="outline" onClick={handleCancelUpload}>
|
| 1630 |
+
Cancel
|
| 1631 |
+
</Button>
|
| 1632 |
+
<Button onClick={handleConfirmUpload}>Upload</Button>
|
| 1633 |
+
</DialogFooter>
|
| 1634 |
+
</DialogContent>
|
| 1635 |
+
</Dialog>
|
| 1636 |
+
)}
|
| 1637 |
+
</div>
|
| 1638 |
+
);
|
| 1639 |
+
}
|
web/src/components/CourseInfoHeader.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo } from "react";
|
| 2 |
+
|
| 3 |
+
type Person = { name: string; email?: string };
|
| 4 |
+
|
| 5 |
+
export type CourseInfo = {
|
| 6 |
+
id: string;
|
| 7 |
+
name: string;
|
| 8 |
+
instructor?: Person;
|
| 9 |
+
teachingAssistant?: Person;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function gmailCompose(email: string, subject: string, body?: string) {
|
| 13 |
+
const s = encodeURIComponent(subject);
|
| 14 |
+
const b = body ? `&body=${encodeURIComponent(body)}` : "";
|
| 15 |
+
return `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(email)}&su=${s}${b}`;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function CourseInfoHeader({
|
| 19 |
+
course,
|
| 20 |
+
className,
|
| 21 |
+
}: {
|
| 22 |
+
course: CourseInfo;
|
| 23 |
+
className?: string;
|
| 24 |
+
}) {
|
| 25 |
+
const instructorLink = useMemo(() => {
|
| 26 |
+
const p = course.instructor;
|
| 27 |
+
if (!p?.email) return "";
|
| 28 |
+
return gmailCompose(
|
| 29 |
+
p.email,
|
| 30 |
+
`[Clare] Question about ${course.name}`,
|
| 31 |
+
`Hi ${p.name},\n\nI have a question about ${course.name}:\n\n(Write your question here)\n\nThanks,\n`
|
| 32 |
+
);
|
| 33 |
+
}, [course]);
|
| 34 |
+
|
| 35 |
+
const taLink = useMemo(() => {
|
| 36 |
+
const p = course.teachingAssistant;
|
| 37 |
+
if (!p?.email) return "";
|
| 38 |
+
return gmailCompose(
|
| 39 |
+
p.email,
|
| 40 |
+
`[Clare] Help request for ${course.name}`,
|
| 41 |
+
`Hi ${p.name},\n\nI need help with ${course.name}:\n\n(Write your question here)\n\nThanks,\n`
|
| 42 |
+
);
|
| 43 |
+
}, [course]);
|
| 44 |
+
|
| 45 |
+
const instructorName = course.instructor?.name ?? "N/A";
|
| 46 |
+
const taName = course.teachingAssistant?.name ?? "N/A";
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className={className ?? "px-4 pt-4 pb-3"}>
|
| 50 |
+
<div className="space-y-1">
|
| 51 |
+
<div className="text-base font-semibold text-foreground truncate">
|
| 52 |
+
{course.name}
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="text-sm text-muted-foreground">
|
| 56 |
+
Instructor:{" "}
|
| 57 |
+
{instructorLink ? (
|
| 58 |
+
<a
|
| 59 |
+
href={instructorLink}
|
| 60 |
+
target="_blank"
|
| 61 |
+
rel="noopener noreferrer"
|
| 62 |
+
className="text-primary hover:underline"
|
| 63 |
+
title={`Message ${instructorName} in Gmail`}
|
| 64 |
+
onClick={(e) => e.stopPropagation()}
|
| 65 |
+
>
|
| 66 |
+
{instructorName}
|
| 67 |
+
</a>
|
| 68 |
+
) : (
|
| 69 |
+
<span className="text-muted-foreground/60">{instructorName}</span>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="text-sm text-muted-foreground">
|
| 74 |
+
TA:{" "}
|
| 75 |
+
{taLink ? (
|
| 76 |
+
<a
|
| 77 |
+
href={taLink}
|
| 78 |
+
target="_blank"
|
| 79 |
+
rel="noopener noreferrer"
|
| 80 |
+
className="text-primary hover:underline"
|
| 81 |
+
title={`Message ${taName} in Gmail`}
|
| 82 |
+
onClick={(e) => e.stopPropagation()}
|
| 83 |
+
>
|
| 84 |
+
{taName}
|
| 85 |
+
</a>
|
| 86 |
+
) : (
|
| 87 |
+
<span className="text-muted-foreground/60">{taName}</span>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
}
|
web/src/components/FileUploadArea.tsx
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { Button } from "./ui/button";
|
| 3 |
+
import {
|
| 4 |
+
Upload,
|
| 5 |
+
File as FileIcon,
|
| 6 |
+
X,
|
| 7 |
+
FileText,
|
| 8 |
+
Presentation,
|
| 9 |
+
Image as ImageIcon,
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
import { Card } from "./ui/card";
|
| 12 |
+
import { Badge } from "./ui/badge";
|
| 13 |
+
import {
|
| 14 |
+
Select,
|
| 15 |
+
SelectContent,
|
| 16 |
+
SelectItem,
|
| 17 |
+
SelectTrigger,
|
| 18 |
+
SelectValue,
|
| 19 |
+
} from "./ui/select";
|
| 20 |
+
import {
|
| 21 |
+
Dialog,
|
| 22 |
+
DialogContent,
|
| 23 |
+
DialogDescription,
|
| 24 |
+
DialogFooter,
|
| 25 |
+
DialogHeader,
|
| 26 |
+
DialogTitle,
|
| 27 |
+
} from "./ui/dialog";
|
| 28 |
+
import type { UploadedFile, FileType } from "../App";
|
| 29 |
+
|
| 30 |
+
interface FileUploadAreaProps {
|
| 31 |
+
uploadedFiles: UploadedFile[];
|
| 32 |
+
onFileUpload: (files: File[]) => void;
|
| 33 |
+
onRemoveFile: (index: number) => void;
|
| 34 |
+
onFileTypeChange: (index: number, type: FileType) => void;
|
| 35 |
+
disabled?: boolean;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface PendingFile {
|
| 39 |
+
file: File;
|
| 40 |
+
type: FileType;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
|
| 44 |
+
const ACCEPT_ATTR = ".pdf,.docx,.pptx,.png,.jpg,.jpeg,.webp,.gif";
|
| 45 |
+
|
| 46 |
+
function isImageFile(file: File) {
|
| 47 |
+
if (file.type?.startsWith("image/")) return true;
|
| 48 |
+
const n = file.name.toLowerCase();
|
| 49 |
+
return [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => n.endsWith(ext));
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function getFileIcon(filename: string) {
|
| 53 |
+
const lower = filename.toLowerCase();
|
| 54 |
+
if (lower.endsWith(".pdf")) return FileText;
|
| 55 |
+
if (lower.endsWith(".pptx")) return Presentation;
|
| 56 |
+
if (lower.endsWith(".docx")) return FileIcon;
|
| 57 |
+
if ([".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => lower.endsWith(ext)))
|
| 58 |
+
return ImageIcon;
|
| 59 |
+
return FileIcon;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function formatFileSize(bytes: number) {
|
| 63 |
+
if (bytes < 1024) return bytes + " B";
|
| 64 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
| 65 |
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function FileUploadArea({
|
| 69 |
+
uploadedFiles,
|
| 70 |
+
onFileUpload,
|
| 71 |
+
onRemoveFile,
|
| 72 |
+
onFileTypeChange,
|
| 73 |
+
disabled = false,
|
| 74 |
+
}: FileUploadAreaProps) {
|
| 75 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 76 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 77 |
+
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 78 |
+
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
| 79 |
+
|
| 80 |
+
// ===== objectURL cache(更稳:不用 state,避免时序问题)=====
|
| 81 |
+
const urlCacheRef = useRef<Map<string, string>>(new Map());
|
| 82 |
+
|
| 83 |
+
const fingerprint = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
|
| 84 |
+
|
| 85 |
+
const allFiles = useMemo(() => {
|
| 86 |
+
return [
|
| 87 |
+
...uploadedFiles.map((u) => u.file),
|
| 88 |
+
...pendingFiles.map((p) => p.file),
|
| 89 |
+
];
|
| 90 |
+
}, [uploadedFiles, pendingFiles]);
|
| 91 |
+
|
| 92 |
+
// 维护 cache:只保留当前需要的 image url;移除的立即 revoke
|
| 93 |
+
useEffect(() => {
|
| 94 |
+
const need = new Set<string>();
|
| 95 |
+
for (const f of allFiles) {
|
| 96 |
+
if (!isImageFile(f)) continue;
|
| 97 |
+
need.add(fingerprint(f));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// revoke removed
|
| 101 |
+
for (const [key, url] of urlCacheRef.current.entries()) {
|
| 102 |
+
if (!need.has(key)) {
|
| 103 |
+
try {
|
| 104 |
+
URL.revokeObjectURL(url);
|
| 105 |
+
} catch {
|
| 106 |
+
// ignore
|
| 107 |
+
}
|
| 108 |
+
urlCacheRef.current.delete(key);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// create missing
|
| 113 |
+
for (const f of allFiles) {
|
| 114 |
+
if (!isImageFile(f)) continue;
|
| 115 |
+
const key = fingerprint(f);
|
| 116 |
+
if (!urlCacheRef.current.has(key)) {
|
| 117 |
+
urlCacheRef.current.set(key, URL.createObjectURL(f));
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
}, [allFiles]);
|
| 121 |
+
|
| 122 |
+
// unmount:全部 revoke
|
| 123 |
+
useEffect(() => {
|
| 124 |
+
return () => {
|
| 125 |
+
for (const url of urlCacheRef.current.values()) {
|
| 126 |
+
try {
|
| 127 |
+
URL.revokeObjectURL(url);
|
| 128 |
+
} catch {
|
| 129 |
+
// ignore
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
urlCacheRef.current.clear();
|
| 133 |
+
};
|
| 134 |
+
}, []);
|
| 135 |
+
|
| 136 |
+
const getPreviewUrl = (file: File) => {
|
| 137 |
+
const key = fingerprint(file);
|
| 138 |
+
return urlCacheRef.current.get(key);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const filterSupportedFiles = (files: File[]) => {
|
| 142 |
+
return files.filter((file) => {
|
| 143 |
+
if (isImageFile(file)) return true;
|
| 144 |
+
const lower = file.name.toLowerCase();
|
| 145 |
+
return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext));
|
| 146 |
+
});
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 150 |
+
e.preventDefault();
|
| 151 |
+
if (!disabled) setIsDragging(true);
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const handleDragLeave = () => setIsDragging(false);
|
| 155 |
+
|
| 156 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 157 |
+
e.preventDefault();
|
| 158 |
+
setIsDragging(false);
|
| 159 |
+
if (disabled) return;
|
| 160 |
+
|
| 161 |
+
const files = filterSupportedFiles(Array.from(e.dataTransfer.files));
|
| 162 |
+
if (files.length > 0) {
|
| 163 |
+
setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
|
| 164 |
+
setShowTypeDialog(true);
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 169 |
+
const files = filterSupportedFiles(Array.from(e.target.files || []));
|
| 170 |
+
if (files.length > 0) {
|
| 171 |
+
setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
|
| 172 |
+
setShowTypeDialog(true);
|
| 173 |
+
}
|
| 174 |
+
e.target.value = "";
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
const handleConfirmUpload = () => {
|
| 178 |
+
onFileUpload(pendingFiles.map((pf) => pf.file));
|
| 179 |
+
|
| 180 |
+
const startIndex = uploadedFiles.length;
|
| 181 |
+
pendingFiles.forEach((pf, idx) => {
|
| 182 |
+
setTimeout(() => {
|
| 183 |
+
onFileTypeChange(startIndex + idx, pf.type);
|
| 184 |
+
}, 0);
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
setPendingFiles([]);
|
| 188 |
+
setShowTypeDialog(false);
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const handleCancelUpload = () => {
|
| 192 |
+
setPendingFiles([]);
|
| 193 |
+
setShowTypeDialog(false);
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 197 |
+
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const renderLeading = (file: File) => {
|
| 201 |
+
if (isImageFile(file)) {
|
| 202 |
+
const src = getPreviewUrl(file);
|
| 203 |
+
return (
|
| 204 |
+
<div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0">
|
| 205 |
+
{src ? (
|
| 206 |
+
<img
|
| 207 |
+
src={src}
|
| 208 |
+
alt={file.name}
|
| 209 |
+
className="h-full w-full object-cover"
|
| 210 |
+
draggable={false}
|
| 211 |
+
/>
|
| 212 |
+
) : (
|
| 213 |
+
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
| 214 |
+
<ImageIcon className="h-5 w-5" />
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
</div>
|
| 218 |
+
);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const Icon = getFileIcon(file.name);
|
| 222 |
+
return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />;
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
return (
|
| 226 |
+
<Card className="p-4 space-y-3">
|
| 227 |
+
<div className="flex items-center justify-between">
|
| 228 |
+
<h4 className="text-sm">Course Materials</h4>
|
| 229 |
+
{uploadedFiles.length > 0 && (
|
| 230 |
+
<Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{/* Upload Area */}
|
| 235 |
+
<div
|
| 236 |
+
onDragOver={handleDragOver}
|
| 237 |
+
onDragLeave={handleDragLeave}
|
| 238 |
+
onDrop={handleDrop}
|
| 239 |
+
className={[
|
| 240 |
+
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
|
| 241 |
+
isDragging ? "border-primary bg-accent" : "border-border",
|
| 242 |
+
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
| 243 |
+
].join(" ")}
|
| 244 |
+
onClick={() => !disabled && fileInputRef.current?.click()}
|
| 245 |
+
>
|
| 246 |
+
<Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
| 247 |
+
<p className="text-sm text-muted-foreground mb-1">
|
| 248 |
+
{disabled ? "Please log in to upload" : "Drop files or click to upload"}
|
| 249 |
+
</p>
|
| 250 |
+
<p className="text-xs text-muted-foreground">{ACCEPT_EXTS.join(", ")}</p>
|
| 251 |
+
|
| 252 |
+
<input
|
| 253 |
+
ref={fileInputRef}
|
| 254 |
+
type="file"
|
| 255 |
+
multiple
|
| 256 |
+
accept={ACCEPT_ATTR}
|
| 257 |
+
onChange={handleFileSelect}
|
| 258 |
+
className="hidden"
|
| 259 |
+
disabled={disabled}
|
| 260 |
+
/>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{/* Uploaded Files List */}
|
| 264 |
+
{uploadedFiles.length > 0 && (
|
| 265 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 266 |
+
{uploadedFiles.map((uploadedFile, index) => {
|
| 267 |
+
const f = uploadedFile.file;
|
| 268 |
+
return (
|
| 269 |
+
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
|
| 270 |
+
<div className="flex items-center gap-3 group">
|
| 271 |
+
{renderLeading(f)}
|
| 272 |
+
|
| 273 |
+
<div className="flex-1 min-w-0">
|
| 274 |
+
<p className="text-sm truncate">{f.name}</p>
|
| 275 |
+
<p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<Button
|
| 279 |
+
variant="ghost"
|
| 280 |
+
size="icon"
|
| 281 |
+
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
| 282 |
+
onClick={(e) => {
|
| 283 |
+
e.stopPropagation();
|
| 284 |
+
onRemoveFile(index);
|
| 285 |
+
}}
|
| 286 |
+
title="Remove"
|
| 287 |
+
>
|
| 288 |
+
<X className="h-3 w-3" />
|
| 289 |
+
</Button>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<div className="space-y-1">
|
| 293 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 294 |
+
<Select
|
| 295 |
+
value={uploadedFile.type}
|
| 296 |
+
onValueChange={(value) => onFileTypeChange(index, value as FileType)}
|
| 297 |
+
>
|
| 298 |
+
<SelectTrigger className="h-8 text-xs">
|
| 299 |
+
<SelectValue />
|
| 300 |
+
</SelectTrigger>
|
| 301 |
+
<SelectContent>
|
| 302 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 303 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 304 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 305 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 306 |
+
</SelectContent>
|
| 307 |
+
</Select>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
);
|
| 311 |
+
})}
|
| 312 |
+
</div>
|
| 313 |
+
)}
|
| 314 |
+
|
| 315 |
+
{/* Type Selection Dialog */}
|
| 316 |
+
{showTypeDialog && (
|
| 317 |
+
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
|
| 318 |
+
<DialogContent className="sm:max-w-[425px]">
|
| 319 |
+
<DialogHeader>
|
| 320 |
+
<DialogTitle>Select File Types</DialogTitle>
|
| 321 |
+
<DialogDescription>
|
| 322 |
+
Please select the type for each file you are uploading.
|
| 323 |
+
</DialogDescription>
|
| 324 |
+
</DialogHeader>
|
| 325 |
+
|
| 326 |
+
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 327 |
+
{pendingFiles.map((pendingFile, index) => {
|
| 328 |
+
const f = pendingFile.file;
|
| 329 |
+
return (
|
| 330 |
+
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
|
| 331 |
+
<div className="flex items-center gap-3">
|
| 332 |
+
{renderLeading(f)}
|
| 333 |
+
<div className="flex-1 min-w-0">
|
| 334 |
+
<p className="text-sm truncate">{f.name}</p>
|
| 335 |
+
<p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<div className="space-y-1">
|
| 340 |
+
<label className="text-xs text-muted-foreground">File Type</label>
|
| 341 |
+
<Select
|
| 342 |
+
value={pendingFile.type}
|
| 343 |
+
onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
|
| 344 |
+
>
|
| 345 |
+
<SelectTrigger className="h-8 text-xs">
|
| 346 |
+
<SelectValue />
|
| 347 |
+
</SelectTrigger>
|
| 348 |
+
<SelectContent>
|
| 349 |
+
<SelectItem value="syllabus">Syllabus</SelectItem>
|
| 350 |
+
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
|
| 351 |
+
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
|
| 352 |
+
<SelectItem value="other">Other Course Document</SelectItem>
|
| 353 |
+
</SelectContent>
|
| 354 |
+
</Select>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
);
|
| 358 |
+
})}
|
| 359 |
+
</div>
|
| 360 |
+
|
| 361 |
+
<DialogFooter>
|
| 362 |
+
<Button variant="outline" onClick={handleCancelUpload}>
|
| 363 |
+
Cancel
|
| 364 |
+
</Button>
|
| 365 |
+
<Button onClick={handleConfirmUpload}>Upload</Button>
|
| 366 |
+
</DialogFooter>
|
| 367 |
+
</DialogContent>
|
| 368 |
+
</Dialog>
|
| 369 |
+
)}
|
| 370 |
+
</Card>
|
| 371 |
+
);
|
| 372 |
+
}
|
web/src/components/FloatingActionButtons.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Button } from './ui/button';
|
| 3 |
+
import { Download, Sparkles } from 'lucide-react';
|
| 4 |
+
import { toast } from 'sonner';
|
| 5 |
+
import type { User } from '../App';
|
| 6 |
+
|
| 7 |
+
interface FloatingActionButtonsProps {
|
| 8 |
+
user: User | null;
|
| 9 |
+
isLoggedIn: boolean;
|
| 10 |
+
onOpenPanel: () => void;
|
| 11 |
+
onExport: () => void;
|
| 12 |
+
onSummary: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function FloatingActionButtons({
|
| 16 |
+
user,
|
| 17 |
+
isLoggedIn,
|
| 18 |
+
onOpenPanel,
|
| 19 |
+
onExport,
|
| 20 |
+
onSummary,
|
| 21 |
+
}: FloatingActionButtonsProps) {
|
| 22 |
+
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
| 23 |
+
|
| 24 |
+
const handleAction = (action: () => void, actionName: string, shouldOpenPanel: boolean = false) => {
|
| 25 |
+
if (!isLoggedIn) {
|
| 26 |
+
toast.error('Please log in to use this feature');
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
action();
|
| 30 |
+
if (shouldOpenPanel) {
|
| 31 |
+
onOpenPanel();
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const buttons = [
|
| 36 |
+
{
|
| 37 |
+
id: 'export',
|
| 38 |
+
icon: Download,
|
| 39 |
+
label: 'Export Conversation',
|
| 40 |
+
action: onExport,
|
| 41 |
+
openPanel: true, // Open panel for export
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
id: 'summary',
|
| 45 |
+
icon: Sparkles,
|
| 46 |
+
label: 'Summarization',
|
| 47 |
+
action: onSummary,
|
| 48 |
+
openPanel: true, // Open panel for summary
|
| 49 |
+
},
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="fixed right-4 bottom-[28rem] z-40 flex flex-col gap-2">
|
| 54 |
+
{buttons.map((button, index) => {
|
| 55 |
+
const Icon = button.icon;
|
| 56 |
+
const isHovered = hoveredButton === button.id;
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div
|
| 60 |
+
key={button.id}
|
| 61 |
+
className="relative group"
|
| 62 |
+
onMouseEnter={() => setHoveredButton(button.id)}
|
| 63 |
+
onMouseLeave={() => setHoveredButton(null)}
|
| 64 |
+
>
|
| 65 |
+
{/* Tooltip */}
|
| 66 |
+
<div
|
| 67 |
+
className={`
|
| 68 |
+
absolute right-full mr-3 top-1/2 -translate-y-1/2
|
| 69 |
+
px-3 py-2 rounded-lg bg-popover border border-border
|
| 70 |
+
whitespace-nowrap text-sm shadow-lg
|
| 71 |
+
transition-all duration-200
|
| 72 |
+
${isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-2 pointer-events-none'}
|
| 73 |
+
`}
|
| 74 |
+
>
|
| 75 |
+
{button.label}
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Floating Button */}
|
| 79 |
+
<Button
|
| 80 |
+
size="icon"
|
| 81 |
+
className={`
|
| 82 |
+
h-6 w-6 rounded-full shadow-md opacity-60 hover:opacity-100
|
| 83 |
+
transition-all duration-200
|
| 84 |
+
${isLoggedIn
|
| 85 |
+
? 'bg-primary hover:bg-primary/90 text-primary-foreground'
|
| 86 |
+
: 'bg-muted hover:bg-muted/90 text-muted-foreground'
|
| 87 |
+
}
|
| 88 |
+
${isHovered ? 'scale-110' : 'scale-100'}
|
| 89 |
+
`}
|
| 90 |
+
onClick={() => handleAction(button.action, button.label, button.openPanel)}
|
| 91 |
+
style={{
|
| 92 |
+
animationDelay: `${index * 100}ms`,
|
| 93 |
+
}}
|
| 94 |
+
>
|
| 95 |
+
<Icon className="h-3 w-3" />
|
| 96 |
+
</Button>
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
})}
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
web/src/components/GroupMembers.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Users, MailPlus } from 'lucide-react';
|
| 3 |
+
import { Badge } from './ui/badge';
|
| 4 |
+
import { Button } from './ui/button';
|
| 5 |
+
import { Input } from './ui/input';
|
| 6 |
+
import { toast } from 'sonner';
|
| 7 |
+
import {
|
| 8 |
+
Dialog,
|
| 9 |
+
DialogContent,
|
| 10 |
+
DialogDescription,
|
| 11 |
+
DialogFooter,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
} from './ui/dialog';
|
| 15 |
+
import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
|
| 16 |
+
import type { GroupMember } from '../App';
|
| 17 |
+
|
| 18 |
+
interface GroupMembersProps {
|
| 19 |
+
members: GroupMember[];
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function GroupMembers({ members }: GroupMembersProps) {
|
| 23 |
+
const [inviteOpen, setInviteOpen] = useState(false);
|
| 24 |
+
const [inviteEmail, setInviteEmail] = useState('');
|
| 25 |
+
|
| 26 |
+
const handleSendInvite = () => {
|
| 27 |
+
if (!inviteEmail.trim()) {
|
| 28 |
+
toast.error('Please enter an email to invite');
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
toast.success(`Invitation sent to ${inviteEmail}`);
|
| 32 |
+
setInviteEmail('');
|
| 33 |
+
setInviteOpen(false);
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="space-y-3">
|
| 38 |
+
<div className="flex items-center justify-between gap-2">
|
| 39 |
+
<div className="flex items-center gap-2">
|
| 40 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 41 |
+
<h3 className="text-sm">Group Members ({members.length})</h3>
|
| 42 |
+
</div>
|
| 43 |
+
<Button
|
| 44 |
+
size="sm"
|
| 45 |
+
variant="secondary"
|
| 46 |
+
className="h-8 gap-2"
|
| 47 |
+
onClick={() => setInviteOpen(true)}
|
| 48 |
+
>
|
| 49 |
+
<MailPlus className="h-4 w-4" />
|
| 50 |
+
<span className="text-xs font-medium">Invite</span>
|
| 51 |
+
</Button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div className="space-y-2">
|
| 55 |
+
{members.map((member) => {
|
| 56 |
+
const isAI = !!member.isAI;
|
| 57 |
+
return (
|
| 58 |
+
<div
|
| 59 |
+
key={member.id}
|
| 60 |
+
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
| 61 |
+
>
|
| 62 |
+
{/* Avatar */}
|
| 63 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
| 64 |
+
isAI
|
| 65 |
+
? 'overflow-hidden bg-white'
|
| 66 |
+
: 'bg-muted'
|
| 67 |
+
}`}>
|
| 68 |
+
{isAI ? (
|
| 69 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 70 |
+
) : (
|
| 71 |
+
<span className="text-sm">
|
| 72 |
+
{member.name.split(' ').map(n => n[0]).join('').toUpperCase()}
|
| 73 |
+
</span>
|
| 74 |
+
)}
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Member Info */}
|
| 78 |
+
<div className="flex-1 min-w-0">
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<p className="text-sm truncate">{member.name}</p>
|
| 81 |
+
{isAI && (
|
| 82 |
+
<Badge variant="secondary" className="text-xs">AI</Badge>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Online Status */}
|
| 89 |
+
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" title="Online" />
|
| 90 |
+
</div>
|
| 91 |
+
)})}
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
| 95 |
+
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]" style={{ maxWidth: 600 }}>
|
| 96 |
+
<DialogHeader>
|
| 97 |
+
<DialogTitle>Invite member</DialogTitle>
|
| 98 |
+
<DialogDescription>Send a quick email invite with the team details.</DialogDescription>
|
| 99 |
+
</DialogHeader>
|
| 100 |
+
<div className="space-y-3">
|
| 101 |
+
<Input
|
| 102 |
+
type="email"
|
| 103 |
+
placeholder="name@example.com"
|
| 104 |
+
value={inviteEmail}
|
| 105 |
+
onChange={(e) => setInviteEmail(e.target.value)}
|
| 106 |
+
/>
|
| 107 |
+
<p className="text-xs text-muted-foreground">
|
| 108 |
+
An invitation email with a join link will be sent to this address.
|
| 109 |
+
</p>
|
| 110 |
+
</div>
|
| 111 |
+
<DialogFooter>
|
| 112 |
+
<Button variant="outline" onClick={() => setInviteOpen(false)}>Cancel</Button>
|
| 113 |
+
<Button onClick={handleSendInvite}>Send invite</Button>
|
| 114 |
+
</DialogFooter>
|
| 115 |
+
</DialogContent>
|
| 116 |
+
</Dialog>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
web/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/components/Header.tsx
|
| 2 |
+
import React, { useState } from "react";
|
| 3 |
+
import { Button } from "./ui/button";
|
| 4 |
+
import { Menu, Sun, Moon, Languages, ChevronDown, LogOut, Plus, X, Edit, Star } from "lucide-react";
|
| 5 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
| 6 |
+
import {
|
| 7 |
+
DropdownMenu,
|
| 8 |
+
DropdownMenuContent,
|
| 9 |
+
DropdownMenuItem,
|
| 10 |
+
DropdownMenuTrigger,
|
| 11 |
+
DropdownMenuSeparator,
|
| 12 |
+
} from "./ui/dropdown-menu";
|
| 13 |
+
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 14 |
+
import type { Workspace, CourseInfo } from "../App";
|
| 15 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "./ui/dialog";
|
| 16 |
+
import { Input } from "./ui/input";
|
| 17 |
+
import { Label } from "./ui/label";
|
| 18 |
+
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
| 19 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
| 20 |
+
import { toast } from "sonner";
|
| 21 |
+
import { ProfileEditor } from "./ProfileEditor";
|
| 22 |
+
|
| 23 |
+
type UserType = {
|
| 24 |
+
name: string;
|
| 25 |
+
email: string;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
type Language = "auto" | "en" | "zh";
|
| 29 |
+
|
| 30 |
+
interface HeaderProps {
|
| 31 |
+
user: UserType | null;
|
| 32 |
+
onMenuClick: () => void;
|
| 33 |
+
onUserClick: () => void;
|
| 34 |
+
isDarkMode: boolean;
|
| 35 |
+
onToggleDarkMode: () => void;
|
| 36 |
+
language: Language;
|
| 37 |
+
onLanguageChange: (lang: Language) => void;
|
| 38 |
+
workspaces: Workspace[];
|
| 39 |
+
currentWorkspace: Workspace | undefined;
|
| 40 |
+
onWorkspaceChange: (workspaceId: string) => void;
|
| 41 |
+
onCreateWorkspace?: (payload: {
|
| 42 |
+
name: string;
|
| 43 |
+
category: "course" | "personal";
|
| 44 |
+
courseId?: string;
|
| 45 |
+
invites: string[];
|
| 46 |
+
}) => void;
|
| 47 |
+
onLogout: () => void;
|
| 48 |
+
availableCourses?: CourseInfo[];
|
| 49 |
+
onUserUpdate?: (user: UserType) => void;
|
| 50 |
+
|
| 51 |
+
// ✅ NEW: controlled review-star display + click behavior
|
| 52 |
+
reviewStarOpacity?: number; // 0..1
|
| 53 |
+
reviewEnergyPct?: number; // 0..100
|
| 54 |
+
onStarClick?: () => void; // recommended: switch to Review
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function Header({
|
| 58 |
+
user,
|
| 59 |
+
onMenuClick,
|
| 60 |
+
onUserClick,
|
| 61 |
+
isDarkMode,
|
| 62 |
+
onToggleDarkMode,
|
| 63 |
+
language,
|
| 64 |
+
onLanguageChange,
|
| 65 |
+
workspaces,
|
| 66 |
+
currentWorkspace,
|
| 67 |
+
onWorkspaceChange,
|
| 68 |
+
onLogout,
|
| 69 |
+
onCreateWorkspace,
|
| 70 |
+
availableCourses = [],
|
| 71 |
+
onUserUpdate,
|
| 72 |
+
reviewStarOpacity,
|
| 73 |
+
reviewEnergyPct,
|
| 74 |
+
onStarClick,
|
| 75 |
+
}: HeaderProps) {
|
| 76 |
+
const [showProfileEditor, setShowProfileEditor] = useState(false);
|
| 77 |
+
|
| 78 |
+
const [createOpen, setCreateOpen] = useState(false);
|
| 79 |
+
const [workspaceName, setWorkspaceName] = useState("");
|
| 80 |
+
const [category, setCategory] = useState<"course" | "personal">("course");
|
| 81 |
+
const [courseId, setCourseId] = useState("");
|
| 82 |
+
const [inviteEmail, setInviteEmail] = useState("");
|
| 83 |
+
const [invites, setInvites] = useState<string[]>([]);
|
| 84 |
+
|
| 85 |
+
const opacity = typeof reviewStarOpacity === "number" ? reviewStarOpacity : 0.15;
|
| 86 |
+
const energy = typeof reviewEnergyPct === "number" ? reviewEnergyPct : Math.round(opacity * 100);
|
| 87 |
+
|
| 88 |
+
const getStarStyle = () => {
|
| 89 |
+
if (energy <= 0) {
|
| 90 |
+
return { fill: "transparent", stroke: "white", strokeWidth: 1.5, strokeDasharray: "2 2" };
|
| 91 |
+
}
|
| 92 |
+
if (energy >= 60) return { fill: "#fbbf24", stroke: "#fbbf24", strokeWidth: 0 };
|
| 93 |
+
if (energy >= 25) return { fill: "#fcd34d", stroke: "#fcd34d", strokeWidth: 0 };
|
| 94 |
+
return { fill: "#fde68a", stroke: "#fde68a", strokeWidth: 0 };
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const addInvite = () => {
|
| 98 |
+
const email = inviteEmail.trim();
|
| 99 |
+
if (!email) return;
|
| 100 |
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
| 101 |
+
toast.error("Please enter a valid email");
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
if (invites.includes(email)) return;
|
| 105 |
+
setInvites((prev) => [...prev, email]);
|
| 106 |
+
setInviteEmail("");
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const removeInvite = (email: string) => {
|
| 110 |
+
setInvites((prev) => prev.filter((e) => e !== email));
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const handleCreate = () => {
|
| 114 |
+
if (!workspaceName.trim()) {
|
| 115 |
+
toast.error("Please enter a workspace name");
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
if (category === "course" && !courseId) {
|
| 119 |
+
toast.error("Please select a course");
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
if (invites.length === 0) {
|
| 123 |
+
toast.error("Please add at least one member");
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
onCreateWorkspace?.({
|
| 128 |
+
name: workspaceName.trim(),
|
| 129 |
+
category,
|
| 130 |
+
courseId: courseId || undefined,
|
| 131 |
+
invites,
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
setWorkspaceName("");
|
| 135 |
+
setCourseId("");
|
| 136 |
+
setCategory("course");
|
| 137 |
+
setInvites([]);
|
| 138 |
+
setInviteEmail("");
|
| 139 |
+
setCreateOpen(false);
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<header className="h-16 border-b border-border bg-card px-4 lg:px-6 flex items-center justify-between sticky top-0 z-[100]">
|
| 144 |
+
<div className="flex items-center gap-4">
|
| 145 |
+
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenuClick}>
|
| 146 |
+
<Menu className="h-5 w-5" />
|
| 147 |
+
</Button>
|
| 148 |
+
|
| 149 |
+
<div className="flex items-center gap-3">
|
| 150 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center">
|
| 151 |
+
<img src={clareAvatar} alt="Clare AI" className="w-full h-full object-cover" />
|
| 152 |
+
</div>
|
| 153 |
+
<div>
|
| 154 |
+
<h1
|
| 155 |
+
className="text-lg sm:text-xl tracking-tight"
|
| 156 |
+
style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, letterSpacing: "-0.02em" }}
|
| 157 |
+
>
|
| 158 |
+
Clare{" "}
|
| 159 |
+
<span className="text-sm font-bold text-muted-foreground hidden sm:inline ml-2">
|
| 160 |
+
Your Personalized AI Tutor
|
| 161 |
+
</span>
|
| 162 |
+
</h1>
|
| 163 |
+
<p className="text-xs text-muted-foreground hidden sm:block">
|
| 164 |
+
Personalized guidance, review, and intelligent reinforcement
|
| 165 |
+
</p>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<div className="flex items-center gap-2">
|
| 171 |
+
<DropdownMenu>
|
| 172 |
+
<DropdownMenuTrigger asChild>
|
| 173 |
+
<Button variant="ghost" size="icon" aria-label="Change language">
|
| 174 |
+
<Languages className="h-5 w-5" />
|
| 175 |
+
</Button>
|
| 176 |
+
</DropdownMenuTrigger>
|
| 177 |
+
<DropdownMenuContent align="end">
|
| 178 |
+
<DropdownMenuItem onClick={() => onLanguageChange("auto")}>
|
| 179 |
+
{language === "auto" && "✓ "}Auto
|
| 180 |
+
</DropdownMenuItem>
|
| 181 |
+
<DropdownMenuItem onClick={() => onLanguageChange("en")}>
|
| 182 |
+
{language === "en" && "✓ "}English
|
| 183 |
+
</DropdownMenuItem>
|
| 184 |
+
<DropdownMenuItem onClick={() => onLanguageChange("zh")}>
|
| 185 |
+
{language === "zh" && "✓ "}简体中文
|
| 186 |
+
</DropdownMenuItem>
|
| 187 |
+
</DropdownMenuContent>
|
| 188 |
+
</DropdownMenu>
|
| 189 |
+
|
| 190 |
+
<Button variant="ghost" size="icon" onClick={onToggleDarkMode} aria-label="Toggle dark mode">
|
| 191 |
+
{isDarkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
| 192 |
+
</Button>
|
| 193 |
+
|
| 194 |
+
{user && currentWorkspace ? (
|
| 195 |
+
<>
|
| 196 |
+
<DropdownMenu>
|
| 197 |
+
<DropdownMenuTrigger asChild>
|
| 198 |
+
<Button variant="outline" className="gap-2 pl-2 pr-3" aria-label="Switch workspace">
|
| 199 |
+
<img
|
| 200 |
+
src={currentWorkspace.avatar}
|
| 201 |
+
alt={currentWorkspace.name}
|
| 202 |
+
className="w-6 h-6 rounded-full object-cover"
|
| 203 |
+
/>
|
| 204 |
+
<span className="hidden sm:inline max-w-[120px] truncate">{currentWorkspace.name}</span>
|
| 205 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 206 |
+
</Button>
|
| 207 |
+
</DropdownMenuTrigger>
|
| 208 |
+
|
| 209 |
+
<DropdownMenuContent align="end" className="min-w-[14rem]">
|
| 210 |
+
{workspaces.map((workspace) => (
|
| 211 |
+
<DropdownMenuItem
|
| 212 |
+
key={workspace.id}
|
| 213 |
+
onClick={() => onWorkspaceChange(workspace.id)}
|
| 214 |
+
className={`gap-3 ${currentWorkspace.id === workspace.id ? "bg-accent" : ""}`}
|
| 215 |
+
>
|
| 216 |
+
<img
|
| 217 |
+
src={workspace.avatar}
|
| 218 |
+
alt={workspace.name}
|
| 219 |
+
className="w-6 h-6 rounded-full object-cover flex-shrink-0"
|
| 220 |
+
/>
|
| 221 |
+
<span className="truncate">{workspace.name}</span>
|
| 222 |
+
{currentWorkspace.id === workspace.id && <span className="ml-auto text-primary">✓</span>}
|
| 223 |
+
</DropdownMenuItem>
|
| 224 |
+
))}
|
| 225 |
+
<DropdownMenuSeparator />
|
| 226 |
+
<DropdownMenuItem className="gap-2" onClick={() => setCreateOpen(true)}>
|
| 227 |
+
<Plus className="h-4 w-4" />
|
| 228 |
+
<span>New Group Workspace</span>
|
| 229 |
+
</DropdownMenuItem>
|
| 230 |
+
</DropdownMenuContent>
|
| 231 |
+
</DropdownMenu>
|
| 232 |
+
|
| 233 |
+
{/* Profile Avatar Button */}
|
| 234 |
+
<div className="relative inline-block">
|
| 235 |
+
<DropdownMenu>
|
| 236 |
+
<DropdownMenuTrigger asChild>
|
| 237 |
+
<Button variant="ghost" size="icon" className="rounded-full" aria-label="User profile">
|
| 238 |
+
<img
|
| 239 |
+
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`}
|
| 240 |
+
alt={user.name}
|
| 241 |
+
className="w-8 h-8 rounded-full object-cover"
|
| 242 |
+
/>
|
| 243 |
+
</Button>
|
| 244 |
+
</DropdownMenuTrigger>
|
| 245 |
+
|
| 246 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 247 |
+
<div className="px-2 py-1.5">
|
| 248 |
+
<div className="flex items-center justify-between gap-2">
|
| 249 |
+
<div className="flex-1 min-w-0">
|
| 250 |
+
<p className="text-sm font-medium truncate">{user.name}</p>
|
| 251 |
+
<p className="text-xs text-muted-foreground truncate">
|
| 252 |
+
ID: {user.email.split("@")[0] || user.email}
|
| 253 |
+
</p>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 257 |
+
<Star
|
| 258 |
+
className="w-4 h-4"
|
| 259 |
+
style={{
|
| 260 |
+
...getStarStyle(),
|
| 261 |
+
opacity,
|
| 262 |
+
filter: energy >= 85 ? "drop-shadow(0 0 2px rgba(251, 191, 36, 0.8))" : "none",
|
| 263 |
+
}}
|
| 264 |
+
/>
|
| 265 |
+
<div className="flex flex-col items-end">
|
| 266 |
+
<span className="text-xs font-medium">{energy}%</span>
|
| 267 |
+
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
| 268 |
+
<div
|
| 269 |
+
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
|
| 270 |
+
style={{ width: `${energy}%` }}
|
| 271 |
+
/>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<DropdownMenuSeparator />
|
| 279 |
+
|
| 280 |
+
<DropdownMenuItem onClick={() => setShowProfileEditor(true)}>
|
| 281 |
+
<Edit className="h-4 w-4 mr-2" />
|
| 282 |
+
Edit Profile
|
| 283 |
+
</DropdownMenuItem>
|
| 284 |
+
|
| 285 |
+
<DropdownMenuSeparator />
|
| 286 |
+
|
| 287 |
+
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
|
| 288 |
+
<LogOut className="h-4 w-4 mr-2" />
|
| 289 |
+
Log out
|
| 290 |
+
</DropdownMenuItem>
|
| 291 |
+
</DropdownMenuContent>
|
| 292 |
+
</DropdownMenu>
|
| 293 |
+
|
| 294 |
+
{/* Star badge in top-right corner of avatar */}
|
| 295 |
+
<TooltipProvider>
|
| 296 |
+
<Tooltip>
|
| 297 |
+
<TooltipTrigger asChild>
|
| 298 |
+
<button
|
| 299 |
+
type="button"
|
| 300 |
+
className="absolute cursor-pointer z-20 pointer-events-auto bg-transparent border-0 p-0"
|
| 301 |
+
style={{
|
| 302 |
+
top: "-8px",
|
| 303 |
+
right: "-16px",
|
| 304 |
+
opacity,
|
| 305 |
+
transition: "opacity 0.3s ease-in-out",
|
| 306 |
+
filter: energy >= 85
|
| 307 |
+
? "drop-shadow(0 0 4px rgba(251, 191, 36, 0.8)) drop-shadow(0 0 8px rgba(251, 191, 36, 0.4))"
|
| 308 |
+
: "none",
|
| 309 |
+
}}
|
| 310 |
+
onClick={(e) => {
|
| 311 |
+
e.preventDefault();
|
| 312 |
+
e.stopPropagation();
|
| 313 |
+
onStarClick?.();
|
| 314 |
+
}}
|
| 315 |
+
aria-label="Review energy"
|
| 316 |
+
title="Review energy"
|
| 317 |
+
>
|
| 318 |
+
<Star
|
| 319 |
+
className="w-5 h-5"
|
| 320 |
+
style={{
|
| 321 |
+
...getStarStyle(),
|
| 322 |
+
opacity,
|
| 323 |
+
filter: energy >= 85 ? "drop-shadow(0 0 2px rgba(251, 191, 36, 1))" : "none",
|
| 324 |
+
}}
|
| 325 |
+
/>
|
| 326 |
+
</button>
|
| 327 |
+
</TooltipTrigger>
|
| 328 |
+
|
| 329 |
+
<TooltipContent
|
| 330 |
+
className="z-[200] border border-amber-300/30 shadow-md"
|
| 331 |
+
style={{
|
| 332 |
+
zIndex: 200,
|
| 333 |
+
backgroundColor: "rgba(251, 191, 36, 0.95)",
|
| 334 |
+
color: "rgb(28, 25, 23)",
|
| 335 |
+
}}
|
| 336 |
+
sideOffset={5}
|
| 337 |
+
>
|
| 338 |
+
<div className="space-y-1">
|
| 339 |
+
<p className="text-sm font-medium">Energy: {energy}%</p>
|
| 340 |
+
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
| 341 |
+
<div
|
| 342 |
+
className="h-full bg-gradient-to-r from-amber-400 to-yellow-500 transition-all duration-300"
|
| 343 |
+
style={{ width: `${energy}%` }}
|
| 344 |
+
/>
|
| 345 |
+
</div>
|
| 346 |
+
<p className="text-xs opacity-80">Enter Review and complete at least 1 action today to recharge.</p>
|
| 347 |
+
</div>
|
| 348 |
+
</TooltipContent>
|
| 349 |
+
</Tooltip>
|
| 350 |
+
</TooltipProvider>
|
| 351 |
+
</div>
|
| 352 |
+
</>
|
| 353 |
+
) : null}
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
{/* Create Group Workspace Dialog */}
|
| 357 |
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
| 358 |
+
<DialogContent
|
| 359 |
+
className="w-[600px] max-w-[600px] sm:max-w-[600px] z-[1001] pointer-events-auto"
|
| 360 |
+
style={{ width: 600, maxWidth: 600, zIndex: 1001 }}
|
| 361 |
+
overlayClassName="!z-[99]"
|
| 362 |
+
overlayStyle={{
|
| 363 |
+
top: "64px",
|
| 364 |
+
left: 0,
|
| 365 |
+
right: 0,
|
| 366 |
+
bottom: 0,
|
| 367 |
+
zIndex: 99,
|
| 368 |
+
position: "fixed",
|
| 369 |
+
}}
|
| 370 |
+
onPointerDownOutside={(e) => e.preventDefault()}
|
| 371 |
+
onInteractOutside={(e) => e.preventDefault()}
|
| 372 |
+
>
|
| 373 |
+
<DialogHeader>
|
| 374 |
+
<DialogTitle>Create Group Workspace</DialogTitle>
|
| 375 |
+
</DialogHeader>
|
| 376 |
+
|
| 377 |
+
<div className="space-y-6">
|
| 378 |
+
<div className="space-y-2">
|
| 379 |
+
<Label htmlFor="ws-name">Workspace Name</Label>
|
| 380 |
+
<Input
|
| 381 |
+
id="ws-name"
|
| 382 |
+
value={workspaceName}
|
| 383 |
+
onChange={(e) => setWorkspaceName(e.target.value)}
|
| 384 |
+
placeholder="e.g., CS 101 Study Group"
|
| 385 |
+
/>
|
| 386 |
+
</div>
|
| 387 |
+
|
| 388 |
+
<div className="space-y-2">
|
| 389 |
+
<Label>Category</Label>
|
| 390 |
+
<RadioGroup
|
| 391 |
+
value={category}
|
| 392 |
+
onValueChange={(val) => setCategory(val as "course" | "personal")}
|
| 393 |
+
className="flex gap-4"
|
| 394 |
+
>
|
| 395 |
+
<div className="flex items-center space-x-2">
|
| 396 |
+
<RadioGroupItem id="cat-course" value="course" />
|
| 397 |
+
<Label htmlFor="cat-course">Course</Label>
|
| 398 |
+
</div>
|
| 399 |
+
<div className="flex items-center space-x-2">
|
| 400 |
+
<RadioGroupItem id="cat-personal" value="personal" />
|
| 401 |
+
<Label htmlFor="cat-personal">Personal Interest</Label>
|
| 402 |
+
</div>
|
| 403 |
+
</RadioGroup>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
{category === "course" && (
|
| 407 |
+
<div className="space-y-2">
|
| 408 |
+
<Label htmlFor="course-select">Course Name</Label>
|
| 409 |
+
<Select value={courseId} onValueChange={setCourseId}>
|
| 410 |
+
<SelectTrigger id="course-select">
|
| 411 |
+
<SelectValue placeholder="Select a course" />
|
| 412 |
+
</SelectTrigger>
|
| 413 |
+
<SelectContent>
|
| 414 |
+
{availableCourses.map((course) => (
|
| 415 |
+
<SelectItem key={course.id} value={course.id}>
|
| 416 |
+
{course.name}
|
| 417 |
+
</SelectItem>
|
| 418 |
+
))}
|
| 419 |
+
</SelectContent>
|
| 420 |
+
</Select>
|
| 421 |
+
</div>
|
| 422 |
+
)}
|
| 423 |
+
|
| 424 |
+
<div className="space-y-2">
|
| 425 |
+
<Label>Invite Members (emails)</Label>
|
| 426 |
+
<div className="flex gap-2">
|
| 427 |
+
<Input
|
| 428 |
+
value={inviteEmail}
|
| 429 |
+
onChange={(e) => setInviteEmail(e.target.value)}
|
| 430 |
+
placeholder="Enter email and click Add"
|
| 431 |
+
onKeyDown={(e) => {
|
| 432 |
+
if (e.key === "Enter") {
|
| 433 |
+
e.preventDefault();
|
| 434 |
+
addInvite();
|
| 435 |
+
}
|
| 436 |
+
}}
|
| 437 |
+
/>
|
| 438 |
+
<Button variant="secondary" onClick={addInvite}>
|
| 439 |
+
Add
|
| 440 |
+
</Button>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
{invites.length > 0 && (
|
| 444 |
+
<div className="flex flex-wrap gap-2">
|
| 445 |
+
{invites.map((email) => (
|
| 446 |
+
<span key={email} className="inline-flex items-center px-2 py-1 rounded-full bg-muted text-sm">
|
| 447 |
+
{email}
|
| 448 |
+
<Button
|
| 449 |
+
variant="ghost"
|
| 450 |
+
size="icon"
|
| 451 |
+
className="h-4 w-4 ml-1"
|
| 452 |
+
onClick={() => removeInvite(email)}
|
| 453 |
+
aria-label={`Remove ${email}`}
|
| 454 |
+
>
|
| 455 |
+
<X className="h-3 w-3" />
|
| 456 |
+
</Button>
|
| 457 |
+
</span>
|
| 458 |
+
))}
|
| 459 |
+
</div>
|
| 460 |
+
)}
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
<DialogFooter>
|
| 465 |
+
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
| 466 |
+
Cancel
|
| 467 |
+
</Button>
|
| 468 |
+
<Button onClick={handleCreate}>Create</Button>
|
| 469 |
+
</DialogFooter>
|
| 470 |
+
</DialogContent>
|
| 471 |
+
</Dialog>
|
| 472 |
+
|
| 473 |
+
{/* Profile Editor Dialog */}
|
| 474 |
+
{user && showProfileEditor && (
|
| 475 |
+
<ProfileEditor
|
| 476 |
+
user={user}
|
| 477 |
+
onSave={(updatedUser) => {
|
| 478 |
+
if (onUserUpdate) onUserUpdate(updatedUser);
|
| 479 |
+
setShowProfileEditor(false);
|
| 480 |
+
}}
|
| 481 |
+
onClose={() => setShowProfileEditor(false)}
|
| 482 |
+
/>
|
| 483 |
+
)}
|
| 484 |
+
</header>
|
| 485 |
+
);
|
| 486 |
+
}
|
web/src/components/LearningModeSelector.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Card } from './ui/card';
|
| 3 |
+
import {
|
| 4 |
+
Lightbulb,
|
| 5 |
+
MessageCircleQuestion,
|
| 6 |
+
GraduationCap,
|
| 7 |
+
FileEdit,
|
| 8 |
+
Zap,
|
| 9 |
+
MessageSquare
|
| 10 |
+
} from 'lucide-react';
|
| 11 |
+
import type { LearningMode } from '../App';
|
| 12 |
+
|
| 13 |
+
interface ModeSelectorProps {
|
| 14 |
+
selectedMode: LearningMode;
|
| 15 |
+
onModeChange: (mode: LearningMode) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const modes = [
|
| 19 |
+
{
|
| 20 |
+
id: 'general' as LearningMode,
|
| 21 |
+
icon: MessageSquare,
|
| 22 |
+
title: 'General',
|
| 23 |
+
description: 'Answer various questions (context required)',
|
| 24 |
+
color: 'from-purple-500 to-purple-600',
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
id: 'concept' as LearningMode,
|
| 28 |
+
icon: Lightbulb,
|
| 29 |
+
title: 'Concept Explainer',
|
| 30 |
+
description: 'Break down complex topics',
|
| 31 |
+
color: 'from-blue-500 to-blue-600',
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
id: 'socratic' as LearningMode,
|
| 35 |
+
title: 'Socratic Tutor',
|
| 36 |
+
description: 'Learn through questions',
|
| 37 |
+
color: 'from-red-500 to-rose-600',
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: 'exam' as LearningMode,
|
| 41 |
+
icon: GraduationCap,
|
| 42 |
+
title: 'Exam Prep/Quiz',
|
| 43 |
+
description: 'Test your knowledge',
|
| 44 |
+
color: 'from-green-500 to-green-600',
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'assignment' as LearningMode,
|
| 48 |
+
icon: FileEdit,
|
| 49 |
+
title: 'Assignment Helper',
|
| 50 |
+
description: 'Get homework guidance',
|
| 51 |
+
color: 'from-orange-500 to-orange-600',
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
id: 'summary' as LearningMode,
|
| 55 |
+
icon: Zap,
|
| 56 |
+
title: 'Quick Summary',
|
| 57 |
+
description: 'Fast key points review',
|
| 58 |
+
color: 'from-pink-500 to-pink-600',
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
export function LearningModeSelector({ selectedMode, onModeChange }: ModeSelectorProps) {
|
| 63 |
+
return (
|
| 64 |
+
<div className="space-y-2">
|
| 65 |
+
{modes.map((mode) => {
|
| 66 |
+
const Icon = mode.icon;
|
| 67 |
+
const isSelected = selectedMode === mode.id;
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<Card
|
| 71 |
+
key={mode.id}
|
| 72 |
+
className={`
|
| 73 |
+
p-3 cursor-pointer transition-all duration-200
|
| 74 |
+
${isSelected
|
| 75 |
+
? 'border-primary bg-accent shadow-sm'
|
| 76 |
+
: 'hover:border-primary/50 hover:shadow-sm'
|
| 77 |
+
}
|
| 78 |
+
`}
|
| 79 |
+
onClick={() => onModeChange(mode.id)}
|
| 80 |
+
>
|
| 81 |
+
<div className="flex items-start gap-3">
|
| 82 |
+
<div className={`
|
| 83 |
+
w-10 h-10 rounded-lg bg-gradient-to-br ${mode.color}
|
| 84 |
+
flex items-center justify-center flex-shrink-0
|
| 85 |
+
`}>
|
| 86 |
+
<Icon className="h-5 w-5 text-white" />
|
| 87 |
+
</div>
|
| 88 |
+
<div className="flex-1 min-w-0">
|
| 89 |
+
<h4 className="text-sm mb-1">{mode.title}</h4>
|
| 90 |
+
<p className="text-xs text-muted-foreground">{mode.description}</p>
|
| 91 |
+
</div>
|
| 92 |
+
{isSelected && (
|
| 93 |
+
<div className="w-2 h-2 rounded-full bg-primary flex-shrink-0 mt-2" />
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
</Card>
|
| 97 |
+
);
|
| 98 |
+
})}
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
web/src/components/LeftSidebar.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/components/LeftSidebar.tsx
|
| 2 |
+
// Backward-compatible re-export:
|
| 3 |
+
// If any legacy code imports from "web/src/components/LeftSidebar.tsx",
|
| 4 |
+
// it will still render the new sidebar implementation.
|
| 5 |
+
export { LeftSidebar } from "./sidebar/LeftSidebar";
|
| 6 |
+
export default undefined;
|
web/src/components/LoginScreen.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/components/LoginScreen.tsx
|
| 2 |
+
import React, { useState } from "react";
|
| 3 |
+
import { Button } from "./ui/button";
|
| 4 |
+
import { Input } from "./ui/input";
|
| 5 |
+
import { Label } from "./ui/label";
|
| 6 |
+
import { Card } from "./ui/card";
|
| 7 |
+
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 8 |
+
import type { User } from "../App";
|
| 9 |
+
import { apiLogin } from "../lib/api";
|
| 10 |
+
|
| 11 |
+
interface LoginScreenProps {
|
| 12 |
+
onLogin: (user: User) => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function LoginScreen({ onLogin }: LoginScreenProps) {
|
| 16 |
+
const [showForm, setShowForm] = useState(false);
|
| 17 |
+
const [name, setName] = useState("");
|
| 18 |
+
const [email, setEmail] = useState("");
|
| 19 |
+
|
| 20 |
+
const [submitting, setSubmitting] = useState(false);
|
| 21 |
+
const [err, setErr] = useState<string | null>(null);
|
| 22 |
+
|
| 23 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 24 |
+
e.preventDefault();
|
| 25 |
+
setErr(null);
|
| 26 |
+
|
| 27 |
+
const n = name.trim();
|
| 28 |
+
const u = email.trim();
|
| 29 |
+
if (!n || !u) return;
|
| 30 |
+
|
| 31 |
+
setSubmitting(true);
|
| 32 |
+
try {
|
| 33 |
+
// backend expects: { name, user_id }
|
| 34 |
+
const resp = await apiLogin({ name: n, user_id: u });
|
| 35 |
+
|
| 36 |
+
// api.ts returns { ok: true/false ... }
|
| 37 |
+
if ((resp as any)?.ok !== true) {
|
| 38 |
+
const msg = (resp as any)?.error || "Login failed";
|
| 39 |
+
setErr(msg);
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
onLogin({ name: n, email: u });
|
| 44 |
+
} catch (e: any) {
|
| 45 |
+
setErr(e?.message || "Login failed");
|
| 46 |
+
} finally {
|
| 47 |
+
setSubmitting(false);
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
| 53 |
+
<Card className="w-full max-w-md p-8">
|
| 54 |
+
<div className="flex flex-col items-center space-y-6">
|
| 55 |
+
<div className="w-24 h-24 rounded-full overflow-hidden bg-white flex items-center justify-center">
|
| 56 |
+
<img src={clareAvatar} alt="Clare AI" className="w-full h-full object-cover" />
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="text-center space-y-2">
|
| 60 |
+
<h1 className="text-2xl">Welcome to Clare</h1>
|
| 61 |
+
<p className="text-sm text-muted-foreground">
|
| 62 |
+
Your AI teaching assistant for personalized learning
|
| 63 |
+
</p>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{!showForm ? (
|
| 67 |
+
<Button onClick={() => setShowForm(true)} className="w-full" size="lg">
|
| 68 |
+
Sign In
|
| 69 |
+
</Button>
|
| 70 |
+
) : (
|
| 71 |
+
<form onSubmit={handleSubmit} className="w-full space-y-4">
|
| 72 |
+
<div className="space-y-2">
|
| 73 |
+
<Label htmlFor="login-name">Name</Label>
|
| 74 |
+
<Input
|
| 75 |
+
id="login-name"
|
| 76 |
+
value={name}
|
| 77 |
+
onChange={(e) => setName(e.target.value)}
|
| 78 |
+
placeholder="Enter your name"
|
| 79 |
+
required
|
| 80 |
+
disabled={submitting}
|
| 81 |
+
/>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="space-y-2">
|
| 85 |
+
<Label htmlFor="login-email">Email / Student ID</Label>
|
| 86 |
+
<Input
|
| 87 |
+
id="login-email"
|
| 88 |
+
type="email"
|
| 89 |
+
value={email}
|
| 90 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 91 |
+
placeholder="Enter your email or ID"
|
| 92 |
+
required
|
| 93 |
+
disabled={submitting}
|
| 94 |
+
/>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{err && (
|
| 98 |
+
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md p-2">
|
| 99 |
+
{err}
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
<div className="flex gap-2">
|
| 104 |
+
<Button type="submit" className="flex-1" disabled={submitting}>
|
| 105 |
+
{submitting ? "Signing in..." : "Enter"}
|
| 106 |
+
</Button>
|
| 107 |
+
<Button
|
| 108 |
+
type="button"
|
| 109 |
+
variant="outline"
|
| 110 |
+
onClick={() => {
|
| 111 |
+
if (submitting) return;
|
| 112 |
+
setShowForm(false);
|
| 113 |
+
setErr(null);
|
| 114 |
+
}}
|
| 115 |
+
disabled={submitting}
|
| 116 |
+
>
|
| 117 |
+
Cancel
|
| 118 |
+
</Button>
|
| 119 |
+
</div>
|
| 120 |
+
</form>
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
</Card>
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
web/src/components/Message.tsx
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// web/src/components/Message.tsx
|
| 2 |
+
import React, { useMemo, useState } from "react";
|
| 3 |
+
import { Button } from "./ui/button";
|
| 4 |
+
import ReactMarkdown from "react-markdown";
|
| 5 |
+
import remarkGfm from "remark-gfm";
|
| 6 |
+
import pdfIcon from "../assets/file-icons/pdf.png";
|
| 7 |
+
import pptIcon from "../assets/file-icons/ppt.png";
|
| 8 |
+
import otherIcon from "../assets/file-icons/other_format.png";
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import {
|
| 12 |
+
Copy,
|
| 13 |
+
ThumbsUp,
|
| 14 |
+
ThumbsDown,
|
| 15 |
+
ChevronDown,
|
| 16 |
+
ChevronUp,
|
| 17 |
+
Check,
|
| 18 |
+
X,
|
| 19 |
+
} from "lucide-react";
|
| 20 |
+
import { Badge } from "./ui/badge";
|
| 21 |
+
import {
|
| 22 |
+
Collapsible,
|
| 23 |
+
CollapsibleContent,
|
| 24 |
+
CollapsibleTrigger,
|
| 25 |
+
} from "./ui/collapsible";
|
| 26 |
+
import { Textarea } from "./ui/textarea";
|
| 27 |
+
import type { Message as MessageType } from "../App";
|
| 28 |
+
import { toast } from "sonner";
|
| 29 |
+
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 30 |
+
|
| 31 |
+
// ✅ NEW: real API call
|
| 32 |
+
import { apiFeedback } from "../lib/api";
|
| 33 |
+
|
| 34 |
+
interface MessageProps {
|
| 35 |
+
key?: React.Key;
|
| 36 |
+
message: MessageType;
|
| 37 |
+
showSenderInfo?: boolean; // For group chat mode
|
| 38 |
+
isFirstGreeting?: boolean; // Indicates if this is the first greeting message
|
| 39 |
+
showNextButton?: boolean; // For quiz mode
|
| 40 |
+
onNextQuestion?: () => void; // For quiz mode
|
| 41 |
+
chatMode?: "ask" | "review" | "quiz"; // Current chat mode
|
| 42 |
+
|
| 43 |
+
// ✅ NEW: for feedback submission
|
| 44 |
+
currentUserId?: string;
|
| 45 |
+
docType?: string;
|
| 46 |
+
learningMode?: string;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// 反馈标签选项
|
| 50 |
+
const FEEDBACK_TAGS = {
|
| 51 |
+
"not-helpful": [
|
| 52 |
+
"Code was incorrect",
|
| 53 |
+
"Shouldn't have used Memory",
|
| 54 |
+
"Don't like the personality",
|
| 55 |
+
"Don't like the style",
|
| 56 |
+
"Not factually correct",
|
| 57 |
+
],
|
| 58 |
+
helpful: [
|
| 59 |
+
"Accurate and helpful",
|
| 60 |
+
"Clear explanation",
|
| 61 |
+
"Good examples",
|
| 62 |
+
"Solved my problem",
|
| 63 |
+
"Well structured",
|
| 64 |
+
],
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
// ---- Markdown list normalization ----
|
| 68 |
+
function normalizeMarkdownLists(input: string) {
|
| 69 |
+
if (!input) return input;
|
| 70 |
+
|
| 71 |
+
return input
|
| 72 |
+
.replace(/(^|\n)(\s*)(\d+\.)\s*\n+\s+/g, "$1$2$3 ")
|
| 73 |
+
.replace(/(^|\n)(\s*)([-*+])\s*\n+\s+/g, "$1$2$3 ");
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export function Message({
|
| 77 |
+
message,
|
| 78 |
+
showSenderInfo = false,
|
| 79 |
+
isFirstGreeting = false,
|
| 80 |
+
showNextButton = false,
|
| 81 |
+
onNextQuestion,
|
| 82 |
+
chatMode = "ask",
|
| 83 |
+
|
| 84 |
+
// ✅ NEW
|
| 85 |
+
currentUserId,
|
| 86 |
+
docType = "Syllabus",
|
| 87 |
+
learningMode = "general",
|
| 88 |
+
}: MessageProps) {
|
| 89 |
+
const [feedback, setFeedback] = useState<"helpful" | "not-helpful" | null>(
|
| 90 |
+
null
|
| 91 |
+
);
|
| 92 |
+
const [copied, setCopied] = useState(false);
|
| 93 |
+
|
| 94 |
+
// ✅ References UI state
|
| 95 |
+
const [referencesOpen, setReferencesOpen] = useState(false);
|
| 96 |
+
|
| 97 |
+
const [showFeedbackArea, setShowFeedbackArea] = useState(false);
|
| 98 |
+
const [feedbackType, setFeedbackType] = useState<
|
| 99 |
+
"helpful" | "not-helpful" | null
|
| 100 |
+
>(null);
|
| 101 |
+
const [feedbackText, setFeedbackText] = useState("");
|
| 102 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
| 103 |
+
const [nextButtonClicked, setNextButtonClicked] = useState(false);
|
| 104 |
+
|
| 105 |
+
const isUser = message.role === "user";
|
| 106 |
+
const isWelcomeMessage =
|
| 107 |
+
isFirstGreeting || message.id === "review-1" || message.id === "quiz-1";
|
| 108 |
+
const shouldShowActions = isUser ? true : !isWelcomeMessage;
|
| 109 |
+
|
| 110 |
+
const handleCopy = async () => {
|
| 111 |
+
await navigator.clipboard.writeText(message.content);
|
| 112 |
+
setCopied(true);
|
| 113 |
+
toast.success("Message copied to clipboard");
|
| 114 |
+
setTimeout(() => setCopied(false), 2000);
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const handleFeedbackClick = (type: "helpful" | "not-helpful") => {
|
| 118 |
+
if (feedback === type) {
|
| 119 |
+
setFeedback(null);
|
| 120 |
+
setShowFeedbackArea(false);
|
| 121 |
+
setFeedbackType(null);
|
| 122 |
+
setFeedbackText("");
|
| 123 |
+
setSelectedTags([]);
|
| 124 |
+
} else {
|
| 125 |
+
setFeedback(type);
|
| 126 |
+
setFeedbackType(type);
|
| 127 |
+
setShowFeedbackArea(true);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const handleFeedbackClose = () => {
|
| 132 |
+
setShowFeedbackArea(false);
|
| 133 |
+
setFeedbackType(null);
|
| 134 |
+
setFeedbackText("");
|
| 135 |
+
setSelectedTags([]);
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const handleTagToggle = (tag: string) => {
|
| 139 |
+
setSelectedTags((prev) =>
|
| 140 |
+
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
| 141 |
+
);
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// ✅ REAL submit to backend -> LangSmith dataset
|
| 145 |
+
const handleFeedbackSubmit = async () => {
|
| 146 |
+
if (!currentUserId || !currentUserId.trim()) {
|
| 147 |
+
toast.error("Missing user_id; cannot submit feedback.");
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
if (!feedbackType) {
|
| 151 |
+
toast.error("Please select Helpful / Not helpful.");
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// UI uses "not-helpful" (dash), backend expects "not_helpful" (underscore)
|
| 156 |
+
const rating = feedbackType === "helpful" ? "helpful" : "not_helpful";
|
| 157 |
+
|
| 158 |
+
try {
|
| 159 |
+
await apiFeedback({
|
| 160 |
+
user_id: currentUserId,
|
| 161 |
+
rating,
|
| 162 |
+
assistant_message_id: message.id,
|
| 163 |
+
assistant_text: message.content,
|
| 164 |
+
user_text: "", // optional
|
| 165 |
+
comment: feedbackText || "",
|
| 166 |
+
tags: selectedTags,
|
| 167 |
+
refs: message.references ?? [],
|
| 168 |
+
learning_mode: chatMode === "ask" ? learningMode : chatMode,
|
| 169 |
+
doc_type: docType,
|
| 170 |
+
timestamp_ms: Date.now(),
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
toast.success("Thanks — feedback submitted.");
|
| 174 |
+
handleFeedbackClose();
|
| 175 |
+
} catch (e: any) {
|
| 176 |
+
// eslint-disable-next-line no-console
|
| 177 |
+
console.error("feedback submit failed:", e);
|
| 178 |
+
toast.error(e?.message || "Failed to submit feedback.");
|
| 179 |
+
}
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const normalizedMarkdown = useMemo(() => {
|
| 183 |
+
return normalizeMarkdownLists(message.content || "");
|
| 184 |
+
}, [message.content]);
|
| 185 |
+
|
| 186 |
+
const markdownClass = useMemo(() => {
|
| 187 |
+
return [
|
| 188 |
+
"text-base leading-relaxed break-words",
|
| 189 |
+
" [&_p]:my-2",
|
| 190 |
+
" [&_p:first-child]:mt-0",
|
| 191 |
+
" [&_p:last-child]:mb-0",
|
| 192 |
+
" [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-black/5 dark:[&_code]:bg-white/10",
|
| 193 |
+
" [&_pre]:my-2 [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-auto [&_pre]:bg-black/5 dark:[&_pre]:bg-white/10",
|
| 194 |
+
" [&_a]:underline [&_a]:underline-offset-2",
|
| 195 |
+
].join("");
|
| 196 |
+
}, []);
|
| 197 |
+
|
| 198 |
+
const renderBubbleContent = () => {
|
| 199 |
+
if (isUser) {
|
| 200 |
+
return (
|
| 201 |
+
<p className="whitespace-pre-wrap text-base break-words">
|
| 202 |
+
{message.content}
|
| 203 |
+
</p>
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
return (
|
| 208 |
+
<ReactMarkdown
|
| 209 |
+
remarkPlugins={[remarkGfm]}
|
| 210 |
+
className={markdownClass}
|
| 211 |
+
components={{
|
| 212 |
+
p: ({ children }) => (
|
| 213 |
+
<p className="my-2 whitespace-pre-wrap break-words">{children}</p>
|
| 214 |
+
),
|
| 215 |
+
ul: ({ children }) => (
|
| 216 |
+
<ul className="my-3 pl-6 space-y-2">{children}</ul>
|
| 217 |
+
),
|
| 218 |
+
ol: ({ children }) => <ol className="my-3 space-y-4">{children}</ol>,
|
| 219 |
+
li: ({ children, node }) => {
|
| 220 |
+
const parent = (node as any)?.parent?.tagName;
|
| 221 |
+
if (parent === "ol") {
|
| 222 |
+
return (
|
| 223 |
+
<li className="list-none">
|
| 224 |
+
<div className="flex items-start">
|
| 225 |
+
<span className="w-6 text-right pr-2 flex-shrink-0 font-medium">
|
| 226 |
+
{(node as any)?.index + 1}.
|
| 227 |
+
</span>
|
| 228 |
+
<div className="min-w-0">{children}</div>
|
| 229 |
+
</div>
|
| 230 |
+
</li>
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
return <li>{children}</li>;
|
| 234 |
+
},
|
| 235 |
+
strong: ({ children }) => (
|
| 236 |
+
<strong className="font-semibold">{children}</strong>
|
| 237 |
+
),
|
| 238 |
+
em: ({ children }) => <em className="italic">{children}</em>,
|
| 239 |
+
}}
|
| 240 |
+
>
|
| 241 |
+
{normalizedMarkdown}
|
| 242 |
+
</ReactMarkdown>
|
| 243 |
+
);
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
const hasRefs = !!(message.references && message.references.length > 0);
|
| 247 |
+
|
| 248 |
+
const attachments = (message as any).attachments as
|
| 249 |
+
| Array<{ name: string; kind: string; size: number; fileType?: string }>
|
| 250 |
+
| undefined;
|
| 251 |
+
|
| 252 |
+
const hasAttachments = !!(attachments && attachments.length);
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
return (
|
| 256 |
+
<div
|
| 257 |
+
className={`flex gap-2 ${
|
| 258 |
+
isUser && !showSenderInfo ? "justify-end" : "justify-start"
|
| 259 |
+
} px-4`}
|
| 260 |
+
>
|
| 261 |
+
{/* Avatar */}
|
| 262 |
+
{showSenderInfo && message.sender ? (
|
| 263 |
+
<div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 overflow-hidden bg-white">
|
| 264 |
+
{message.sender.isAI ? (
|
| 265 |
+
<img
|
| 266 |
+
src={clareAvatar}
|
| 267 |
+
alt="Clare"
|
| 268 |
+
className="w-full h-full object-cover"
|
| 269 |
+
/>
|
| 270 |
+
) : (
|
| 271 |
+
<img
|
| 272 |
+
src={
|
| 273 |
+
message.sender.avatar ||
|
| 274 |
+
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
|
| 275 |
+
message.sender.email || message.sender.name
|
| 276 |
+
)}`
|
| 277 |
+
}
|
| 278 |
+
alt={message.sender.name}
|
| 279 |
+
className="w-full h-full object-cover"
|
| 280 |
+
/>
|
| 281 |
+
)}
|
| 282 |
+
</div>
|
| 283 |
+
) : !isUser ? (
|
| 284 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 285 |
+
<img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
|
| 286 |
+
</div>
|
| 287 |
+
) : null}
|
| 288 |
+
|
| 289 |
+
<div
|
| 290 |
+
className={`group flex flex-col gap-2 ${
|
| 291 |
+
isUser && !showSenderInfo ? "items-end" : "items-start"
|
| 292 |
+
}`}
|
| 293 |
+
style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}
|
| 294 |
+
>
|
| 295 |
+
{/* Sender name in group chat */}
|
| 296 |
+
{showSenderInfo && message.sender && (
|
| 297 |
+
<div className="flex items-center gap-2 px-1">
|
| 298 |
+
<span className="text-xs">{message.sender.name}</span>
|
| 299 |
+
{message.sender.isAI && (
|
| 300 |
+
<Badge variant="secondary" className="text-xs h-4 px-1">
|
| 301 |
+
AI
|
| 302 |
+
</Badge>
|
| 303 |
+
)}
|
| 304 |
+
</div>
|
| 305 |
+
)}
|
| 306 |
+
|
| 307 |
+
{/* Bubble (make it relative so we can anchor the ref box to bottom-left) */}
|
| 308 |
+
<div
|
| 309 |
+
className={`
|
| 310 |
+
relative
|
| 311 |
+
rounded-2xl px-4 py-3
|
| 312 |
+
${isUser && !showSenderInfo ? "bg-primary text-primary-foreground" : "bg-muted"}
|
| 313 |
+
`}
|
| 314 |
+
>
|
| 315 |
+
{/* ✅ Attachments shown “with” the message (neutral card style) */}
|
| 316 |
+
{hasAttachments && (
|
| 317 |
+
<div className="mb-3 flex flex-wrap gap-2">
|
| 318 |
+
{attachments!.map((a, idx) => {
|
| 319 |
+
const icon =
|
| 320 |
+
a.kind === "pdf" ? pdfIcon : a.kind === "ppt" ? pptIcon : otherIcon;
|
| 321 |
+
|
| 322 |
+
const label =
|
| 323 |
+
a.kind === "pdf"
|
| 324 |
+
? "PDF"
|
| 325 |
+
: a.kind === "ppt"
|
| 326 |
+
? "Presentation"
|
| 327 |
+
: a.kind === "doc"
|
| 328 |
+
? "Document"
|
| 329 |
+
: a.kind === "image"
|
| 330 |
+
? "Image"
|
| 331 |
+
: "File";
|
| 332 |
+
|
| 333 |
+
return (
|
| 334 |
+
<div
|
| 335 |
+
key={`${a.name}-${idx}`}
|
| 336 |
+
className="
|
| 337 |
+
inline-flex items-center gap-3
|
| 338 |
+
rounded-xl border border-border
|
| 339 |
+
bg-white text-foreground
|
| 340 |
+
dark:bg-zinc-900 dark:text-zinc-100
|
| 341 |
+
px-3 py-2
|
| 342 |
+
shadow-sm
|
| 343 |
+
max-w-full
|
| 344 |
+
"
|
| 345 |
+
title={a.name}
|
| 346 |
+
>
|
| 347 |
+
<img
|
| 348 |
+
src={icon}
|
| 349 |
+
alt={a.name}
|
| 350 |
+
className="h-6 w-6 object-contain opacity-90"
|
| 351 |
+
draggable={false}
|
| 352 |
+
/>
|
| 353 |
+
<div className="min-w-0 leading-tight">
|
| 354 |
+
<div className="text-sm font-medium truncate max-w-[320px]">
|
| 355 |
+
{a.name}
|
| 356 |
+
</div>
|
| 357 |
+
<div className="text-[11px] text-muted-foreground mt-0.5">
|
| 358 |
+
{label}
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
);
|
| 363 |
+
})}
|
| 364 |
+
</div>
|
| 365 |
+
)}
|
| 366 |
+
|
| 367 |
+
{renderBubbleContent()}
|
| 368 |
+
|
| 369 |
+
{/* ✅ Restore "left-bottom small reference box" */}
|
| 370 |
+
{!isUser && hasRefs && (
|
| 371 |
+
<div className="mt-3">
|
| 372 |
+
<Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
|
| 373 |
+
<CollapsibleTrigger asChild>
|
| 374 |
+
<button
|
| 375 |
+
type="button"
|
| 376 |
+
className="
|
| 377 |
+
inline-flex items-center gap-1
|
| 378 |
+
rounded-md border border-border
|
| 379 |
+
bg-background/80 dark:bg-background/30
|
| 380 |
+
px-2 py-1
|
| 381 |
+
text-xs text-foreground
|
| 382 |
+
shadow-sm
|
| 383 |
+
hover:bg-background
|
| 384 |
+
transition
|
| 385 |
+
"
|
| 386 |
+
title="References"
|
| 387 |
+
>
|
| 388 |
+
{referencesOpen ? (
|
| 389 |
+
<ChevronUp className="h-3 w-3" />
|
| 390 |
+
) : (
|
| 391 |
+
<ChevronDown className="h-3 w-3" />
|
| 392 |
+
)}
|
| 393 |
+
<span className="font-medium">References</span>
|
| 394 |
+
<span className="opacity-70">({message.references!.length})</span>
|
| 395 |
+
</button>
|
| 396 |
+
</CollapsibleTrigger>
|
| 397 |
+
|
| 398 |
+
<CollapsibleContent className="mt-2 space-y-1">
|
| 399 |
+
{message.references!.map((ref, index) => (
|
| 400 |
+
<div
|
| 401 |
+
key={index}
|
| 402 |
+
className="rounded-md border border-border bg-background/60 dark:bg-background/20 px-2 py-1 text-xs"
|
| 403 |
+
>
|
| 404 |
+
{ref}
|
| 405 |
+
</div>
|
| 406 |
+
))}
|
| 407 |
+
</CollapsibleContent>
|
| 408 |
+
</Collapsible>
|
| 409 |
+
</div>
|
| 410 |
+
)}
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
{/* Next Question Button for Quiz Mode */}
|
| 414 |
+
{!isUser &&
|
| 415 |
+
showNextButton &&
|
| 416 |
+
!nextButtonClicked &&
|
| 417 |
+
chatMode === "quiz" &&
|
| 418 |
+
onNextQuestion && (
|
| 419 |
+
<div className="mt-2">
|
| 420 |
+
<Button
|
| 421 |
+
onClick={() => {
|
| 422 |
+
setNextButtonClicked(true);
|
| 423 |
+
onNextQuestion();
|
| 424 |
+
}}
|
| 425 |
+
className="bg-primary hover:bg-primary/90"
|
| 426 |
+
>
|
| 427 |
+
Next Question
|
| 428 |
+
</Button>
|
| 429 |
+
</div>
|
| 430 |
+
)}
|
| 431 |
+
|
| 432 |
+
{/* Message Actions */}
|
| 433 |
+
{shouldShowActions && (
|
| 434 |
+
<div className="flex items-center gap-1">
|
| 435 |
+
<Button
|
| 436 |
+
variant="ghost"
|
| 437 |
+
size="icon"
|
| 438 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 439 |
+
copied
|
| 440 |
+
? "bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400"
|
| 441 |
+
: ""
|
| 442 |
+
}`}
|
| 443 |
+
onClick={handleCopy}
|
| 444 |
+
title="Copy"
|
| 445 |
+
>
|
| 446 |
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
| 447 |
+
</Button>
|
| 448 |
+
|
| 449 |
+
{!isUser && (
|
| 450 |
+
<>
|
| 451 |
+
<Button
|
| 452 |
+
variant="ghost"
|
| 453 |
+
size="icon"
|
| 454 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 455 |
+
feedback === "helpful"
|
| 456 |
+
? "bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400"
|
| 457 |
+
: ""
|
| 458 |
+
}`}
|
| 459 |
+
onClick={() => handleFeedbackClick("helpful")}
|
| 460 |
+
title="Helpful"
|
| 461 |
+
>
|
| 462 |
+
<ThumbsUp className="h-4 w-4" />
|
| 463 |
+
</Button>
|
| 464 |
+
<Button
|
| 465 |
+
variant="ghost"
|
| 466 |
+
size="icon"
|
| 467 |
+
className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
|
| 468 |
+
feedback === "not-helpful"
|
| 469 |
+
? "bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
| 470 |
+
: ""
|
| 471 |
+
}`}
|
| 472 |
+
onClick={() => handleFeedbackClick("not-helpful")}
|
| 473 |
+
title="Not helpful"
|
| 474 |
+
>
|
| 475 |
+
<ThumbsDown className="h-4 w-4" />
|
| 476 |
+
</Button>
|
| 477 |
+
</>
|
| 478 |
+
)}
|
| 479 |
+
</div>
|
| 480 |
+
)}
|
| 481 |
+
|
| 482 |
+
{/* Feedback Area */}
|
| 483 |
+
{!isUser && showFeedbackArea && feedbackType && (
|
| 484 |
+
<div className="w-full mt-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
| 485 |
+
<div className="flex items-start justify-between mb-4">
|
| 486 |
+
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
| 487 |
+
Tell us more:
|
| 488 |
+
</h4>
|
| 489 |
+
<Button
|
| 490 |
+
variant="ghost"
|
| 491 |
+
size="sm"
|
| 492 |
+
className="h-6 w-6 p-0"
|
| 493 |
+
onClick={handleFeedbackClose}
|
| 494 |
+
>
|
| 495 |
+
<X className="h-4 w-4" />
|
| 496 |
+
</Button>
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
<div className="flex flex-wrap gap-2 mb-4">
|
| 500 |
+
{FEEDBACK_TAGS[feedbackType].map((tag) => (
|
| 501 |
+
<Button
|
| 502 |
+
key={tag}
|
| 503 |
+
variant={selectedTags.includes(tag) ? "default" : "outline"}
|
| 504 |
+
size="sm"
|
| 505 |
+
className="h-7 text-xs"
|
| 506 |
+
onClick={() => handleTagToggle(tag)}
|
| 507 |
+
>
|
| 508 |
+
{tag}
|
| 509 |
+
</Button>
|
| 510 |
+
))}
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
<Textarea
|
| 514 |
+
className="min-h-[60px] mb-4 bg-gray-100/50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600"
|
| 515 |
+
value={feedbackText}
|
| 516 |
+
onChange={(e) => setFeedbackText(e.target.value)}
|
| 517 |
+
placeholder="Additional feedback (optional)..."
|
| 518 |
+
/>
|
| 519 |
+
|
| 520 |
+
<div className="flex justify-end gap-2">
|
| 521 |
+
<Button variant="outline" size="sm" onClick={handleFeedbackClose}>
|
| 522 |
+
Cancel
|
| 523 |
+
</Button>
|
| 524 |
+
<Button
|
| 525 |
+
size="sm"
|
| 526 |
+
onClick={handleFeedbackSubmit}
|
| 527 |
+
disabled={selectedTags.length === 0 && !feedbackText.trim()}
|
| 528 |
+
>
|
| 529 |
+
Submit
|
| 530 |
+
</Button>
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
)}
|
| 534 |
+
</div>
|
| 535 |
+
|
| 536 |
+
{isUser && !showSenderInfo && (
|
| 537 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
|
| 538 |
+
{message.sender ? (
|
| 539 |
+
<img
|
| 540 |
+
src={
|
| 541 |
+
message.sender.avatar ||
|
| 542 |
+
`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
|
| 543 |
+
message.sender.email || message.sender.name
|
| 544 |
+
)}`
|
| 545 |
+
}
|
| 546 |
+
alt={message.sender.name}
|
| 547 |
+
className="w-full h-full object-cover"
|
| 548 |
+
/>
|
| 549 |
+
) : (
|
| 550 |
+
<span className="text-base">👤</span>
|
| 551 |
+
)}
|
| 552 |
+
</div>
|
| 553 |
+
)}
|
| 554 |
+
</div>
|
| 555 |
+
);
|
| 556 |
+
}
|
web/src/components/Onboarding.tsx
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useState, useEffect, useMemo } from "react";
|
| 2 |
+
import { Button } from "./ui/button";
|
| 3 |
+
import { Input } from "./ui/input";
|
| 4 |
+
import { Label } from "./ui/label";
|
| 5 |
+
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
| 6 |
+
import type { User as UserType } from "../App";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
| 9 |
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
| 10 |
+
import { Textarea } from "./ui/textarea";
|
| 11 |
+
|
| 12 |
+
// ✅ Add Bio step. Total steps: 5
|
| 13 |
+
const TOTAL_STEPS = 5;
|
| 14 |
+
|
| 15 |
+
type InitQ = {
|
| 16 |
+
id: string;
|
| 17 |
+
title: string;
|
| 18 |
+
placeholder?: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const INIT_QUESTIONS: InitQ[] = [
|
| 22 |
+
{
|
| 23 |
+
id: "course_goal",
|
| 24 |
+
title: "What’s the single most important outcome you want from this course?",
|
| 25 |
+
placeholder: "e.g., understand LLM basics, build a project, prep for an exam, apply to work…",
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
id: "background",
|
| 29 |
+
title: "What’s your current background (major, job, or anything relevant)?",
|
| 30 |
+
placeholder: "One sentence is totally fine.",
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
id: "ai_experience",
|
| 34 |
+
title: "Have you worked with AI/LLMs before? If yes, at what level?",
|
| 35 |
+
placeholder: "e.g., none / used ChatGPT / built small projects / research…",
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
id: "python_level",
|
| 39 |
+
title: "How comfortable are you with Python? (Beginner / Intermediate / Advanced)",
|
| 40 |
+
placeholder: "Type one: Beginner / Intermediate / Advanced",
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: "preferred_format",
|
| 44 |
+
title: "What helps you learn best? (You can list multiple, separated by commas)",
|
| 45 |
+
placeholder: "Step-by-step, examples, visuals, concise answers, Socratic questions…",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
id: "pace",
|
| 49 |
+
title: "What pace do you prefer from me? (Fast / Steady / Very detailed)",
|
| 50 |
+
placeholder: "Type one: Fast / Steady / Very detailed",
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
id: "biggest_pain",
|
| 54 |
+
title: "Where do you typically get stuck when learning technical topics?",
|
| 55 |
+
placeholder: "Concepts, tools, task breakdown, math, confidence, time management…",
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
id: "support_pref",
|
| 59 |
+
title: "When you’re unsure, how should I support you?",
|
| 60 |
+
placeholder: "Hints first / guided questions / direct answer / ask then answer…",
|
| 61 |
+
},
|
| 62 |
+
];
|
| 63 |
+
|
| 64 |
+
interface OnboardingProps {
|
| 65 |
+
user: UserType;
|
| 66 |
+
onComplete: (user: UserType) => void;
|
| 67 |
+
onSkip: () => void;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function Onboarding({ user, onComplete, onSkip }: OnboardingProps) {
|
| 71 |
+
const [currentStep, setCurrentStep] = useState(1);
|
| 72 |
+
|
| 73 |
+
// Step 1: Basic
|
| 74 |
+
const [name, setName] = useState(user.name ?? "");
|
| 75 |
+
const [email, setEmail] = useState(user.email ?? "");
|
| 76 |
+
|
| 77 |
+
// Step 2: Academic
|
| 78 |
+
const [studentId, setStudentId] = useState(user.studentId ?? "");
|
| 79 |
+
const [department, setDepartment] = useState(user.department ?? "");
|
| 80 |
+
const [yearLevel, setYearLevel] = useState(user.yearLevel ?? "");
|
| 81 |
+
const [major, setMajor] = useState(user.major ?? "");
|
| 82 |
+
|
| 83 |
+
// Step 3: Preferences
|
| 84 |
+
const [learningStyle, setLearningStyle] = useState(user.learningStyle ?? "visual");
|
| 85 |
+
const [learningPace, setLearningPace] = useState(user.learningPace ?? "moderate");
|
| 86 |
+
|
| 87 |
+
// Step 4: Bio (8 questions -> generate bio)
|
| 88 |
+
const [bioQIndex, setBioQIndex] = useState(0);
|
| 89 |
+
const [bioInput, setBioInput] = useState("");
|
| 90 |
+
const [bioAnswers, setBioAnswers] = useState<Record<string, string>>({});
|
| 91 |
+
const [bioSubmitting, setBioSubmitting] = useState(false);
|
| 92 |
+
const [generatedBio, setGeneratedBio] = useState<string>(user.bio ?? "");
|
| 93 |
+
const [bioReady, setBioReady] = useState<boolean>(!!(user.bio && user.bio.trim().length > 0));
|
| 94 |
+
|
| 95 |
+
const currentBioQ = useMemo(() => INIT_QUESTIONS[bioQIndex], [bioQIndex]);
|
| 96 |
+
|
| 97 |
+
// Optional: if user already has bio, mark ready.
|
| 98 |
+
useEffect(() => {
|
| 99 |
+
if (user.bio && user.bio.trim().length > 0) {
|
| 100 |
+
setGeneratedBio(user.bio);
|
| 101 |
+
setBioReady(true);
|
| 102 |
+
}
|
| 103 |
+
}, [user.bio]);
|
| 104 |
+
|
| 105 |
+
// Step 5: Photo
|
| 106 |
+
const [photoPreview, setPhotoPreview] = useState<string | null>(user.avatarUrl ?? null);
|
| 107 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 108 |
+
|
| 109 |
+
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 110 |
+
const file = e.target.files?.[0];
|
| 111 |
+
if (!file) return;
|
| 112 |
+
|
| 113 |
+
if (!file.type.startsWith("image/")) {
|
| 114 |
+
toast.error("Please select an image file");
|
| 115 |
+
return;
|
| 116 |
+
}
|
| 117 |
+
if (file.size > 2 * 1024 * 1024) {
|
| 118 |
+
toast.error("File size must be less than 2MB");
|
| 119 |
+
return;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const reader = new FileReader();
|
| 123 |
+
reader.onload = (ev) => setPhotoPreview(ev.target?.result as string);
|
| 124 |
+
reader.readAsDataURL(file);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const handleChangePhotoClick = () => fileInputRef.current?.click();
|
| 128 |
+
|
| 129 |
+
const handlePrevious = () => {
|
| 130 |
+
if (currentStep > 1) setCurrentStep((s) => s - 1);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const handleSkip = () => onSkip();
|
| 134 |
+
|
| 135 |
+
// --------------------------
|
| 136 |
+
// Step 4: Bio generation flow
|
| 137 |
+
// --------------------------
|
| 138 |
+
const handleBioNext = async () => {
|
| 139 |
+
const v = bioInput.trim();
|
| 140 |
+
if (!v) return;
|
| 141 |
+
|
| 142 |
+
const q = INIT_QUESTIONS[bioQIndex];
|
| 143 |
+
const nextAnswers = { ...bioAnswers, [q.id]: v };
|
| 144 |
+
|
| 145 |
+
setBioAnswers(nextAnswers);
|
| 146 |
+
setBioInput("");
|
| 147 |
+
|
| 148 |
+
const nextIndex = bioQIndex + 1;
|
| 149 |
+
|
| 150 |
+
// Continue questions
|
| 151 |
+
if (nextIndex < INIT_QUESTIONS.length) {
|
| 152 |
+
setBioQIndex(nextIndex);
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// Last question -> submit to backend and generate bio
|
| 157 |
+
// NOTE: use same backend logic as before; we do NOT touch parsing/storage logic.
|
| 158 |
+
setBioSubmitting(true);
|
| 159 |
+
try {
|
| 160 |
+
const r = await fetch("/api/profile/init_submit", {
|
| 161 |
+
method: "POST",
|
| 162 |
+
headers: { "Content-Type": "application/json" },
|
| 163 |
+
body: JSON.stringify({
|
| 164 |
+
user_id: email.trim() || user.email, // prefer current email input
|
| 165 |
+
answers: nextAnswers,
|
| 166 |
+
language_preference: "English",
|
| 167 |
+
}),
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
if (!r.ok) throw new Error("init_submit failed");
|
| 171 |
+
const j = await r.json();
|
| 172 |
+
|
| 173 |
+
const bio = (j?.bio || "").toString();
|
| 174 |
+
if (!bio.trim()) {
|
| 175 |
+
throw new Error("empty bio");
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
setGeneratedBio(bio);
|
| 179 |
+
setBioReady(true);
|
| 180 |
+
|
| 181 |
+
toast.success("Bio generated!");
|
| 182 |
+
} catch (e) {
|
| 183 |
+
toast.error("Failed to generate bio. Please try again.");
|
| 184 |
+
// allow retry: keep last answer stored; user can edit generated flow by resetting if needed
|
| 185 |
+
} finally {
|
| 186 |
+
setBioSubmitting(false);
|
| 187 |
+
}
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
const handleBioReset = () => {
|
| 191 |
+
setBioQIndex(0);
|
| 192 |
+
setBioInput("");
|
| 193 |
+
setBioAnswers({});
|
| 194 |
+
setBioSubmitting(false);
|
| 195 |
+
setGeneratedBio("");
|
| 196 |
+
setBioReady(false);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
// Main Next handler (respects Step 4 gating)
|
| 200 |
+
const handleNext = async () => {
|
| 201 |
+
// Step 1 validation (kept)
|
| 202 |
+
if (currentStep === 1) {
|
| 203 |
+
if (!name.trim() || !email.trim()) {
|
| 204 |
+
toast.error("Please fill in all required fields");
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Step 4 gating: must finish + have bioReady before moving on
|
| 210 |
+
if (currentStep === 4) {
|
| 211 |
+
if (!bioReady) {
|
| 212 |
+
// If still answering questions, Next acts as “Next question”
|
| 213 |
+
if (bioQIndex < INIT_QUESTIONS.length) {
|
| 214 |
+
await handleBioNext();
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
// Safety: should not happen, but block
|
| 218 |
+
toast.error("Please finish the Bio questions first.");
|
| 219 |
+
return;
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (currentStep < TOTAL_STEPS) setCurrentStep((s) => s + 1);
|
| 224 |
+
else handleComplete();
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
const handleComplete = () => {
|
| 228 |
+
if (!name.trim() || !email.trim()) {
|
| 229 |
+
toast.error("Please fill in all required fields");
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// ✅ Bio now comes from Onboarding Step 4
|
| 234 |
+
const finalBio = (generatedBio || user.bio || "").trim() || undefined;
|
| 235 |
+
|
| 236 |
+
const next: UserType = {
|
| 237 |
+
...user,
|
| 238 |
+
name: name.trim(),
|
| 239 |
+
email: email.trim(),
|
| 240 |
+
|
| 241 |
+
studentId: studentId.trim() || undefined,
|
| 242 |
+
department: department.trim() || undefined,
|
| 243 |
+
yearLevel: yearLevel || undefined,
|
| 244 |
+
major: major.trim() || undefined,
|
| 245 |
+
|
| 246 |
+
learningStyle: learningStyle || undefined,
|
| 247 |
+
learningPace: learningPace || undefined,
|
| 248 |
+
|
| 249 |
+
avatarUrl: photoPreview || undefined,
|
| 250 |
+
|
| 251 |
+
bio: finalBio, // ✅ sync to profile via your existing onComplete->save logic
|
| 252 |
+
onboardingCompleted: true,
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
onComplete(next);
|
| 256 |
+
toast.success("Profile setup completed!");
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
const renderStepContent = () => {
|
| 260 |
+
switch (currentStep) {
|
| 261 |
+
case 1:
|
| 262 |
+
return (
|
| 263 |
+
<div className="space-y-4">
|
| 264 |
+
<h3 className="text-lg font-medium">Basic Information</h3>
|
| 265 |
+
<p className="text-sm text-muted-foreground">Let's start with your basic information</p>
|
| 266 |
+
|
| 267 |
+
<div className="space-y-4">
|
| 268 |
+
<div className="space-y-2">
|
| 269 |
+
<Label htmlFor="onboarding-name">Full Name *</Label>
|
| 270 |
+
<Input
|
| 271 |
+
id="onboarding-name"
|
| 272 |
+
value={name}
|
| 273 |
+
onChange={(e) => setName(e.target.value)}
|
| 274 |
+
placeholder="Enter your full name"
|
| 275 |
+
/>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div className="space-y-2">
|
| 279 |
+
<Label htmlFor="onboarding-email">Email *</Label>
|
| 280 |
+
<Input
|
| 281 |
+
id="onboarding-email"
|
| 282 |
+
type="email"
|
| 283 |
+
value={email}
|
| 284 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 285 |
+
placeholder="Enter your email"
|
| 286 |
+
/>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
);
|
| 291 |
+
|
| 292 |
+
case 2:
|
| 293 |
+
return (
|
| 294 |
+
<div className="space-y-4">
|
| 295 |
+
<h3 className="text-lg font-medium">Academic Background</h3>
|
| 296 |
+
<p className="text-sm text-muted-foreground">Tell us about your academic information</p>
|
| 297 |
+
|
| 298 |
+
<div className="space-y-4">
|
| 299 |
+
<div className="space-y-2">
|
| 300 |
+
<Label htmlFor="onboarding-student-id">Student ID</Label>
|
| 301 |
+
<Input
|
| 302 |
+
id="onboarding-student-id"
|
| 303 |
+
value={studentId}
|
| 304 |
+
onChange={(e) => setStudentId(e.target.value)}
|
| 305 |
+
placeholder="Enter your student ID"
|
| 306 |
+
/>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div className="space-y-2">
|
| 310 |
+
<Label htmlFor="onboarding-department">Department</Label>
|
| 311 |
+
<Input
|
| 312 |
+
id="onboarding-department"
|
| 313 |
+
value={department}
|
| 314 |
+
onChange={(e) => setDepartment(e.target.value)}
|
| 315 |
+
placeholder="Enter your department"
|
| 316 |
+
/>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 320 |
+
<div className="space-y-2">
|
| 321 |
+
<Label htmlFor="onboarding-year">Year Level</Label>
|
| 322 |
+
<Select value={yearLevel} onValueChange={setYearLevel}>
|
| 323 |
+
<SelectTrigger id="onboarding-year">
|
| 324 |
+
<SelectValue placeholder="Select year level" />
|
| 325 |
+
</SelectTrigger>
|
| 326 |
+
<SelectContent>
|
| 327 |
+
<SelectItem value="1st Year">1st Year</SelectItem>
|
| 328 |
+
<SelectItem value="2nd Year">2nd Year</SelectItem>
|
| 329 |
+
<SelectItem value="3rd Year">3rd Year</SelectItem>
|
| 330 |
+
<SelectItem value="4th Year">4th Year</SelectItem>
|
| 331 |
+
<SelectItem value="Graduate">Graduate</SelectItem>
|
| 332 |
+
</SelectContent>
|
| 333 |
+
</Select>
|
| 334 |
+
</div>
|
| 335 |
+
|
| 336 |
+
<div className="space-y-2">
|
| 337 |
+
<Label htmlFor="onboarding-major">Major</Label>
|
| 338 |
+
<Input
|
| 339 |
+
id="onboarding-major"
|
| 340 |
+
value={major}
|
| 341 |
+
onChange={(e) => setMajor(e.target.value)}
|
| 342 |
+
placeholder="Enter your major"
|
| 343 |
+
/>
|
| 344 |
+
</div>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
);
|
| 349 |
+
|
| 350 |
+
case 3:
|
| 351 |
+
return (
|
| 352 |
+
<div className="space-y-4">
|
| 353 |
+
<h3 className="text-lg font-medium">Learning Preferences</h3>
|
| 354 |
+
<p className="text-sm text-muted-foreground">Help us personalize your learning experience</p>
|
| 355 |
+
|
| 356 |
+
<div className="space-y-4">
|
| 357 |
+
<div className="space-y-2">
|
| 358 |
+
<Label htmlFor="onboarding-learning-style">Preferred Learning Style</Label>
|
| 359 |
+
<Select value={learningStyle} onValueChange={setLearningStyle}>
|
| 360 |
+
<SelectTrigger id="onboarding-learning-style">
|
| 361 |
+
<SelectValue />
|
| 362 |
+
</SelectTrigger>
|
| 363 |
+
<SelectContent>
|
| 364 |
+
<SelectItem value="visual">Visual</SelectItem>
|
| 365 |
+
<SelectItem value="auditory">Auditory</SelectItem>
|
| 366 |
+
<SelectItem value="reading">Reading/Writing</SelectItem>
|
| 367 |
+
<SelectItem value="kinesthetic">Kinesthetic</SelectItem>
|
| 368 |
+
</SelectContent>
|
| 369 |
+
</Select>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<div className="space-y-2">
|
| 373 |
+
<Label htmlFor="onboarding-pace">Learning Pace</Label>
|
| 374 |
+
<Select value={learningPace} onValueChange={setLearningPace}>
|
| 375 |
+
<SelectTrigger id="onboarding-pace">
|
| 376 |
+
<SelectValue />
|
| 377 |
+
</SelectTrigger>
|
| 378 |
+
<SelectContent>
|
| 379 |
+
<SelectItem value="slow">Slow & Steady</SelectItem>
|
| 380 |
+
<SelectItem value="moderate">Moderate</SelectItem>
|
| 381 |
+
<SelectItem value="fast">Fast-paced</SelectItem>
|
| 382 |
+
</SelectContent>
|
| 383 |
+
</Select>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
);
|
| 388 |
+
|
| 389 |
+
case 4:
|
| 390 |
+
return (
|
| 391 |
+
<div className="space-y-4">
|
| 392 |
+
<h3 className="text-lg font-medium">Profile Bio</h3>
|
| 393 |
+
<p className="text-sm text-muted-foreground">
|
| 394 |
+
Answer a few quick questions and we’ll generate a Bio that syncs to your profile.
|
| 395 |
+
</p>
|
| 396 |
+
|
| 397 |
+
{!bioReady ? (
|
| 398 |
+
<div className="space-y-3">
|
| 399 |
+
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-2">
|
| 400 |
+
<div className="text-sm font-medium">{currentBioQ.title}</div>
|
| 401 |
+
{currentBioQ.placeholder && (
|
| 402 |
+
<div className="text-xs text-muted-foreground">{currentBioQ.placeholder}</div>
|
| 403 |
+
)}
|
| 404 |
+
<div className="text-xs text-muted-foreground">
|
| 405 |
+
Question {bioQIndex + 1} of {INIT_QUESTIONS.length}
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<Textarea
|
| 409 |
+
value={bioInput}
|
| 410 |
+
onChange={(e) => setBioInput(e.target.value)}
|
| 411 |
+
placeholder="Type your answer here..."
|
| 412 |
+
className="min-h-[96px] mt-2"
|
| 413 |
+
disabled={bioSubmitting}
|
| 414 |
+
onKeyDown={(e) => {
|
| 415 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 416 |
+
e.preventDefault();
|
| 417 |
+
handleBioNext();
|
| 418 |
+
}
|
| 419 |
+
}}
|
| 420 |
+
/>
|
| 421 |
+
|
| 422 |
+
<div className="flex items-center justify-between pt-2">
|
| 423 |
+
<Button variant="outline" onClick={handleBioReset} disabled={bioSubmitting}>
|
| 424 |
+
Reset
|
| 425 |
+
</Button>
|
| 426 |
+
|
| 427 |
+
<Button onClick={handleBioNext} disabled={bioSubmitting || !bioInput.trim()}>
|
| 428 |
+
{bioQIndex === INIT_QUESTIONS.length - 1
|
| 429 |
+
? bioSubmitting
|
| 430 |
+
? "Generating…"
|
| 431 |
+
: "Generate Bio"
|
| 432 |
+
: "Next Question"}
|
| 433 |
+
</Button>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
|
| 437 |
+
<div className="text-xs text-muted-foreground">
|
| 438 |
+
Tip: Press Enter to go next (Shift+Enter for a new line).
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
) : (
|
| 442 |
+
<div className="space-y-3">
|
| 443 |
+
<div className="rounded-lg border border-border bg-background p-4 space-y-2">
|
| 444 |
+
<div className="text-sm font-medium">Generated Bio</div>
|
| 445 |
+
<div className="text-xs text-muted-foreground">
|
| 446 |
+
You can edit it before continuing. This will be saved to your profile.
|
| 447 |
+
</div>
|
| 448 |
+
|
| 449 |
+
<Textarea
|
| 450 |
+
value={generatedBio}
|
| 451 |
+
onChange={(e) => setGeneratedBio(e.target.value)}
|
| 452 |
+
className="min-h-[140px] mt-2"
|
| 453 |
+
/>
|
| 454 |
+
|
| 455 |
+
<div className="flex items-center justify-between pt-2">
|
| 456 |
+
<Button variant="outline" onClick={handleBioReset}>
|
| 457 |
+
Regenerate
|
| 458 |
+
</Button>
|
| 459 |
+
<div className="text-xs text-muted-foreground">
|
| 460 |
+
Click “Next Step” to continue.
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
</div>
|
| 467 |
+
);
|
| 468 |
+
|
| 469 |
+
case 5:
|
| 470 |
+
return (
|
| 471 |
+
<div className="space-y-4">
|
| 472 |
+
<h3 className="text-lg font-medium">Profile Picture</h3>
|
| 473 |
+
<p className="text-sm text-muted-foreground">Upload a photo to personalize your profile (optional)</p>
|
| 474 |
+
|
| 475 |
+
<div className="flex items-center gap-4">
|
| 476 |
+
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white text-3xl overflow-hidden">
|
| 477 |
+
{photoPreview ? (
|
| 478 |
+
<img src={photoPreview} alt="Profile" className="w-full h-full object-cover" />
|
| 479 |
+
) : (
|
| 480 |
+
(name?.charAt(0) || "U").toUpperCase()
|
| 481 |
+
)}
|
| 482 |
+
</div>
|
| 483 |
+
|
| 484 |
+
<div>
|
| 485 |
+
<input
|
| 486 |
+
ref={fileInputRef}
|
| 487 |
+
type="file"
|
| 488 |
+
accept="image/jpeg,image/png,image/gif,image/webp"
|
| 489 |
+
onChange={handlePhotoSelect}
|
| 490 |
+
className="hidden"
|
| 491 |
+
/>
|
| 492 |
+
<Button variant="outline" size="sm" onClick={handleChangePhotoClick}>
|
| 493 |
+
Change Photo
|
| 494 |
+
</Button>
|
| 495 |
+
<p className="text-xs text-muted-foreground mt-1">JPG, PNG or GIF. Max size 2MB</p>
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
);
|
| 500 |
+
|
| 501 |
+
default:
|
| 502 |
+
return null;
|
| 503 |
+
}
|
| 504 |
+
};
|
| 505 |
+
|
| 506 |
+
return (
|
| 507 |
+
<Dialog
|
| 508 |
+
open
|
| 509 |
+
onOpenChange={(open) => {
|
| 510 |
+
if (!open) onSkip();
|
| 511 |
+
}}
|
| 512 |
+
>
|
| 513 |
+
<DialogContent
|
| 514 |
+
className="sm:max-w-lg p-0 gap-0 max-h-[90vh] overflow-hidden"
|
| 515 |
+
style={{ zIndex: 1001 }}
|
| 516 |
+
overlayClassName="!inset-0 !z-[99]"
|
| 517 |
+
overlayStyle={{ top: 0, left: 0, right: 0, bottom: 0, zIndex: 99, position: "fixed" }}
|
| 518 |
+
>
|
| 519 |
+
<div className="flex flex-col max-h-[90vh]">
|
| 520 |
+
{/* Header */}
|
| 521 |
+
<div className="border-b border-border p-4 flex items-center justify-between flex-shrink-0">
|
| 522 |
+
<div className="flex-1">
|
| 523 |
+
<DialogTitle className="text-xl font-medium">Welcome! Let's set up your profile</DialogTitle>
|
| 524 |
+
<p className="text-sm text-muted-foreground mt-1">
|
| 525 |
+
Step {currentStep} of {TOTAL_STEPS}
|
| 526 |
+
</p>
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
+
{/* Progress indicator */}
|
| 530 |
+
<div className="flex gap-1">
|
| 531 |
+
{Array.from({ length: TOTAL_STEPS }).map((_, index) => (
|
| 532 |
+
<div
|
| 533 |
+
key={index}
|
| 534 |
+
className={`h-2 w-2 rounded-full transition-colors ${
|
| 535 |
+
index + 1 <= currentStep ? "bg-primary" : "bg-muted"
|
| 536 |
+
}`}
|
| 537 |
+
/>
|
| 538 |
+
))}
|
| 539 |
+
</div>
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
{/* Content */}
|
| 543 |
+
<div className="p-6 overflow-y-auto flex-1">{renderStepContent()}</div>
|
| 544 |
+
|
| 545 |
+
{/* Footer */}
|
| 546 |
+
<div className="border-t border-border p-4 flex justify-between gap-2 flex-shrink-0">
|
| 547 |
+
<div className="flex gap-2">
|
| 548 |
+
{currentStep > 1 && (
|
| 549 |
+
<Button variant="outline" onClick={handlePrevious} disabled={bioSubmitting}>
|
| 550 |
+
<ChevronLeft className="h-4 w-4 mr-1" />
|
| 551 |
+
Previous
|
| 552 |
+
</Button>
|
| 553 |
+
)}
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
<div className="flex gap-2">
|
| 557 |
+
<Button variant="outline" onClick={handleSkip} disabled={bioSubmitting}>
|
| 558 |
+
Skip all
|
| 559 |
+
</Button>
|
| 560 |
+
|
| 561 |
+
<Button onClick={handleNext} disabled={bioSubmitting}>
|
| 562 |
+
{currentStep === TOTAL_STEPS ? "Complete" : "Next Step"}
|
| 563 |
+
{currentStep < TOTAL_STEPS && <ChevronRight className="h-4 w-4 ml-1" />}
|
| 564 |
+
</Button>
|
| 565 |
+
</div>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
</DialogContent>
|
| 569 |
+
</Dialog>
|
| 570 |
+
);
|
| 571 |
+
}
|
web/src/components/ProfileEditor.tsx
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Button } from "./ui/button";
|
| 3 |
+
import { Input } from "./ui/input";
|
| 4 |
+
import { Label } from "./ui/label";
|
| 5 |
+
import { Textarea } from "./ui/textarea";
|
| 6 |
+
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
| 7 |
+
import type { User as UserType } from "../App";
|
| 8 |
+
import { toast } from "sonner";
|
| 9 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
| 10 |
+
|
| 11 |
+
interface ProfileEditorProps {
|
| 12 |
+
user: UserType;
|
| 13 |
+
onSave: (user: UserType) => void;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function ProfileEditor({ user, onSave, onClose }: ProfileEditorProps) {
|
| 18 |
+
const [name, setName] = useState(user.name ?? "");
|
| 19 |
+
const [email, setEmail] = useState(user.email ?? "");
|
| 20 |
+
|
| 21 |
+
const [studentId, setStudentId] = useState(user.studentId ?? "");
|
| 22 |
+
const [department, setDepartment] = useState(user.department ?? "");
|
| 23 |
+
const [yearLevel, setYearLevel] = useState(user.yearLevel ?? "");
|
| 24 |
+
const [major, setMajor] = useState(user.major ?? "");
|
| 25 |
+
|
| 26 |
+
// ✅ bio is editable here (user can override Clare-generated bio)
|
| 27 |
+
const [bio, setBio] = useState(user.bio ?? "");
|
| 28 |
+
|
| 29 |
+
const [learningStyle, setLearningStyle] = useState(user.learningStyle ?? "visual");
|
| 30 |
+
const [learningPace, setLearningPace] = useState(user.learningPace ?? "moderate");
|
| 31 |
+
|
| 32 |
+
const [photoPreview, setPhotoPreview] = useState<string | null>(user.avatarUrl ?? null);
|
| 33 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 34 |
+
|
| 35 |
+
// ✅ Keep fields in sync if user changes while dialog is open
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
setName(user.name ?? "");
|
| 38 |
+
setEmail(user.email ?? "");
|
| 39 |
+
|
| 40 |
+
setStudentId(user.studentId ?? "");
|
| 41 |
+
setDepartment(user.department ?? "");
|
| 42 |
+
setYearLevel(user.yearLevel ?? "");
|
| 43 |
+
setMajor(user.major ?? "");
|
| 44 |
+
|
| 45 |
+
setBio(user.bio ?? "");
|
| 46 |
+
|
| 47 |
+
setLearningStyle(user.learningStyle ?? "visual");
|
| 48 |
+
setLearningPace(user.learningPace ?? "moderate");
|
| 49 |
+
|
| 50 |
+
setPhotoPreview(user.avatarUrl ?? null);
|
| 51 |
+
}, [
|
| 52 |
+
user.name,
|
| 53 |
+
user.email,
|
| 54 |
+
user.studentId,
|
| 55 |
+
user.department,
|
| 56 |
+
user.yearLevel,
|
| 57 |
+
user.major,
|
| 58 |
+
user.bio,
|
| 59 |
+
user.learningStyle,
|
| 60 |
+
user.learningPace,
|
| 61 |
+
user.avatarUrl,
|
| 62 |
+
]);
|
| 63 |
+
|
| 64 |
+
const handleSave = () => {
|
| 65 |
+
if (!name.trim() || !email.trim()) {
|
| 66 |
+
toast.error("Please fill in all required fields");
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const next: UserType = {
|
| 71 |
+
...user,
|
| 72 |
+
name: name.trim(),
|
| 73 |
+
email: email.trim(),
|
| 74 |
+
|
| 75 |
+
studentId: studentId.trim() || undefined,
|
| 76 |
+
department: department.trim() || undefined,
|
| 77 |
+
yearLevel: yearLevel || undefined,
|
| 78 |
+
major: major.trim() || undefined,
|
| 79 |
+
|
| 80 |
+
// ✅ allow user edit
|
| 81 |
+
bio: (bio ?? "").slice(0, 200),
|
| 82 |
+
|
| 83 |
+
learningStyle: learningStyle || undefined,
|
| 84 |
+
learningPace: learningPace || undefined,
|
| 85 |
+
|
| 86 |
+
avatarUrl: photoPreview || undefined,
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
onSave(next);
|
| 90 |
+
toast.success("Profile updated successfully!");
|
| 91 |
+
onClose();
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 95 |
+
const file = e.target.files?.[0];
|
| 96 |
+
if (!file) return;
|
| 97 |
+
|
| 98 |
+
if (!file.type.startsWith("image/")) {
|
| 99 |
+
toast.error("Please select an image file");
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
if (file.size > 2 * 1024 * 1024) {
|
| 103 |
+
toast.error("File size must be less than 2MB");
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const reader = new FileReader();
|
| 108 |
+
reader.onload = (ev) => {
|
| 109 |
+
setPhotoPreview(ev.target?.result as string);
|
| 110 |
+
toast.success("Photo updated successfully!");
|
| 111 |
+
};
|
| 112 |
+
reader.readAsDataURL(file);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleChangePhotoClick = () => fileInputRef.current?.click();
|
| 116 |
+
|
| 117 |
+
return (
|
| 118 |
+
<Dialog
|
| 119 |
+
open
|
| 120 |
+
onOpenChange={(open) => {
|
| 121 |
+
if (!open) onClose();
|
| 122 |
+
}}
|
| 123 |
+
>
|
| 124 |
+
<DialogContent
|
| 125 |
+
className="sm:max-w-[800px] p-0 gap-0 max-h-[90vh] overflow-hidden"
|
| 126 |
+
style={{ zIndex: 1001, maxWidth: "800px", width: "800px" }}
|
| 127 |
+
overlayClassName="!top-16 !left-0 !right-0 !bottom-0 !z-[99]"
|
| 128 |
+
overlayStyle={{ top: "64px", left: 0, right: 0, bottom: 0, zIndex: 99, position: "fixed" }}
|
| 129 |
+
>
|
| 130 |
+
<div className="flex flex-col max-h-[90vh]">
|
| 131 |
+
{/* Header */}
|
| 132 |
+
<div className="p-4 flex items-center justify-between flex-shrink-0">
|
| 133 |
+
<DialogTitle className="text-xl font-medium">Edit Profile</DialogTitle>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Content */}
|
| 137 |
+
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
| 138 |
+
{/* Profile Picture */}
|
| 139 |
+
<div className="flex items-center gap-4">
|
| 140 |
+
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white text-2xl overflow-hidden">
|
| 141 |
+
{photoPreview ? (
|
| 142 |
+
<img src={photoPreview} alt="Profile" className="w-full h-full object-cover" />
|
| 143 |
+
) : (
|
| 144 |
+
(name?.charAt(0) || "U").toUpperCase()
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
<div>
|
| 148 |
+
<input
|
| 149 |
+
ref={fileInputRef}
|
| 150 |
+
type="file"
|
| 151 |
+
accept="image/jpeg,image/png,image/gif,image/webp"
|
| 152 |
+
onChange={handlePhotoSelect}
|
| 153 |
+
className="hidden"
|
| 154 |
+
/>
|
| 155 |
+
<Button variant="outline" size="sm" onClick={handleChangePhotoClick}>
|
| 156 |
+
Change Photo
|
| 157 |
+
</Button>
|
| 158 |
+
<p className="text-xs text-muted-foreground mt-1">JPG, PNG or GIF. Max size 2MB</p>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Basic Information */}
|
| 163 |
+
<div className="space-y-4">
|
| 164 |
+
<h3 className="text-sm font-medium">Basic Information</h3>
|
| 165 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 166 |
+
<div className="space-y-2">
|
| 167 |
+
<Label htmlFor="edit-name">Full Name *</Label>
|
| 168 |
+
<Input
|
| 169 |
+
id="edit-name"
|
| 170 |
+
value={name}
|
| 171 |
+
onChange={(e) => setName(e.target.value)}
|
| 172 |
+
placeholder="Enter your full name"
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
<div className="space-y-2">
|
| 176 |
+
<Label htmlFor="edit-email">Email *</Label>
|
| 177 |
+
<Input
|
| 178 |
+
id="edit-email"
|
| 179 |
+
type="email"
|
| 180 |
+
value={email}
|
| 181 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 182 |
+
placeholder="Enter your email"
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
<div className="space-y-2">
|
| 186 |
+
<Label htmlFor="edit-student-id">Student ID</Label>
|
| 187 |
+
<Input
|
| 188 |
+
id="edit-student-id"
|
| 189 |
+
value={studentId}
|
| 190 |
+
onChange={(e) => setStudentId(e.target.value)}
|
| 191 |
+
placeholder="Enter your student ID"
|
| 192 |
+
/>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="space-y-2">
|
| 195 |
+
<Label htmlFor="edit-department">Department</Label>
|
| 196 |
+
<Input
|
| 197 |
+
id="edit-department"
|
| 198 |
+
value={department}
|
| 199 |
+
onChange={(e) => setDepartment(e.target.value)}
|
| 200 |
+
placeholder="Enter your department"
|
| 201 |
+
/>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
{/* Academic Background */}
|
| 207 |
+
<div className="space-y-4">
|
| 208 |
+
<h3 className="text-sm font-medium">Academic Background</h3>
|
| 209 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 210 |
+
<div className="space-y-2">
|
| 211 |
+
<Label htmlFor="edit-year">Year Level</Label>
|
| 212 |
+
<Select value={yearLevel} onValueChange={setYearLevel}>
|
| 213 |
+
<SelectTrigger id="edit-year">
|
| 214 |
+
<SelectValue placeholder="Select year level" />
|
| 215 |
+
</SelectTrigger>
|
| 216 |
+
<SelectContent>
|
| 217 |
+
<SelectItem value="1st Year">1st Year</SelectItem>
|
| 218 |
+
<SelectItem value="2nd Year">2nd Year</SelectItem>
|
| 219 |
+
<SelectItem value="3rd Year">3rd Year</SelectItem>
|
| 220 |
+
<SelectItem value="4th Year">4th Year</SelectItem>
|
| 221 |
+
<SelectItem value="Graduate">Graduate</SelectItem>
|
| 222 |
+
</SelectContent>
|
| 223 |
+
</Select>
|
| 224 |
+
</div>
|
| 225 |
+
<div className="space-y-2">
|
| 226 |
+
<Label htmlFor="edit-major">Major</Label>
|
| 227 |
+
<Input
|
| 228 |
+
id="edit-major"
|
| 229 |
+
value={major}
|
| 230 |
+
onChange={(e) => setMajor(e.target.value)}
|
| 231 |
+
placeholder="Enter your major"
|
| 232 |
+
/>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Bio (Editable) */}
|
| 238 |
+
<div className="space-y-2">
|
| 239 |
+
<Label htmlFor="edit-bio">Bio</Label>
|
| 240 |
+
<Textarea
|
| 241 |
+
id="edit-bio"
|
| 242 |
+
value={bio}
|
| 243 |
+
onChange={(e) => setBio(e.target.value)}
|
| 244 |
+
placeholder="Tell us about yourself..."
|
| 245 |
+
className="min-h-[100px] resize-none"
|
| 246 |
+
maxLength={200}
|
| 247 |
+
/>
|
| 248 |
+
<p className="text-xs text-muted-foreground">Max 200 characters. You can edit this anytime.</p>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Learning Preferences */}
|
| 252 |
+
<div className="space-y-4">
|
| 253 |
+
<h3 className="text-sm font-medium">Learning Preferences</h3>
|
| 254 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 255 |
+
<div className="space-y-2">
|
| 256 |
+
<Label htmlFor="edit-learning-style">Preferred Learning Style</Label>
|
| 257 |
+
<Select value={learningStyle} onValueChange={setLearningStyle}>
|
| 258 |
+
<SelectTrigger id="edit-learning-style">
|
| 259 |
+
<SelectValue />
|
| 260 |
+
</SelectTrigger>
|
| 261 |
+
<SelectContent>
|
| 262 |
+
<SelectItem value="visual">Visual</SelectItem>
|
| 263 |
+
<SelectItem value="auditory">Auditory</SelectItem>
|
| 264 |
+
<SelectItem value="reading">Reading/Writing</SelectItem>
|
| 265 |
+
<SelectItem value="kinesthetic">Kinesthetic</SelectItem>
|
| 266 |
+
</SelectContent>
|
| 267 |
+
</Select>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<div className="space-y-2">
|
| 271 |
+
<Label htmlFor="edit-pace">Learning Pace</Label>
|
| 272 |
+
<Select value={learningPace} onValueChange={setLearningPace}>
|
| 273 |
+
<SelectTrigger id="edit-pace">
|
| 274 |
+
<SelectValue />
|
| 275 |
+
</SelectTrigger>
|
| 276 |
+
<SelectContent>
|
| 277 |
+
<SelectItem value="slow">Slow & Steady</SelectItem>
|
| 278 |
+
<SelectItem value="moderate">Moderate</SelectItem>
|
| 279 |
+
<SelectItem value="fast">Fast-paced</SelectItem>
|
| 280 |
+
</SelectContent>
|
| 281 |
+
</Select>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
{/* Footer */}
|
| 288 |
+
<div className="border-t border-border p-4 flex justify-end gap-2 flex-shrink-0">
|
| 289 |
+
<Button variant="outline" onClick={onClose}>
|
| 290 |
+
Cancel
|
| 291 |
+
</Button>
|
| 292 |
+
<Button onClick={handleSave}>Save Changes</Button>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</DialogContent>
|
| 296 |
+
</Dialog>
|
| 297 |
+
);
|
| 298 |
+
}
|