78 Commits

Author SHA1 Message Date
CakeCN
496c5be90a code-server-like ssh clash 2025-12-13 23:50:45 +08:00
CakeCN
ec6be8233c 增加code-serverlike cdnback 2025-12-13 22:52:16 +08:00
CakeCN
d0331d9361 navbar adjust 2025-12-13 21:47:10 +08:00
CakeCN
8e79380ba4 progress 检查 2025-12-13 18:56:21 +08:00
CakeCN
6acb1db093 direct no load history when no history 2025-12-13 18:36:33 +08:00
CakeCN
af0ebda419 try fix nextchapter bug 2025-12-13 18:16:34 +08:00
CakeCN
ac74c28f64 提示helpTip的标准化 2025-12-12 16:58:27 +08:00
CakeCN
41c17fcde1 help tips 标准化 2025-12-12 16:46:41 +08:00
CakeCN
5789974d2e 修复一些progressbar bug 2025-12-12 16:34:42 +08:00
CakeCN
221f3faace 美化processbar 2025-12-12 16:26:51 +08:00
CakeCN
560c590067 fix chatmanager 2025-12-12 16:15:48 +08:00
CakeCN
02cf3c0f78 优化前端js代码 2025-12-12 16:11:46 +08:00
CakeCN
9a7571f832 增加function approval button 2025-12-12 00:55:16 +08:00
CakeCN
14dd164545 function 提交 2025-12-12 00:47:09 +08:00
CakeCN
ff3d6e5bf9 fix chatmanager no scores bug 2025-12-12 00:29:03 +08:00
CakeCN
97df0f6407 logger /download 2025-12-12 00:25:39 +08:00
CakeCN
58a25b3e07 fix clear session 2025-12-11 22:40:43 +08:00
CakeCN
c4743bcadd restart with clear user_session 2025-12-11 22:37:58 +08:00
CakeCN
f9b3cf53da 不再使用markdown编译html,而是直接markdown源文件,前端进行渲染 2025-12-11 21:46:04 +08:00
CakeCN
fe1784fa20 前后端next_chapter同步 2025-12-11 21:25:23 +08:00
CakeCN
140ece742e log clear session 2025-12-11 20:46:22 +08:00
CakeCN
97cda4031b clear user session fix 2025-12-11 20:23:21 +08:00
CakeCN
9c3ffce1e2 fix load_history always "false" 2025-12-11 20:20:38 +08:00
CakeCN
23bfc73bcf more log 2025-12-11 20:17:38 +08:00
CakeCN
4a5a34b0aa log restart 2025-12-11 20:16:24 +08:00
CakeCN
2f48f1b6b4 load now chapter with restart 2025-12-11 20:00:15 +08:00
CakeCN
b175407d4b 前端对话历史 2025-12-11 19:53:14 +08:00
CakeCN
ce4a9ac2ca onlogin reduce 2025-12-11 17:47:13 +08:00
CakeCN
be78f480a5 roll back 2025-12-11 17:38:28 +08:00
CakeCN
f804eac811 reduce onlogin 2025-12-11 17:33:36 +08:00
CakeCN
77cd53c241 调整onlogin 代码量 2025-12-11 17:28:20 +08:00
CakeCN
7300b304e3 修复bug 2025-12-11 16:19:59 +08:00
CakeCN
7aa7bf1690 log receive ack 2025-12-11 15:42:45 +08:00
CakeCN
84a4a578a8 add some logger 2025-12-11 15:32:41 +08:00
CakeCN
5df2931fa1 fix logger 2025-12-11 15:23:50 +08:00
CakeCN
99758ce1f8 add app context log 2025-12-11 15:10:02 +08:00
CakeCN
ae91b62332 logger time 2025-12-11 15:02:33 +08:00
CakeCN
1bf13aacf6 little change 2025-12-11 14:04:58 +08:00
CakeCN
9dfa0c9738 speed up event wait 2025-12-10 22:47:39 +08:00
CakeCN
f330916ee0 Merge branch 'main' into test 2025-12-10 22:36:00 +08:00
CakeCN
1d4e625b8b reduce some log 2025-12-10 22:35:41 +08:00
CakeCN
dba7914e5d logger reduce 2025-12-10 19:22:13 +08:00
CakeCN
889399b8aa docs 2025-12-10 19:20:17 +08:00
CakeCN
6cc5a23874 upload ['multiagents_dialogs'] fix 2025-12-10 16:47:22 +08:00
CakeCN
38d65bac3d log 2025-12-10 16:31:22 +08:00
CakeCN
abf6f3aa2f 序列化 2025-12-10 15:10:37 +08:00
CakeCN
36662654ba add namespace logger 2025-12-10 14:52:33 +08:00
CakeCN
4d5a8bb23f learn progress 2025-12-09 22:56:51 +08:00
CakeCN
15cbeb93ee nouser require role 2025-12-09 22:08:58 +08:00
CakeCN
ed6075a89c 用user_id save 2025-12-09 22:03:55 +08:00
CakeCN
9b131dfc94 fix 2025-12-09 16:47:14 +08:00
CakeCN
67552dbca9 fix 2025-12-09 16:44:54 +08:00
CakeCN
ec2d1234b2 fix 2025-12-09 16:40:10 +08:00
CakeCN
7159371128 fix 2025-12-09 16:39:01 +08:00
CakeCN
3b4bebba6c aseclient fix 2025-12-09 16:38:18 +08:00
CakeCN
c3ca3f83f6 fix ase bug 2025-12-09 16:24:00 +08:00
CakeCN
d222358ded aseclient initdata 2025-12-09 16:21:14 +08:00
CakeCN
1be048bf06 load history or not 2025-12-09 16:02:11 +08:00
CakeCN
88ae20d596 调整文件格式 2025-12-09 15:47:17 +08:00
CakeCN
c85f8c9539 import fix 2025-12-09 14:29:16 +08:00
CakeCN
f92688cf2c import fix 2025-12-09 14:28:33 +08:00
CakeCN
a509b2efb2 调整course_service 文件长度 2025-12-09 13:25:05 +08:00
CakeCN
c3c76788fe 上传学习进度 2025-12-09 13:16:10 +08:00
CakeCN
c08fb49bcd log his 2025-12-08 23:55:33 +08:00
CakeCN
f8f7a9d104 建立连接OK提示 2025-12-08 13:28:41 +08:00
CakeCN
a210f5bac3 no chapter_dict show 2025-12-08 13:25:46 +08:00
CakeCN
dc9f246841 no app log in client 2025-12-08 12:56:56 +08:00
CakeCN
74f26c72fd url fix 2025-12-08 12:17:14 +08:00
CakeCN
aae10819ca more log 2025-12-08 11:35:09 +08:00
CakeCN
42549bb32a 构建test机制(hsamooc.cn上只跑test) 2025-12-02 22:40:03 +08:00
CakeCN
93cf991779 client: 增加ACK支持 2025-12-02 22:31:38 +08:00
CakeCN
788fbb07da ack-client 2025-12-02 22:31:34 +08:00
CakeCN
6c48d79076 change clear to sync 2025-11-18 23:32:41 +08:00
CakeCN
5e6e040753 fix 2025-11-18 22:42:29 +08:00
CakeCN
ede89cef53 fix load_full_markdown_file import bug 2025-11-18 22:15:45 +08:00
CakeCN
0fd548edcd save_load_learned 2025-11-17 23:14:55 +08:00
553a7724b3 allow clear chat log 2025-11-17 16:35:26 +08:00
Cai
0852e12118 cdn fix more 2025-11-17 14:04:52 +08:00
72 changed files with 15675 additions and 1342 deletions

3187
Html/.log

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,124 @@
# ACK机制支持指南
## 概述
客户端已实现基于emit机制的ACK确认功能允许客户端在发送消息后等待服务器确认从而支持同步和异步两种通信模式。
## 客户端改动
### 1. 新增参数
客户端的三个主要发送方法 `send_text``send_voice``send_image` 都新增了以下参数:
- `wait_for_ack: bool = False` - 是否等待服务器ACK确认
- `timeout: int = 5` - 等待ACK的超时时间
### 2. 消息格式变化
客户端发送的消息中新增了 `ack_id` 字段,格式为 `{id}_{timestamp}`,其中:
- `id` 是客户端ID
- `timestamp` 是当前时间的格式化字符串(精确到微秒)
### 3. ACK事件
客户端监听 `ack` 事件,用于接收服务器的确认消息。
## 后端WebSocket服务器需要的支持
### 1. 接收消息处理
当服务器收到客户端发送的消息时,需要:
1. 检查消息中是否包含 `ack_id` 字段
2. 如果包含 `ack_id`立即发送ACK确认消息不需要等待后续业务逻辑执行
3. 继续执行后续的业务逻辑
### 2. ACK消息格式
服务器需要向客户端发送 `ack` 事件,消息格式如下:
```json
{
"ack_id": "客户端发送的ack_id值"
}
```
### 3. 发送ACK的时机
**非常重要**:服务器必须在收到客户端消息后**立即**发送ACK确认而不是在业务逻辑执行完成后发送。这样可以确保客户端能够快速收到确认继续执行后续操作。
### 4. 实现示例Python Socket.IO
```python
# 服务器端示例代码
@sio.on('text_message') # 根据实际事件名调整
def handle_text_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
@sio.on('voice_message') # 根据实际事件名调整
def handle_voice_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
@sio.on('image_message') # 根据实际事件名调整
def handle_image_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
```
## 使用示例
### 客户端同步调用等待ACK
```python
client = ASEngineClient(server_url, namespace, id)
client.connect()
# 同步调用等待ACK确认后再继续执行
result = client.send_text('text_message', 'Hello', wait_for_ack=True, timeout=3)
if result:
print("消息已被服务器确认收到")
# 执行后续操作
else:
print("等待ACK超时")
```
### 客户端异步调用不等待ACK
```python
client = ASEngineClient(server_url, namespace, id)
client.connect()
# 异步调用发送后立即返回不等待ACK
result = client.send_text('text_message', 'Hello', wait_for_ack=False)
if result:
print("消息已发送")
# 执行后续操作不需要等待ACK
```
## 注意事项
1. **向后兼容**:客户端的改动保持了向后兼容性,现有代码无需修改即可继续使用。
2. **超时处理**客户端会在指定的超时时间后停止等待ACK并返回False。
3. **资源清理**客户端会在收到ACK或超时时清理相关的ACK状态避免内存泄漏。
4. **唯一标识**`ack_id` 确保了每个消息的唯一性避免ACK混淆。
5. **性能考虑**等待ACK会阻塞当前线程建议仅在必要时使用同步模式。
## 总结
通过实现基于emit机制的ACK确认功能客户端和服务器之间的通信可以灵活支持同步和异步两种模式。服务器需要做的改动非常简单只需要在收到消息后立即发送ACK确认即可。
这种设计既保证了通信的可靠性,又不会影响业务逻辑的执行效率,是一种高效且可靠的通信方式。

View File

@@ -0,0 +1,326 @@
# ASEngine 数据同步机制文档
## 1. 概述
本文档介绍了如何通过 `learning_progess_process.py` 实现与 ASEngine大模型框架后端之间的运行时数据下载和上传。该机制为客户端与大模型服务之间提供了可靠的数据同步方案适用于需要在不同服务之间共享和同步用户数据的场景。
## 2. 使用场景
### 2.1 核心场景
- **用户学习进度同步**:将用户在客户端的学习进度同步到大模型框架后端,确保跨设备访问一致性
- **对话历史管理**:从大模型框架获取用户对话历史,用于客户端展示和分析
- **多服务数据共享**:在客户端应用与大模型服务之间建立数据桥接,实现数据互通
- **分布式系统协作**:支持多个服务实例之间的数据同步,确保系统一致性
### 2.2 扩展场景
- **离线数据同步**:支持在网络恢复后进行数据同步
- **数据备份与恢复**:通过上传/下载机制实现数据备份和恢复。
- **多租户数据隔离**:通过 namespace_token 实现不同租户数据隔离
## 3. 实现原理
### 3.1 系统架构
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ 客户端应用 │ │ ASEngine │
│ (learning_progess_process.py) │ (大模型框架后端) │
├─────────────────────────┤ ├─────────────────────────┤
│ 1. get_multiagents_dialog_list │ ←─── 下载数据 ──── │ /api/sync/download │
│ 2. upload_learning_progress_to_cloud │ ──── 上传数据 ────→ │ /api/sync/upload │
└─────────────────────────┘ └─────────────────────────┘
```
### 3.2 数据流向
1. **数据下载流程**
- 客户端调用 `get_multiagents_dialog_list()` 方法
- 发送 POST 请求到 ASEngine 的 `/api/sync/download` 接口
- 获取用户对话历史数据
- 数据用于本地处理或存储
2. **数据上传流程**
- 客户端调用 `upload_learning_progress_to_cloud()` 方法
- 序列化待上传的数据(处理特殊类型如 datetime
- 发送 POST 请求到 ASEngine 的 `/api/sync/upload` 接口
- 接收上传结果并记录日志
## 4. 核心功能说明
### 4.1 数据下载功能
```python
def get_multiagents_dialog_list(user_id: str) -> List[Dict]:
"""
从 MultiAgents 框架获取用户对话列表
参数:
- user_id: 用户ID
返回:
- Dict[List]: 对话列表(key 为 History database)
"""
# 实现细节...
```
**功能特点**
- 从配置中动态获取 ASEngine URL 和认证信息
- 支持超时控制10秒
- 完善的错误处理和日志记录
- 返回结构化数据,便于后续处理
### 4.2 数据上传功能
```python
def upload_learning_progress_to_cloud(progress: Dict) -> Dict:
"""
将学习进度上传到云服务
参数:
- progress: 学习进度数据字典
返回:
- Dict: 上传结果包含success和message字段
"""
# 实现细节...
```
**功能特点**
- 自动处理数据序列化,支持复杂数据类型
- 完善的错误处理机制
- 详细的日志记录,便于问题排查
- 返回标准化结果格式
### 4.3 辅助功能
#### 4.3.1 学习进度保存
```python
def save_learning_progress(chatmanager, user_id: str) -> bool:
"""
保存用户学习进度到MongoDB
"""
# 实现细节...
```
#### 4.3.2 学习进度加载
```python
def load_learning_progress(user_id: str, material_id: str, chapter_name: str, lesson_name: str) -> Dict:
"""
加载用户学习进度
"""
# 实现细节...
```
## 5. 配置方法
### 5.1 环境配置
在 Flask 应用的配置中添加以下配置项:
```python
# ASEngine 配置
ASE_ENGINE_URL = "https://your-ase-engine-url.com" # ASEngine 服务地址
ASE_ENGINE_URL_TOKEN = "your-namespace-token" # 命名空间认证令牌
```
### 5.2 依赖安装
确保项目中已安装以下依赖:
```bash
pip install requests # HTTP 请求库
pip install pymongo # MongoDB 驱动如果使用MongoDB存储
```
## 6. 使用示例
### 6.1 数据下载示例
```python
from apps.services.course_services.learning_progess_process import get_multiagents_dialog_list
# 获取用户对话历史
dialog_list = get_multiagents_dialog_list(user_id="user123")
# 处理对话数据
his_data = dialog_list.get('data', [])
for dialog in his_data:
# 处理单个对话
print(f"对话ID: {dialog.get('id')}, 内容: {dialog.get('content')}")
```
### 6.2 数据上传示例
```python
from apps.services.course_services.learning_progess_process import upload_learning_progress_to_cloud
# 准备待上传的数据
progress_data = {
"user_id": "user123",
"multiagents_dialogs": {
"databases": {...}, # 对话数据库
"global_databases": {...}, # 全局数据库
"session_info_out": {...} # 会话信息
}
}
# 上传数据到 ASEngine
result = upload_learning_progress_to_cloud(progress_data)
# 处理上传结果
if result["success"]:
print("数据上传成功")
else:
print(f"数据上传失败: {result['message']}")
```
### 6.3 完整流程示例
```python
from apps.services.course_services.learning_progess_process import (
get_multiagents_dialog_list,
save_learning_progress,
load_learning_progress,
upload_learning_progress_to_cloud
)
# 1. 从 ASEngine 获取对话历史
dialog_list = get_multiagents_dialog_list(user_id="user123")
# 2. 保存到本地 MongoDB
save_learning_progress(chatmanager, user_id="user123")
# 3. 从本地加载学习进度
progress = load_learning_progress(
user_id="user123",
material_id="material456",
chapter_name="第1章",
lesson_name="第1节"
)
# 4. 将学习进度上传回 ASEngine
if progress["exists"]:
upload_result = upload_learning_progress_to_cloud(progress["data"])
print(f"上传结果: {upload_result}")
```
## 7. 代码结构与扩展
### 7.1 核心模块
| 模块 | 功能描述 | 文件位置 |
| --------------- | -------------------------- | ------------------------------------------------------------- |
| 数据同步服务 | 处理与 ASEngine 的数据交互 | `apps/services/course_services/learning_progess_process.py` |
| JSON 序列化工具 | 处理复杂数据类型序列化 | `apps/utils/json_serializer.py` |
| 配置管理 | 管理 ASEngine 连接配置 | `apps/config.py` |
### 7.2 扩展建议
1. **添加重试机制**
```python
def upload_with_retry(progress: Dict, max_retries: int = 3) -> Dict:
for i in range(max_retries):
result = upload_learning_progress_to_cloud(progress)
if result["success"]:
return result
time.sleep(1) # 指数退避算法可进一步优化
return {"success": False, "message": f"上传失败,已重试{max_retries}次"}
```
2. **添加数据校验**
```python
def validate_progress_data(progress: Dict) -> bool:
"""验证进度数据格式"""
required_fields = ["user_id", "multiagents_dialogs"]
for field in required_fields:
if field not in progress:
return False
return True
```
3. **支持异步操作**
```python
import asyncio
import aiohttp
async def async_upload_learning_progress(progress: Dict) -> Dict:
"""异步上传学习进度"""
# 异步实现
pass
```
## 8. 最佳实践
### 8.1 错误处理
- 始终检查 API 返回结果的 `success` 字段
- 实现适当的日志记录,便于问题排查
- 对网络异常和超时进行合理处理
### 8.2 性能优化
- 避免频繁的小数据上传,考虑批量处理
- 对大文件进行压缩后上传
- 合理设置超时时间,避免长时间阻塞
### 8.3 安全性
- 确保 ASEngine URL 和令牌安全存储,避免硬编码
- 使用 HTTPS 协议进行数据传输
- 对敏感数据进行加密处理
### 8.4 监控与告警
- 添加数据同步成功率监控
- 对同步失败情况进行告警
- 记录详细的操作日志,便于审计
## 9. 后续项目参考建议
### 9.1 架构设计
- **分层设计**:将数据同步逻辑与业务逻辑分离,提高代码可维护性
- **接口标准化**:定义统一的数据格式和接口规范,便于扩展
- **依赖注入**:使用依赖注入模式,提高代码可测试性
### 9.2 代码实现
- **模块化**:将不同功能模块拆分为独立函数或类
- **类型注解**:使用 Type Hints 提高代码可读性和类型安全性
- **文档完善**:为每个函数添加详细的文档字符串
- **测试覆盖**:编写单元测试和集成测试,确保功能正确性
### 9.3 扩展功能
- **支持多种数据格式**:如 JSON、Protobuf 等
- **添加数据版本控制**:支持不同版本数据的兼容处理
- **实现增量同步**:只同步变化的数据,提高同步效率
- **支持断点续传**:对于大文件上传实现断点续传功能
## 10. 总结
本文档介绍了基于 `learning_progess_process.py` 实现的 ASEngine 数据同步机制,包括使用场景、实现原理、核心功能、配置方法和使用示例。该机制为客户端与大模型服务之间提供了可靠的数据同步方案,可作为后续项目的参考模板。
通过遵循本文档中的最佳实践和扩展建议,可以构建更加健壮、高效和可扩展的数据同步系统,满足不同场景下的数据同步需求。
## 11. 版本历史
| 版本 | 日期 | 描述 | 作者 |
| ---- | ---------- | ------------ | ------------ |
| v1.0 | 2025-12-10 | 初始版本 | 系统自动生成 |
| v1.1 | YYYY-MM-DD | 添加异步支持 | XXX |
| v1.2 | YYYY-MM-DD | 实现增量同步 | XXX |
## 12. 参考资料
- [Flask 官方文档](https://flask.palletsprojects.com/)
- [Requests 库文档](https://requests.readthedocs.io/)
- [ASEngine API 文档](https://your-ase-engine-docs.com/)

View File

@@ -0,0 +1,113 @@
# JSON Serializer 用法文档
## 概述
`json_serializer` 是一个通用的JSON序列化工具用于处理Python中常见的非JSON序列化类型`datetime``date``time``ObjectId`等。
## 支持的类型
- `datetime`: 转换为ISO格式字符串
- `date`: 转换为ISO格式字符串
- `time`: 转换为ISO格式字符串
- `ObjectId`: 转换为字符串
- `bytes`: 转换为UTF-8字符串
- `set`/`frozenset`: 转换为列表
- 其他不可序列化类型: 转换为字符串表示
## 安装位置
位于 `apps/utils/json_serializer.py`
## 使用方法
### 导入
```python
from apps.utils.json_serializer import json_serialize, json_serialize_dict, JSONEncoder
```
### 函数说明
1. **json_serialize(obj)**: 将对象序列化为JSON字符串
- 参数: `obj` - 要序列化的对象
- 返回: JSON字符串
2. **json_serialize_dict(obj)**: 将对象转换为可JSON序列化的字典
- 参数: `obj` - 要序列化的对象
- 返回: 可JSON序列化的字典
3. **JSONEncoder**: 自定义JSON编码器类可直接用于`json.dumps()`
### 示例
#### 基本使用
```python
from datetime import datetime
from apps.utils.json_serializer import json_serialize_dict
# 创建包含datetime的字典
data = {
"user_id": "test_user",
"updated_at": datetime.now(),
"scores": [85, 90, 95]
}
# 序列化
serialized = json_serialize_dict(data)
print(serialized)
# 输出: {"user_id": "test_user", "updated_at": "2025-12-10T15:30:45.123456", "scores": [85, 90, 95]}
```
#### 在API请求中使用
```python
import requests
from apps.utils.json_serializer import json_serialize_dict
# 准备请求数据
data = {
"user_id": "test_user",
"updated_at": datetime.now(),
"progress": {"chapter1": "completed", "chapter2": "in_progress"}
}
# 序列化数据
serialized_data = json_serialize_dict(data)
# 发送请求
response = requests.post(
"https://api.example.com/upload",
json=serialized_data,
headers={"Content-Type": "application/json"}
)
```
#### 直接使用JSONEncoder
```python
import json
from datetime import datetime
from apps.utils.json_serializer import JSONEncoder
# 创建数据
data = {"updated_at": datetime.now()}
# 使用自定义编码器
json_str = json.dumps(data, cls=JSONEncoder, ensure_ascii=False)
print(json_str)
# 输出: {"updated_at": "2025-12-10T15:30:45.123456"}
```
## 应用场景
1. API请求中的数据序列化
2. 保存数据到文件或数据库
3. 跨服务数据传输
4. 日志记录中的复杂对象序列化
## 注意事项
- 对于自定义对象,会尝试序列化其`__dict__`属性
- 对于无法序列化的类型,会转换为字符串表示
- 确保导入路径正确,特别是在不同模块中使用时

View File

@@ -0,0 +1,165 @@
# HelpTip 组件文档
## 功能介绍
HelpTip 是一个通用的提示框组件,用于在鼠标悬停时显示提示信息。它具有以下特点:
- 提示框跟随鼠标移动
- 支持多行文本
- 自动防出界
- 平滑的显示/隐藏动画
- 支持在不同页面复用
- 代码标准化,易于维护
## 文件结构
```
/static/
├── css/
│ └── helpTip.css # 样式文件
└── js/
└── helpTip.js # 核心功能文件
```
## 使用方法
### 1. 引入文件
在需要使用 HelpTip 的 HTML 页面中引入以下文件:
```html
<!-- 样式文件 -->
<link rel="stylesheet" href="/static/css/helpTip.css">
<!-- 功能文件 -->
<script src="/static/js/helpTip.js"></script>
```
### 2. 基本用法
```javascript
// 获取需要绑定提示框的元素
const element = document.getElementById('target-element');
// 绑定提示框
attachImmediateTooltip(element, '这是一条提示信息');
```
### 3. 支持换行的提示信息
```javascript
attachImmediateTooltip(element, `这是一条\n多行提示信息\n\n支持换行符`);
```
### 4. 与问号图标配合使用
```html
<!-- HTML -->
<span>这是一个元素<span class="help-icon">?</span></span>
```
```javascript
// JavaScript
const helpIcon = document.querySelector('.help-icon');
attachImmediateTooltip(helpIcon, '这是一条详细的提示信息');
```
## API 说明
### attachImmediateTooltip(anchorEl, text)
绑定提示框到指定元素。
#### 参数
- `anchorEl` (HTMLElement): 触发提示框的元素
- `text` (string): 提示框显示的文本内容,支持换行符 `\n`
#### 返回值
- `Function`: 清理函数,用于移除事件监听器
## 样式说明
### 问号图标样式
| 类名 | 说明 |
|------|------|
| `.help-icon` | 问号图标基础样式 |
| `.help-icon:hover` | 问号图标悬停样式 |
### 提示框样式
| 类名 | 说明 |
|------|------|
| `.tooltip-portal` | 提示框基础样式 |
| `.tooltip-portal.show` | 提示框显示状态 |
| `.tooltip-portal.fade` | 提示框动画效果 |
## 示例场景
### 1. 进度条提示
```javascript
// 为进度条的每个阶段绑定提示框
for (let i = 0; i < progressItems.length; i++) {
const item = progressItems[i];
attachImmediateTooltip(item, `阶段 ${i + 1}: ${item.title}`);
}
```
### 2. 表单字段提示
```javascript
// 为表单字段绑定提示框
const formFields = document.querySelectorAll('.form-field');
formFields.forEach(field => {
const tip = field.getAttribute('data-tip');
if (tip) {
attachImmediateTooltip(field, tip);
}
});
```
### 3. 工具栏图标提示
```javascript
// 为工具栏图标绑定提示框
const toolbarIcons = document.querySelectorAll('.toolbar-icon');
toolbarIcons.forEach(icon => {
const title = icon.getAttribute('title');
if (title) {
attachImmediateTooltip(icon, title);
}
});
```
## 注意事项
1. 确保在 DOM 加载完成后再调用 `attachImmediateTooltip` 函数
2. 如果元素是动态生成的,需要在元素生成后再绑定提示框
3. 提示框会自动添加到 `document.body` 中,不会被父元素的 `overflow` 属性裁切
4. 提示框的 `z-index` 为 999999确保它能覆盖所有元素
5. 提示框默认支持多行文本,使用 `white-space: pre-wrap` 样式
## 浏览器兼容性
- Chrome/Edge (最新版)
- Firefox (最新版)
- Safari (最新版)
## 代码优化建议
1. 对于大量元素需要绑定提示框的情况,建议使用事件委托
2. 如果页面中不再需要使用提示框,可以调用返回的清理函数移除事件监听器
3. 提示文本应简洁明了,避免过长
## 更新日志
- v1.0.0: 初始版本,实现了基本的提示框功能
- v1.0.1: 优化了样式,增加了动画效果
- v1.0.2: 支持多行文本,自动防出界
## 贡献指南
如果您发现问题或有改进建议,欢迎提出 Issue 或 Pull Request。

View File

@@ -48,4 +48,5 @@ class Config:
}
ASE_ENGINE_URL = GLOBAL_CONFIG['ASE_ENGINE']['url']
ASE_ENGINE_URL_TOKEN = GLOBAL_CONFIG['ASE_ENGINE']['url_token']
ASE_ENGINE_NAMESPACE = GLOBAL_CONFIG['ASE_ENGINE']['namespace']

View File

@@ -5,6 +5,7 @@ import os
from datetime import datetime
from typing import Dict, Callable, Any, Optional
from functools import partial
from threading import Event
class ASEngineClient:
def __init__(self, server_url: str, namespace: str, id:str):
@@ -29,9 +30,8 @@ class ASEngineClient:
self._on_connect_callback_entry=None
self._on_connect_callback_initData=None
# 注册基础事件处理函数
# ACK相关
self.ack_events: Dict[str, Event] = {} # 存储ack_id和对应的Event对象
self._register_base_events()
def _register_base_events(self):
@@ -43,7 +43,11 @@ class ASEngineClient:
self.sid = self.sio.get_sid(namespace=self.namespace)
print(f"Connected to server. SID: {self.sid}")
if self._on_connect_callback is not None:
self._on_connect_callback(self._on_connect_callback_entry, self._on_connect_callback_initData)
self._on_connect_callback(
self,
self._on_connect_callback_entry,
init_data=self._on_connect_callback_initData
)
# 断开连接事件
@self.sio.on('disconnect', namespace=self.namespace)
@@ -63,27 +67,58 @@ class ASEngineClient:
def on_response(data):
print(f"Received response: {data}")
# ACK事件处理
@self.sio.on('ack', namespace=self.namespace)
def on_ack(data):
ack_id = data.get('ack_id')
if ack_id and ack_id in self.ack_events:
event = self.ack_events[ack_id]
event.set() # 触发事件,通知等待的线程
def register_on_connect_entry(self, callback: Callable, entry: Any, init_data: Any)->bool:
"""注册连接事件回调函数
参数:
- callback: 连接事件回调函数,接受 **kwargs 参数
- entry: 回调关联的条目对象
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象
- init_data: 初始化数据
"""
if not callable(callback):
return False
self._on_connect_callback = callback
self._on_connect_callback_entry=entry
self._on_connect_callback_initData=init_data
return True
def register_route_with_entry(self, route: str,
callback: Optional[Callable] = None,
entry: Any = None,
init_data: Optional[Any] = None) -> bool:
"""注册带条目的路由回调函数
参数:
- route: 路由名称
- callback: 回调函数,接受 **kwargs 参数
- entry: 回调关联的条目对象
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象
- data: 接收到的消息数据
- init_data: 初始化数据
"""
if not route:
print("Route cannot be empty")
return False
if callback is None:
bound_cb = self._default_callback
else:
bound_cb = partial(callback, entry) # 等价于 lambda data, init: callback(entry, data, init)
else:bound_cb = callback
self.routes[route] = {
'callback': bound_cb,
'entry': entry,
@@ -98,8 +133,9 @@ class ASEngineClient:
return
cb = route_info['callback'] # 已经绑定过 entry
init = route_info.get('init_data')
entry = route_info.get('entry')
try:
cb(data, init) # 现在只传 data / init_data
cb(self, entry, data=data, init_data=init) # 传递关键字参数
except Exception as e:
print(f"Callback error on route '{_route}': {e}")
@@ -107,11 +143,17 @@ class ASEngineClient:
def register_route(self, route: str, callback: Optional[Callable] = None, init_data: Optional[Any] = None):
"""
注册一个路由及其回调函数
:param route: 路由名称
:param callback: 回调函数,接收消息数据作为参数
:return: 是否注册成功
"""注册路由回调函数
参数:
- route: 路由名称
- callback: 回调函数,接受 **kwargs 参数
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象(此处为 None
- data: 接收到的消息数据
- init_data: 初始化数据
"""
if not route:
print("Route cannot be empty")
@@ -119,7 +161,9 @@ class ASEngineClient:
# 存储路由和回调
self.routes[route] = {
'callback': callback if callback else self._default_callback
'callback': callback if callback else self._default_callback,
'entry': None,
'init_data': init_data
}
# 注册事件处理器
@@ -128,15 +172,27 @@ class ASEngineClient:
print(f"Received message on route '{route}': {data}")
if 'callback' in self.routes[route]:
try:
self.routes[route]['callback'](None, data, init_data)
self.routes[route]['callback'](
self,
entry=None,
data=data,
init_data=init_data
)
except Exception as e:
print(f"Error in callback for route '{route}': {e}")
print(f"Route '{route}' registered successfully")
return True
def _default_callback(self, entry: Any, data: Any, init_data: Optional[Any] = None):
"""默认回调函数"""
def _default_callback(self, **kwargs):
"""默认回调函数
关键字参数:
- entry: 回调关联的条目对象
- data: 接收到的消息数据
- init_data: 初始化数据
"""
data = kwargs.get('data', {})
print(f"Default callback received: {data}")
def connect(self, timeout: int = 5) -> bool:
@@ -169,12 +225,16 @@ class ASEngineClient:
self.sid = None
print("Disconnected from server")
def send_text(self, route: str, text: str, id:str="") -> bool:
def send_text(self, route: str, text: str, id:str="", wait_for_ack: bool = False, timeout: int = 5, return_event: bool = False) -> Any:
"""
发送文本消息
:param route: 目标路由
:param text: 要发送的文本
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:param return_event: 是否返回事件对象和ack_id而不是等待ACK
:return: 如果wait_for_ack为True且return_event为False返回是否收到ACK如果return_event为True返回(event, ack_id);否则返回是否发送成功
"""
if not self.connected:
print("Not connected to server")
@@ -183,19 +243,50 @@ class ASEngineClient:
id = self.id
try:
self.sio.emit(route, {'type': 'text', 'data': text, 'id': id}, namespace=self.namespace)
print(f"Sent text to route '{route}': {text}")
return True
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
data = {
'type': 'text',
'data': text,
'id': id,
'ack_id': ack_id
}
self.sio.emit(route, data, namespace=self.namespace)
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
# 如果需要返回事件对象,直接返回
if return_event:
return event, ack_id
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send text to route '{route}': {e}")
return False
def send_voice(self, route: str, voice_data: bytes, id:str=None) -> bool:
def send_voice(self, route: str, voice_data: bytes, id:str=None, wait_for_ack: bool = False, timeout: int = 5) -> bool:
"""
发送语音数据
:param route: 目标路由
:param voice_data: 语音数据字节流
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:return: 是否发送成功若wait_for_ack为True则返回是否收到ACK
"""
if id is None:
id = self.id
@@ -204,8 +295,9 @@ class ASEngineClient:
return False
try:
# 编码语音数据为base64
# voice_data = 'data:image/jpeg;base64,' +base64.b64encode(voice_data).decode('utf-8')
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 发送数据
self.sio.emit(
route,
@@ -214,24 +306,43 @@ class ASEngineClient:
'data': voice_data,
'timestamp': int(time.time() * 1000),
'sampleRate': 16000,
'id': id
'id': id,
'ack_id': ack_id
},
namespace=self.namespace
)
# 如果不需要等待ACK直接返回True
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
print(f"Sent voice file to route '{route}'")
return True
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send voice to route '{route}': {e}")
return False
def send_image(self, route: str, file_path: str, id:str) -> bool:
def send_image(self, route: str, file_path: str, id:str, wait_for_ack: bool = False, timeout: int = 5) -> bool:
"""
发送图片文件
:param route: 目标路由
:param file_path: 图片文件路径
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:return: 是否发送成功若wait_for_ack为True则返回是否收到ACK
"""
if not self.connected:
print("Not connected to server")
@@ -251,18 +362,40 @@ class ASEngineClient:
# 获取文件名
filename = os.path.basename(file_path)
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 发送数据
self.sio.emit(
route,
{
'type': 'image',
'data': image_data,
'id': id
'id': id,
'ack_id': ack_id
},
namespace=self.namespace
)
print(f"Sent image file to route '{route}': {filename}")
return True
# 如果不需要等待ACK直接返回True
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send image to route '{route}': {e}")
return False
@@ -286,6 +419,17 @@ class HSAEngineClient(ASEngineClient):
super().__init__(server_url, namespace, id)
self.chatmanager = chatmanager
def loadmarkdown(self, fmd, fmdp, fsp):
self.send_text('markdown-in', fmd, self.id)
self.send_text('markdown-prompt-in', fmdp, self.id)
self.send_text('score-prompt-in', fsp, self.id)
event1, ack_id1 = self.send_text('markdown-in', fmd, self.id, wait_for_ack=True, return_event=True)
event2, ack_id2 = self.send_text('markdown-prompt-in', fmdp, self.id, wait_for_ack=True, return_event=True)
event3, ack_id3 = self.send_text('score-prompt-in', fsp, self.id, wait_for_ack=True, return_event=True)
timeout = 5 # 超时时间
success1 = event1.wait(timeout=timeout)
success2 = event2.wait(timeout=timeout)
success3 = event3.wait(timeout=timeout)
if ack_id1 in self.ack_events:
del self.ack_events[ack_id1]
if ack_id2 in self.ack_events:
del self.ack_events[ack_id2]
if ack_id3 in self.ack_events:
del self.ack_events[ack_id3]
return success1 and success2 and success3

View File

@@ -33,7 +33,8 @@ class ProcRecord:
class ChatManager:
_procs: Dict[str, ProcRecord] = {}
_lock = threading.RLock()
def __init__(self):
def __init__(self, restart=False):
self.restart = restart
self.chapter_chain_now = -1
self.ase_client = None
self.app = None
@@ -48,7 +49,8 @@ class ChatManager:
self.chapter_chain = None
self.function_manager = FunctionManager()
self.bb = None
self.chat_historys = None
self.chat_historys = []
self.scores = []
self.vscode_ws = None
self._vscode_queue = deque()
self._lock = Semaphore(1)
@@ -118,6 +120,7 @@ class ChatManager:
def next_chapter(self):
print(f"now next chapter {self.chapter_chain_now} and len {len(self.chapter_chain)}")
assert self.chapter_chain_now + 1 < len(self.chapter_chain), "chapter_chain_now out of range"
self.chapter_chain_now += 1
self.bb.next_chapter()
@@ -128,10 +131,11 @@ class ChatManager:
self.chapter_chain_now = chapter_index
self.chapter_chain[chapter_index].Focus()
def load_next_chapter(self):
print(f"now load next chapter markdown {self.chapter_chain_now}")
def load_now_chapter(self, load_code = True):
print(f"now load now chapter markdown {self.chapter_chain_now}")
self.ase_client.loadmarkdown(self.chapter_chain[self.chapter_chain_now].chapter, self.chapter_chain[self.chapter_chain_now].chapter_prompt, self.chapter_chain[self.chapter_chain_now].score_prompt)
self.load_code_in_markdown()
if load_code:
self.load_code_in_markdown()
def upload_bb(self):

View File

@@ -0,0 +1,83 @@
import requests
from flask_socketio import Namespace
def clear_user_session(asengine_url, asengine_token, user_id):
'''
快速清理用户会话的函数
'''
try:
base_url = asengine_url
clear_url = f"{base_url}/api/sync/clear_user_session"
payload = {
'namespace_url': asengine_token, # WebSocket命名空间URL
'user_id': user_id, # 用户ID对应session_info.id
'clear_type': 'all' # 清理类型,默认为全部
}
headers = {
'Content-Type': 'application/json'
}
response = requests.post(clear_url, json=payload, headers=headers)
response.raise_for_status() # 检查请求是否成功
print(f"User session {user_id} cleared successfully. Response: {response.json()}")
except Exception as e:
print(f"Error clearing user session: {e}")
def on_connect_to_ase(client, entry, init_data, **kwargs):
'''
此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。
'''
namespace_entry, ase_client = entry
namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent')
namespace_entry.emit('message', "Agents服务已连接, 加载教案中", room=init_data['room'], namespace='/agent')
restart = init_data.get("restart", False)
if restart: # 重启时,从-1章进入 0章
ase_client.chatmanager.next_chapter()
ase_client.chatmanager.load_now_chapter(load_code=restart)
print("load_now_chapter with restart:", restart)
if restart:
ase_client.send_text("chapter-start", "")
namespace_entry.emit('message', "教案加载完毕", room=init_data['room'], namespace='/agent')
#接受消息回来直接让message监听发送
def receive_ase_paste_detected(client_entry, namespace_entry, data, init_data):
print("receive_ase_paste_detected", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_dialog(client_entry, namespace_entry, data, init_data):
print("receive_ase_dialog", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_message_hint(client_entry, namespace_entry, data, init_data):
print("receive_ase_message_hint", data)
namespace_entry.emit('message-hint', data['data'], room=init_data['room'], namespace='/agent')
#接受语音信息
def receive_ase_voice_out(client_entry, namespace_entry, data, init_data):
namespace_entry.emit('voiceMessage', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_process(client_entry, namespace_entry, data, init_data):
namespace_entry.emit('process', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_judge(client_entry, namespace_entry, data, init_data):
print("receive_ase_judge", data)
namespace_entry.emit('request_function', data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_next_chapter(client_entry, namespace_entry, data, init_data):
print("receive_ase_next_chapter", data)
namespace_entry.emit('request_function',data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_chapter_score(client_entry, user_and_manager, data, init_data):
print("load_next_chapter")
user, chatmanager = user_and_manager
if len(chatmanager.scores) >= chatmanager.chapter_chain_now:
chatmanager.scores[chatmanager.chapter_chain_now - 1] = data.get('data', None)
else: chatmanager.scores.append(data.get('data', None))
user.set_course_progress(chatmanager.chat_historys, chatmanager.scores, chatmanager.course_id, chatmanager.chapter_name, chatmanager.lesson_name)
chatmanager.load_now_chapter()
chatmanager.ase_client.send_text("chapter-start", "")

View File

@@ -27,7 +27,7 @@ def get_text_file(file_name):
response = requests.get(file_name)
if response.status_code == 200:
print(file_name)
# print(file_name)
# print(f"get_text_file: {response.text}")
return response.text

View File

@@ -1,470 +1,14 @@
# myapp/services/course_service.py
import re
from typing import Dict, List
import requests
import json
from typing import Dict, List, Optional
from flask import current_app
from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
from ..services.cos_service import upload_file, delete_file
from ..models.material import Material
from .course_services.material_process import *
def get_new_edit_materials(num: int)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find().sort("updated_at", -1).limit(num)
returnCode = []
for material in materials:
returnCode.append(Material(**material))
returnCode[-1]._id = str(material['_id'])
return returnCode
def user_selected_course(user_obj):
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
brief["chapters"] = [chapter.model_dump() for chapter in course_obj.chapters]
briefs.append(brief)
return briefs
def user_selected_course_briefs(user_obj):
"""根据用户对象的选课列表,拼装课程简要信息数组"""
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
briefs.append(brief)
return briefs
def create_material(teacher_id, material_name, description, chapters, image_url):
material = Material(
name=material_name,
description=description,
teacher_id=teacher_id,
created_at=datetime.now(),
updated_at=datetime.now(),
chapters=chapters,
image_url=image_url
)
mongo = current_app.extensions["mongo"]
material_id = mongo.db.materials.insert_one(material.model_dump()).inserted_id
return str(material_id)
def load_material(material_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({"_id": ObjectId(material_id)})
material['_id'] = material_id
return Material(**material)
def update_material(material_id, chapters):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters': chapters, 'updated_at': datetime.now()}}
)
return True
return False
def add_material_chapter(material_id, chapter_name, teacher_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material['teacher_id'] != teacher_id:
return False
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$push': {'chapters': {'chapter_name': chapter_name, 'lessons': []}}})
return True
def rename_material_structure(material_id, is_chapter, chapter_name, lesson_name, new_name):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False # 材料不存在
# 检查名称是否存在于列表中的辅助函数
def name_exists_in_list(item_list, name_key, target_name):
return any(item[name_key] == target_name for item in item_list)
# 查找包含指定章节名的章节
def find_chapter_by_name(chapters, target_name):
for chapter in chapters:
if chapter['chapter_name'] == target_name:
return chapter
return None
# 检查章节是否存在
target_chapter = find_chapter_by_name(material['chapters'], chapter_name)
if not target_chapter:
return False
if is_chapter:
# 检查新章节名是否已存在
if name_exists_in_list(material['chapters'], 'chapter_name', new_name):
return False
# 更新章节名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].chapter_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}]
)
else:
# 检查课时是否存在
if not name_exists_in_list(target_chapter['lessons'], 'lesson_name', lesson_name):
return False
# 检查新课时名是否已存在
if name_exists_in_list(target_chapter['lessons'], 'lesson_name', new_name):
return False
# 更新课时名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].lessons.$[l].lesson_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}, {'l.lesson_name': lesson_name}]
)
return True
def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
删除指定课程的课时。
删除后,更新 MongoDB 中对应的章节中的 lesson 列表。
"""
mongo = current_app.extensions["mongo"]
# 查询 material 是否存在,且确保该 teacher_id 对应的 material
material = mongo.db.materials.find_one({'_id': ObjectId(material_id), 'teacher_id': teacher_id})
for chapter in material['chapters']:
if chapter['chapter_name'] == chapter_name:
for lesson in chapter['lessons']:
if lesson['lesson_name'] == lesson_name:
if lesson['markdown_lesson_file_link']!='':
delete_file(lesson['markdown_lesson_file_link'])
if lesson['markdown_prompt_file_name']!='':
delete_file(lesson['markdown_prompt_file_name'])
if lesson['markdown_score_prompt_file_link']!='':
delete_file(lesson['markdown_score_prompt_file_link'])
if not material:
return False # 找不到对应的 material 或 teacher_id 不匹配
# 删除 lesson 的条件
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id # 确保是该教师的课程
},
{
"$pull": {
"chapters.$[c].lessons": {
"lesson_name": lesson_name # 根据 lesson_name 来删除
}
},
"$set": {"updated_at": datetime.now()} # 更新修改时间
},
array_filters=[{"c.chapter_name": chapter_name}] # 找到匹配的章节
)
# 如果删除操作成功res.modified_count 应为 1
return res.modified_count == 1
def reorder_material_structure(material_id, order):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False
# 1. 构建原章节映射(保留章节元信息)和原章节的课时映射
chapter_map = {ch["chapter_name"]: ch for ch in material["chapters"]}
# 每个章节的课时映射:{章节名: {课时名: 课时对象}}
original_chapter_lessons = {
ch["chapter_name"]: {ls["lesson_name"]: ls for ls in ch["lessons"]}
for ch in material["chapters"]
}
# 2. 收集所有章节“少掉的课时”到全局池(仅包含原章节有但新排序没包含的课时)
# 用列表存储,避免同名课时被覆盖(处理可能的重名场景)
global_extra_lessons = []
for ch_name in chapter_map:
original_lessons = original_chapter_lessons[ch_name] # 原章节的所有课时
# 找到当前章节在order中的配置如果没有默认该章节所有课时都是“少掉的”
chapter_order = next((item for item in order if item["chapter_name"] == ch_name), None)
if not chapter_order:
# 章节未在order中出现所有原课时都是“少掉的”
global_extra_lessons.extend(original_lessons.values())
continue
# 章节在order中出现找出原课时中不在新排序里的即“少掉的”
new_lesson_names = chapter_order["lessons"]
for ls_name, ls in original_lessons.items():
if ls_name not in new_lesson_names:
global_extra_lessons.append(ls)
print("global_extra_lessons:", global_extra_lessons)
new_chapters = []
for chapter_order in order:
ch_name = chapter_order["chapter_name"]
if ch_name not in chapter_map:
continue # 跳过不存在的章节
original_ch = chapter_map[ch_name]
original_lessons = original_chapter_lessons[ch_name] # 本章节原课时
new_lessons = []
# 3. 构建当前章节的新课时列表
for ls_name in chapter_order["lessons"]:
# 优先使用本章节原有的课时
if ls_name in original_lessons:
new_lessons.append(original_lessons[ls_name])
continue
# 本章节没有,从全局池(其他章节少掉的课时)中找
# 处理可能的重名:找到第一个匹配的课时
for i, extra_ls in enumerate(global_extra_lessons):
if extra_ls["lesson_name"] == ls_name:
new_lessons.append(extra_ls)
del global_extra_lessons[i] # 从全局池移除,避免重复
break
# 4. 构造新章节(保留原章节元信息,替换课时列表)
new_chapter = {**original_ch, "lessons": new_lessons}
new_chapters.append(new_chapter)
# 5. 处理全局池中剩余的课时(未被任何章节引用的,追加到最后一个章节)
if global_extra_lessons and new_chapters:
new_chapters[-1]["lessons"].extend(global_extra_lessons)
print("new_chapters:", new_chapters)
# 更新数据库
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters": new_chapters,
"updated_at": datetime.now()
}
}
)
return True
def save_materials_markdown_cos(
material_id: str,
chapter_name: str,
lesson_name: str,
docs: Dict[str, Dict[str, str]],
version: str | None = None, # 目前不做并发控制,保留参数
actor=None # 可记录操作者到审计日志
) -> Dict[str, str]:
"""
docs 结构:
{
"lesson": {"cdn_link": "...lesson.md", "content": "..."},
"prompt": {"cdn_link": "...prompt.md", "content": "..."},
"score": {"cdn_link": "...score_prompt.md", "content": "..."}
}
约定cdn_link 与对象存储的 key 一一映射(同路径/同文件名),
我们通过解析 URL 的 path 作为 key 进行覆盖写入(不改文件名)。
"""
def _cdn_to_key(cdn_link: str) -> str:
"""
把 CDN URL 映射成对象存储的 key
- 若 CDN 与存储是“同 key 直出”,则直接取 path去掉前导 /
- 若你有自定义映射规则,在此处改写
"""
p = urlparse(cdn_link)
key = p.path.lstrip("/")
if not key:
raise ValueError(f"非法 cdn_link{cdn_link}")
return key
# 1) 覆盖写入三份文件
results: Dict[str, Dict[str, str]] = {}
for kind in ("lesson", "prompt", "score"):
item = docs.get(kind)
if not item or "cdn_link" not in item or "content" not in item:
raise ValueError(f"缺少 {kind} 的 cdn_link 或 content")
key = _cdn_to_key(item["cdn_link"])
content = item["content"]
# 覆盖写入注意upload_file 内部需实现覆盖同 key
try:
# 约定upload_file 返回可访问的 URL覆盖后可与原相同
url = upload_file(content.encode("utf-8"), key)
except Exception as e:
current_app.logger.exception("覆盖写入失败 kind=%s key=%s", kind, key)
raise
# 简单返回:保留原 cdn_link一般与 url 相同),以及 md5 作为伪版本
import hashlib
etag = hashlib.md5(content.encode("utf-8")).hexdigest()
results[kind] = {"cdn_link": url or item["cdn_link"], "version": etag}
# 2) 写库:更新该 lesson 的更新时间(不改文件名/链接)
_touch_material_lesson_updated_at(material_id, chapter_name, lesson_name, actor=actor)
results["updated_at"] = datetime.now().isoformat() + "Z"
return results
def _touch_material_lesson_updated_at(material_id: str, chapter_name: str, lesson_name: str, actor=None) -> None:
"""仅刷新 materials 文档中该课时的更新时间戳(以及顶层 updated_at"""
mongo = current_app.extensions["mongo"]
now = datetime.now()
# 更新顶层 updated_at
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{"$set": {"updated_at": now}}
)
# 更新嵌套 lesson 的更新时间(如果你在 lesson 内有该字段)
# 需要使用 arrayFilters 精确定位到对应章节/课时
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters.$[c].lessons.$[l].updated_at": now,
# 可选:记录最近编辑人
"chapters.$[c].lessons.$[l].last_editor": getattr(actor, "id", None) if actor else None
}
},
array_filters=[
{"c.chapter_name": chapter_name},
{"l.lesson_name": lesson_name}
]
)
def add_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
1) 生成占位 Markdownlesson_prompt_score_prompt
2) 上传到对象存储(返回 URL
3) 将 lesson 文档(含三份 URLpush 到指定章节
"""
mongo = current_app.extensions["mongo"]
# 组织对象存储的文件路径
chapter_slug = _slugify(chapter_name or "")
lesson_slug = _slugify(lesson_name or "")
timestamp = datetime.now().strftime('%Y%m%dT%H%M%SZ')
base_prefix = f"materials_{material_id}_{chapter_slug}_{lesson_slug}_{timestamp}"
# 占位文本
lesson_md, prompt_md, score_prompt_md = _default_markdowns(lesson_name or "未命名课时")
# 生成文件名(带层级路径)
lesson_key = f"{base_prefix}_lesson.md"
prompt_key = f"{base_prefix}_prompt.md"
score_key = f"{base_prefix}_score_prompt.md"
# 上传(任意一个失败则终止,不写库)
try:
lesson_url = upload_file(lesson_md.encode("utf-8"), lesson_key)
prompt_url = upload_file(prompt_md.encode("utf-8"), prompt_key)
score_url = upload_file(score_prompt_md.encode("utf-8"), score_key)
except Exception as e:
current_app.logger.exception("Upload default markdown failed: %s", e)
return False
# 组装 lesson_doc
lesson_doc = {
"lesson_name": lesson_name or "",
"markdown_lesson_file_link": lesson_url, # lesson 主体
"markdown_prompt_file_name": prompt_url, # 给大模型的 prompt
"markdown_score_prompt_file_link": score_url, # 评分 prompt
}
# 将 lesson push 到指定章节
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id, # 注意:若存的是 ObjectId请确保传入的是同类型
},
{
"$push": {"chapters.$[c].lessons": lesson_doc},
"$set": {"updated_at": datetime.now()},
},
array_filters=[{"c.chapter_name": chapter_name}],
)
# 可选:如果 res.modified_count != 1说明 push 失败(可能章节名不匹配)
# 这里可以考虑是否回滚 COS 上刚上传的 3 个文件(需实现删除逻辑)。
return res.modified_count == 1
def get_materials_by_teacher(teacher_id)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
return [Material(**material) for material in materials]
def get_materials_by_teacher_dict(teacher_id)->List[Dict]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
result = []
for material in materials:
material['_id'] = str(material['_id'])
result.append(material)
return result
def _slugify(name: str) -> str:
"""将章节/课时名转为 URL 友好的 slug。"""
s = name.strip().lower()
s = re.sub(r'\s+', '-', s) # 空白 -> -
s = re.sub(r'[^a-z0-9\-_]+', '', s) # 仅保留安全字符
return s or 'untitled'
def _default_markdowns(lesson_name: str):
"""三份占位 Markdown 文本。可按需调整模板内容。"""
ts = datetime.now().strftime('%Y-%m-%d %H:%M')
lesson_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
在这里编写该步骤的教学内容(文字/代码/图片链接等)。
### 步骤B
继续补充该阶段的其它步骤内容。
"""
prompt_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
请基于“步骤A”的教学目标与材料生成指导性提示词尽量结构化给出评估标准和示例
### 步骤B
请基于“步骤B”的教学目标与材料生成指导性提示词。
"""
score_prompt_md = f"""# {lesson_name}
## 评分准则
- 正确性、完整性、可读性、效率(按需调整权重)
## 阶段一:示例阶段
### 步骤A
对学员在“步骤A”的输出进行评分与文字反馈分数区间 0-100并输出 JSON```{{"score": <int>, "reasons": "<string>", "advices": "<string>"}}```
### 步骤B
同上对“步骤B”输出进行评分与反馈。
"""
return lesson_md, prompt_md, score_prompt_md
from .course_services.learning_progess_process import *

View File

@@ -0,0 +1,239 @@
# myapp/services/course_service.py
import re
import requests
import json
from typing import Dict, List, Optional
from flask import current_app
from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
def save_learning_progress(chatmanager, user_id: str) -> bool:
"""
保存用户学习进度到MongoDB
参数:
- chatmanager: ChatManager实例包含学习进度数据
- user_id: 用户ID
返回:
- bool: 保存是否成功
"""
try:
# 获取MongoDB连接
mongo = current_app.extensions["mongo"]
# 获取MultiAgents框架中的对话列表
dialog_list = get_multiagents_dialog_list(user_id)
his_data = dialog_list['data']
# 构建学习进度数据
progress_data = {
"user_id": user_id,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name,
"chapter_chain_now": chatmanager.chapter_chain_now,
"chat_historys": chatmanager.chat_historys,
"scores": chatmanager.scores,
"multiagents_dialogs": his_data,
"chapter_chain_length": len(chatmanager.chapter_chain),
"updated_at": datetime.now()
}
# 保存到MongoDB使用upsert操作如果存在则更新不存在则插入
mongo.db.learning_progress.update_one(
{
"user_id": user_id,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name
},
{"$set": progress_data},
upsert=True
)
current_app.logger.info(f"学习进度保存成功: user_id={user_id}, lesson={chatmanager.lesson_name}")
return True
except Exception as e:
current_app.logger.error(f"保存学习进度失败: {str(e)}")
return False
def get_multiagents_dialog_list(user_id: str) -> List[Dict]:
"""
从MultiAgents框架获取用户对话列表
参数:
- user_id: 用户ID
返回:
- Dict[List]: 对话列表(key 为 History database)
"""
try:
# 从配置中获取ASE引擎URL和token
config = current_app.config
url_token = config.get("ASE_ENGINE_URL_TOKEN", "")
# 构建请求URL
base_url = config.get("ASE_ENGINE_URL", "")
api_url = f"{base_url}/api/sync/download"
# 构建请求参数
payload = {
"namespace_url": url_token, # 使用配置中的token作为namespace_url
"user_id": user_id
}
# 发送POST请求
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
# 检查响应
if response.status_code == 200:
current_app.logger.info(f"获取MultiAgents对话列表成功: user_id={user_id}, json={response.json()}")
return response.json()
else:
current_app.logger.error(f"获取MultiAgents对话列表失败: HTTP {response.status_code}")
return []
except Exception as e:
current_app.logger.error(f"获取MultiAgents对话列表异常: {str(e)}")
return []
def load_learning_progress(user_id: str, material_id: str, chapter_name: str, lesson_name: str) -> Dict:
"""
加载用户学习进度
参数:
- user_id: 用户ID
- material_id: 课程ID
- chapter_name: 章节名
- lesson_name: 课时名
返回:
- Dict: 学习进度数据,包含以下字段:
- exists: bool表示学习记录是否存在
- data: Dict学习进度详细数据包含chat_historys、scores、chapter_chain_now等字段
- message: str操作结果描述
"""
try:
mongo = current_app.extensions["mongo"]
progress = mongo.db.learning_progress.find_one({
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name
})
if progress:
# 转换ObjectId为字符串
if "_id" in progress:
progress["_id"] = str(progress["_id"])
return {
"exists": True,
"data": progress,
"message": "学习记录加载成功"
}
# 返回默认值,确保即使没有学习记录也有完整的结构
return {
"exists": False,
"data": {
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name,
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"chapter_chain_length": 0,
"updated_at": datetime.now().isoformat()
},
"message": "未找到学习记录,返回默认值"
}
except Exception as e:
error_msg = f"加载学习进度失败: {str(e)}"
current_app.logger.error(error_msg)
# 发生错误时返回默认结构,确保系统稳定性
return {
"exists": False,
"data": {
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name,
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"chapter_chain_length": 0,
"updated_at": datetime.now().isoformat()
},
"message": error_msg
}
def upload_learning_progress_to_cloud(progress: Dict) -> Dict:
"""
将学习进度上传到云服务
参数:
- progress: 学习进度数据字典{'databases':{}, 'global_databases':{}, 'session_info_out':{}}
返回:
- Dict: 上传结果包含success和message字段
"""
try:
# 从配置中获取ASE引擎URL和token
config = current_app.config
url_token = config.get("ASE_ENGINE_URL_TOKEN", "")
# 构建请求URL
base_url = config.get("ASE_ENGINE_URL", "")
api_url = f"{base_url}/api/sync/upload"
# 导入JSON序列化工具
from ...utils.json_serializer import json_serialize_dict
# 序列化进度数据处理datetime等特殊类型
serialized_progress = json_serialize_dict(progress['multiagents_dialogs'])
# 构建请求参数
payload = {
"namespace_url": url_token, # 使用配置中的token作为namespace_url
"user_id": progress.get("user_id", ""),
"data": serialized_progress
}
# 发送POST请求
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
# 处理响应
if response.status_code == 200:
result = response.json()
current_app.logger.info(f"学习进度上传成功: user_id={progress.get('user_id')} 内容:{serialized_progress}")
return {
"success": True,
"message": "学习进度上传成功",
"data": result
}
else:
error_msg = f"上传失败HTTP状态码: {response.status_code}"
current_app.logger.error(f"学习进度上传失败: {error_msg}")
return {
"success": False,
"message": error_msg
}
except Exception as e:
error_msg = f"上传异常: {str(e)}"
current_app.logger.error(f"学习进度上传异常: {error_msg}")
return {
"success": False,
"message": error_msg
}

View File

@@ -0,0 +1,473 @@
import re
import requests
import json
from typing import Dict, List, Optional
from flask import current_app
from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
from apps.services.cos_service import upload_file, delete_file
from apps.models.material import Material
def get_new_edit_materials(num: int)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find().sort("updated_at", -1).limit(num)
returnCode = []
for material in materials:
returnCode.append(Material(**material))
returnCode[-1]._id = str(material['_id'])
return returnCode
def user_selected_course(user_obj):
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
brief["chapters"] = [chapter.model_dump() for chapter in course_obj.chapters]
briefs.append(brief)
return briefs
def user_selected_course_briefs(user_obj):
"""根据用户对象的选课列表,拼装课程简要信息数组"""
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
briefs.append(brief)
return briefs
def create_material(teacher_id, material_name, description, chapters, image_url):
material = Material(
name=material_name,
description=description,
teacher_id=teacher_id,
created_at=datetime.now(),
updated_at=datetime.now(),
chapters=chapters,
image_url=image_url
)
mongo = current_app.extensions["mongo"]
material_id = mongo.db.materials.insert_one(material.model_dump()).inserted_id
return str(material_id)
def load_material(material_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({"_id": ObjectId(material_id)})
material['_id'] = material_id
return Material(**material)
def update_material(material_id, chapters):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters': chapters, 'updated_at': datetime.now()}}
)
return True
return False
def add_material_chapter(material_id, chapter_name, teacher_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material['teacher_id'] != teacher_id:
return False
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$push': {'chapters': {'chapter_name': chapter_name, 'lessons': []}}})
return True
def rename_material_structure(material_id, is_chapter, chapter_name, lesson_name, new_name):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False # 材料不存在
# 检查名称是否存在于列表中的辅助函数
def name_exists_in_list(item_list, name_key, target_name):
return any(item[name_key] == target_name for item in item_list)
# 查找包含指定章节名的章节
def find_chapter_by_name(chapters, target_name):
for chapter in chapters:
if chapter['chapter_name'] == target_name:
return chapter
return None
# 检查章节是否存在
target_chapter = find_chapter_by_name(material['chapters'], chapter_name)
if not target_chapter:
return False
if is_chapter:
# 检查新章节名是否已存在
if name_exists_in_list(material['chapters'], 'chapter_name', new_name):
return False
# 更新章节名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].chapter_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}]
)
else:
# 检查课时是否存在
if not name_exists_in_list(target_chapter['lessons'], 'lesson_name', lesson_name):
return False
# 检查新课时名是否已存在
if name_exists_in_list(target_chapter['lessons'], 'lesson_name', new_name):
return False
# 更新课时名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].lessons.$[l].lesson_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}, {'l.lesson_name': lesson_name}]
)
return True
def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
删除指定课程的课时。
删除后,更新 MongoDB 中对应的章节中的 lesson 列表。
"""
mongo = current_app.extensions["mongo"]
# 查询 material 是否存在,且确保该 teacher_id 对应的 material
material = mongo.db.materials.find_one({'_id': ObjectId(material_id), 'teacher_id': teacher_id})
for chapter in material['chapters']:
if chapter['chapter_name'] == chapter_name:
for lesson in chapter['lessons']:
if lesson['lesson_name'] == lesson_name:
if lesson['markdown_lesson_file_link']!='':
delete_file(lesson['markdown_lesson_file_link'])
if lesson['markdown_prompt_file_name']!='':
delete_file(lesson['markdown_prompt_file_name'])
if lesson['markdown_score_prompt_file_link']!='':
delete_file(lesson['markdown_score_prompt_file_link'])
if not material:
return False # 找不到对应的 material 或 teacher_id 不匹配
# 删除 lesson 的条件
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id # 确保是该教师的课程
},
{
"$pull": {
"chapters.$[c].lessons": {
"lesson_name": lesson_name # 根据 lesson_name 来删除
}
},
"$set": {"updated_at": datetime.now()} # 更新修改时间
},
array_filters=[{"c.chapter_name": chapter_name}] # 找到匹配的章节
)
# 如果删除操作成功res.modified_count 应为 1
return res.modified_count == 1
def reorder_material_structure(material_id, order):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False
# 1. 构建原章节映射(保留章节元信息)和原章节的课时映射
chapter_map = {ch["chapter_name"]: ch for ch in material["chapters"]}
# 每个章节的课时映射:{章节名: {课时名: 课时对象}}
original_chapter_lessons = {
ch["chapter_name"]: {ls["lesson_name"]: ls for ls in ch["lessons"]}
for ch in material["chapters"]
}
# 2. 收集所有章节“少掉的课时”到全局池(仅包含原章节有但新排序没包含的课时)
# 用列表存储,避免同名课时被覆盖(处理可能的重名场景)
global_extra_lessons = []
for ch_name in chapter_map:
original_lessons = original_chapter_lessons[ch_name] # 原章节的所有课时
# 找到当前章节在order中的配置如果没有默认该章节所有课时都是“少掉的”
chapter_order = next((item for item in order if item["chapter_name"] == ch_name), None)
if not chapter_order:
# 章节未在order中出现所有原课时都是“少掉的”
global_extra_lessons.extend(original_lessons.values())
continue
# 章节在order中出现找出原课时中不在新排序里的即“少掉的”
new_lesson_names = chapter_order["lessons"]
for ls_name, ls in original_lessons.items():
if ls_name not in new_lesson_names:
global_extra_lessons.append(ls)
print("global_extra_lessons:", global_extra_lessons)
new_chapters = []
for chapter_order in order:
ch_name = chapter_order["chapter_name"]
if ch_name not in chapter_map:
continue # 跳过不存在的章节
original_ch = chapter_map[ch_name]
original_lessons = original_chapter_lessons[ch_name] # 本章节原课时
new_lessons = []
# 3. 构建当前章节的新课时列表
for ls_name in chapter_order["lessons"]:
# 优先使用本章节原有的课时
if ls_name in original_lessons:
new_lessons.append(original_lessons[ls_name])
continue
# 本章节没有,从全局池(其他章节少掉的课时)中找
# 处理可能的重名:找到第一个匹配的课时
for i, extra_ls in enumerate(global_extra_lessons):
if extra_ls["lesson_name"] == ls_name:
new_lessons.append(extra_ls)
del global_extra_lessons[i] # 从全局池移除,避免重复
break
# 4. 构造新章节(保留原章节元信息,替换课时列表)
new_chapter = {**original_ch, "lessons": new_lessons}
new_chapters.append(new_chapter)
# 5. 处理全局池中剩余的课时(未被任何章节引用的,追加到最后一个章节)
if global_extra_lessons and new_chapters:
new_chapters[-1]["lessons"].extend(global_extra_lessons)
print("new_chapters:", new_chapters)
# 更新数据库
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters": new_chapters,
"updated_at": datetime.now()
}
}
)
return True
def save_materials_markdown_cos(
material_id: str,
chapter_name: str,
lesson_name: str,
docs: Dict[str, Dict[str, str]],
version: str | None = None, # 目前不做并发控制,保留参数
actor=None # 可记录操作者到审计日志
) -> Dict[str, str]:
"""
docs 结构:
{
"lesson": {"cdn_link": "...lesson.md", "content": "..."},
"prompt": {"cdn_link": "...prompt.md", "content": "..."},
"score": {"cdn_link": "...score_prompt.md", "content": "..."}
}
约定cdn_link 与对象存储的 key 一一映射(同路径/同文件名),
我们通过解析 URL 的 path 作为 key 进行覆盖写入(不改文件名)。
"""
def _cdn_to_key(cdn_link: str) -> str:
"""
把 CDN URL 映射成对象存储的 key
- 若 CDN 与存储是“同 key 直出”,则直接取 path去掉前导 /
- 若你有自定义映射规则,在此处改写
"""
p = urlparse(cdn_link)
key = p.path.lstrip("/")
if not key:
raise ValueError(f"非法 cdn_link{cdn_link}")
return key
# 1) 覆盖写入三份文件
results: Dict[str, Dict[str, str]] = {}
for kind in ("lesson", "prompt", "score"):
item = docs.get(kind)
if not item or "cdn_link" not in item or "content" not in item:
raise ValueError(f"缺少 {kind} 的 cdn_link 或 content")
key = _cdn_to_key(item["cdn_link"])
content = item["content"]
# 覆盖写入注意upload_file 内部需实现覆盖同 key
try:
# 约定upload_file 返回可访问的 URL覆盖后可与原相同
url = upload_file(content.encode("utf-8"), key)
except Exception as e:
current_app.logger.exception("覆盖写入失败 kind=%s key=%s", kind, key)
raise
# 简单返回:保留原 cdn_link一般与 url 相同),以及 md5 作为伪版本
import hashlib
etag = hashlib.md5(content.encode("utf-8")).hexdigest()
results[kind] = {"cdn_link": url or item["cdn_link"], "version": etag}
# 2) 写库:更新该 lesson 的更新时间(不改文件名/链接)
_touch_material_lesson_updated_at(material_id, chapter_name, lesson_name, actor=actor)
results["updated_at"] = datetime.now().isoformat() + "Z"
return results
def _touch_material_lesson_updated_at(material_id: str, chapter_name: str, lesson_name: str, actor=None) -> None:
"""仅刷新 materials 文档中该课时的更新时间戳(以及顶层 updated_at"""
mongo = current_app.extensions["mongo"]
now = datetime.now()
# 更新顶层 updated_at
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{"$set": {"updated_at": now}}
)
# 更新嵌套 lesson 的更新时间(如果你在 lesson 内有该字段)
# 需要使用 arrayFilters 精确定位到对应章节/课时
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters.$[c].lessons.$[l].updated_at": now,
# 可选:记录最近编辑人
"chapters.$[c].lessons.$[l].last_editor": getattr(actor, "id", None) if actor else None
}
},
array_filters=[
{"c.chapter_name": chapter_name},
{"l.lesson_name": lesson_name}
]
)
def add_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
1) 生成占位 Markdownlesson_prompt_score_prompt
2) 上传到对象存储(返回 URL
3) 将 lesson 文档(含三份 URLpush 到指定章节
"""
mongo = current_app.extensions["mongo"]
# 组织对象存储的文件路径
chapter_slug = _slugify(chapter_name or "")
lesson_slug = _slugify(lesson_name or "")
timestamp = datetime.now().strftime('%Y%m%dT%H%M%SZ')
base_prefix = f"materials_{material_id}_{chapter_slug}_{lesson_slug}_{timestamp}"
# 占位文本
lesson_md, prompt_md, score_prompt_md = _default_markdowns(lesson_name or "未命名课时")
# 生成文件名(带层级路径)
lesson_key = f"{base_prefix}_lesson.md"
prompt_key = f"{base_prefix}_prompt.md"
score_key = f"{base_prefix}_score_prompt.md"
# 上传(任意一个失败则终止,不写库)
try:
lesson_url = upload_file(lesson_md.encode("utf-8"), lesson_key)
prompt_url = upload_file(prompt_md.encode("utf-8"), prompt_key)
score_url = upload_file(score_prompt_md.encode("utf-8"), score_key)
except Exception as e:
current_app.logger.exception("Upload default markdown failed: %s", e)
return False
# 组装 lesson_doc
lesson_doc = {
"lesson_name": lesson_name or "",
"markdown_lesson_file_link": lesson_url, # lesson 主体
"markdown_prompt_file_name": prompt_url, # 给大模型的 prompt
"markdown_score_prompt_file_link": score_url, # 评分 prompt
}
# 将 lesson push 到指定章节
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id, # 注意:若存的是 ObjectId请确保传入的是同类型
},
{
"$push": {"chapters.$[c].lessons": lesson_doc},
"$set": {"updated_at": datetime.now()},
},
array_filters=[{"c.chapter_name": chapter_name}],
)
# 可选:如果 res.modified_count != 1说明 push 失败(可能章节名不匹配)
# 这里可以考虑是否回滚 COS 上刚上传的 3 个文件(需实现删除逻辑)。
return res.modified_count == 1
def get_materials_by_teacher(teacher_id)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
return [Material(**material) for material in materials]
def get_materials_by_teacher_dict(teacher_id)->List[Dict]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
result = []
for material in materials:
material['_id'] = str(material['_id'])
result.append(material)
return result
def _slugify(name: str) -> str:
"""将章节/课时名转为 URL 友好的 slug。"""
s = name.strip().lower()
s = re.sub(r'\s+', '-', s) # 空白 -> -
s = re.sub(r'[^a-z0-9\-_]+', '', s) # 仅保留安全字符
return s or 'untitled'
def _default_markdowns(lesson_name: str):
"""三份占位 Markdown 文本。可按需调整模板内容。"""
ts = datetime.now().strftime('%Y-%m-%d %H:%M')
lesson_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
在这里编写该步骤的教学内容(文字/代码/图片链接等)。
### 步骤B
继续补充该阶段的其它步骤内容。
"""
prompt_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
请基于"步骤A"的教学目标与材料,生成指导性提示词(尽量结构化,给出评估标准和示例)。
### 步骤B
请基于"步骤B"的教学目标与材料,生成指导性提示词。
"""
score_prompt_md = f"""# {lesson_name}
## 评分准则
- 正确性、完整性、可读性、效率(按需调整权重)
## 阶段一:示例阶段
### 步骤A
对学员在"步骤A"的输出进行评分与文字反馈,分数区间 0-100并输出 JSON```{{"score": <int>, "reasons": "<string>", "advices": "<string>"}}```
### 步骤B
同上,对"步骤B"输出进行评分与反馈。
"""
return lesson_md, prompt_md, score_prompt_md

View File

@@ -24,7 +24,6 @@ def load_markdown_file(course_id, chapter_name, lesson_name):
break
if file_link is None:
return None
print(f"load_markdown_file: {file_link}")
return get_text_file(file_link)
def convert_markdown_to_html(md_file: str) -> str:

View File

@@ -1,23 +1,37 @@
# myapp/sockets/namespaces.py
import json
import time
from flask import current_app, request, session
import requests
from flask import current_app, request, session, url_for
from flask_socketio import Namespace, join_room, leave_room, emit
from ..config import Config
from ..function_tools import judge
from ..function_tools import next_chapter
from ..services.backboard_service import realtime_response
from ..services.markdown_service import load_full_markdown_file
from ..services.markdown_service import load_full_markdown_file
from ..services.course_service import save_learning_progress, upload_learning_progress_to_cloud
from ..extension_ase.ase_client import HSAEngineClient
from ..services.user_service import get_or_load_current_user
from ..services.course_service import load_learning_progress
from ..services.asectrl_service import (
on_connect_to_ase,
receive_ase_paste_detected,
receive_ase_dialog,
receive_ase_message_hint,
receive_ase_voice_out,
receive_ase_process,
receive_ase_judge,
receive_ase_next_chapter,
receive_ase_chapter_score,
clear_user_session
)
class VSCodeNamespace(Namespace):
def on_login(self,data):
self.app = current_app
ex = current_app.extensions
print("VSCode client connected")
dataconfig = data['config']
@@ -49,8 +63,52 @@ class VSCodeNamespace(Namespace):
class AgentNamespace(Namespace):
def _load_learning_progress(self, chatmanager, user_id, course_id, chapter_name, lesson_name, continue_learn):
"""加载并恢复用户学习进度
Args:
chatmanager: 聊天管理器实例
user_id: 用户ID
course_id: 课程ID
chapter_name: 章节名称
lesson_name: 课时名称
continue_learn: 是否继续学习
Returns:
int: 需要跳过的章节数
"""
need_skip_chapters = 0
if continue_learn: # 尝试加载用户学习进度
progress_result = load_learning_progress(user_id, course_id, chapter_name, lesson_name)
if progress_result['exists']:
progress = progress_result['data']
# 恢复学习进度数据
if 'scores' in progress:
# 根据scores的数量确定需要跳过的章节数
need_skip_chapters = len(progress['scores'])
chatmanager.scores = progress['scores']
# 恢复聊天历史
if 'chat_historys' in progress:
chatmanager.chat_historys = progress['chat_historys']
else:
chatmanager.chat_historys = []
# 恢复当前章节链
if 'chapter_chain_now' in progress:
chatmanager.chapter_chain_now = progress['chapter_chain_now']
upload_learning_progress_to_cloud(progress)
else:
# 使用默认值,确保有完整的结构
chatmanager.chat_historys = progress_result['data']['chat_historys']
chatmanager.scores = progress_result['data']['scores']
return need_skip_chapters
def on_login(self, data):
self.current_app = current_app
self.app = current_app
ex = current_app.extensions
uuid2username = ex["uuid2username"]
backboard_manager = ex["backboard_manager"]
@@ -71,67 +129,79 @@ class AgentNamespace(Namespace):
chatmanager = user_uuid2chatmanager[user_uuid]
chatmanager.function_manager.add_function(judge, {'course_id': course_id, 'root_path': f'/home/{user_id}/{course_id}/{chapter_name}/{lesson_name}'})
chatmanager.function_manager.add_function(next_chapter, {'user_uuid': user_uuid})
chatmanager.chat_historys = []
chatmanager.scores = []
clear_user_session(current_app.config["ASE_ENGINE_URL"], current_app.config["ASE_ENGINE_URL_TOKEN"], user_id)
continue_learn = not chatmanager.restart
need_skip_chapters = self._load_learning_progress(chatmanager, user_id, course_id, chapter_name, lesson_name, continue_learn)
fmd,fmdp,fsp = load_full_markdown_file(course_id, chapter_name, lesson_name)
user_uuid2ase_client[user_uuid] = HSAEngineClient(current_app.config["ASE_ENGINE_URL"], current_app.config["ASE_ENGINE_NAMESPACE"],id=user_id, chatmanager = chatmanager)
chatmanager.init(user_uuid, user_uuid2ase_client[user_uuid], fmd, fmdp, fsp, course_id, chapter_name, lesson_name, max_iter=5, app=current_app, socketio=self)
backboard_manager.add_backboard(user_uuid, user_id, course_id, lesson_name, root_path=f'/home/{user_id}/{course_id}/{chapter_name}/{lesson_name}')
chatmanager.init(user_uuid, user_uuid2ase_client[user_uuid],
fmd, fmdp, fsp, course_id, chapter_name, lesson_name, max_iter=5, app=current_app, socketio=self)
backboard_manager.add_backboard(user_uuid, user_id, course_id,
lesson_name, root_path=f'/home/{user_id}/{course_id}/{chapter_name}/{lesson_name}')
chatmanager.bb = backboard_manager.get_backboard(user_uuid)
chatmanager.next_chapter()
# 如果有保存的进度,需要跳过已经完成的章节、无需清空代码、对话历史恢复
if continue_learn:
for _ in range(need_skip_chapters):
chatmanager.next_chapter()
emit('next_chapter', room=user_uuid, namespace='/agent')
# 恢复对话历史到前端 - 使用批量发送消息列表
if chatmanager.chat_historys:
emit('messages', chatmanager.chat_historys, room=user_uuid, namespace='/agent')
self.chatmanager = chatmanager
self.ase_client = user_uuid2ase_client[user_uuid]
user_uuid2ase_client[user_uuid].register_on_connect_entry(
callback=self.on_connect_to_ase,
callback=on_connect_to_ase,
entry=(self, user_uuid2ase_client[user_uuid]),
init_data={'room':user_uuid}
init_data={'room':user_uuid, 'restart':chatmanager.restart}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog',
callback=self.receive_ase_dialog,
callback=receive_ase_dialog,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog-hint',
callback=self.receive_ase_message_hint,
callback=receive_ase_message_hint,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='voice_out',
callback=self.receive_ase_voice_out,
callback=receive_ase_voice_out,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='judge',
callback=self.receive_ase_judge,
callback=receive_ase_judge,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='next_chapter',
callback=self.receive_ase_next_chapter,
callback=receive_ase_next_chapter,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='chapter_score',
callback=self.receive_ase_chapter_score,
callback=receive_ase_chapter_score,
entry=(get_or_load_current_user(),chatmanager),
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='process',
callback=self.receive_ase_process,
callback=receive_ase_process,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='paste_detected',
callback=self.receive_ase_paste_detected,
callback=receive_ase_paste_detected,
entry=self,
init_data={'room': user_uuid}
)
@@ -140,10 +210,8 @@ class AgentNamespace(Namespace):
def on_language(self, language):
ex = current_app.extensions
print(f"Language changed to: {language}")
def on_message(self, data):
print(f"Message from client: {data}")
ex = current_app.extensions
chatmanager = ex["user_uuid2chatmanager"]
uuid = session.get('user_uuid')
@@ -153,14 +221,11 @@ class AgentNamespace(Namespace):
if data['type'] == 'text':
res = user_uuid2ase_client[uuid].send_text('dialog', data['data'])
chatmanager[uuid].chat_historys.append({'role': 'user', 'content': data['data']})
print(f"Send text to route 'dialog' success: {res}")
if data['type'] == 'function': # 在这里 前端会发送允许执行的function与参数
print("function_call", data['data'])
d = data['data']
res = chatmanager[uuid].function_call(d['data']['name'], d['data'].get('args', {}))
d['data'] = res.to_dict()
print("function_call_res", d['data'])
emit(d['route'], d, room=uuid, namespace='/agent')
chatmanager[uuid].chat_historys.append({'role': 'user', 'content': data['data']})
user_uuid2ase_client[uuid].send_text(d['route'], data['data'])
@@ -171,63 +236,9 @@ class AgentNamespace(Namespace):
uuid = session.get('user_uuid')
user_uuid2ase_client = ex["user_uuid2ase_client"]
res = user_uuid2ase_client[uuid].send_voice('voice_in', voice_data['data'])
print(f"Send voicce to route 'voice_in' success: {res}")
def on_connect_to_ase(client_entry: HSAEngineClient, namespace_entry, init_data):
'''
此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。
'''
print(f"on_connect_to_ase {client_entry}, {namespace_entry}, {init_data}")
namespace_entry, ase_client = namespace_entry
namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent')
ase_client.chatmanager.load_next_chapter()
ase_client.send_text("chapter-start", "")
namespace_entry.emit('message', "Multi-Agents服务已连接", room=init_data['room'], namespace='/agent')
#接受消息回来直接让message监听发送
def receive_ase_paste_detected(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_paste_detected", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_dialog(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_dialog", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_message_hint(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_message_hint", data)
namespace_entry.emit('message-hint', data['data'], room=init_data['room'], namespace='/agent')
#接受语音信息
def receive_ase_voice_out(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
namespace_entry.emit('voiceMessage', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_process(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
namespace_entry.emit('process', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_judge(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_judge", data)
namespace_entry.emit('request_function', data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_next_chapter(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_next_chapter", data)
namespace_entry.emit('request_function',data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_chapter_score(client_entry: HSAEngineClient, user_and_manager, data, init_data):
print("load_next_chapter")
user, chatmanager = user_and_manager
if len(chatmanager.scores) >= chatmanager.chapter_chain_now:
chatmanager.scores[chatmanager.chapter_chain_now - 1] = data.get('data', None)
else: chatmanager.scores.append(data.get('data', None))
user.set_course_progress(chatmanager.chat_historys, chatmanager.scores, chatmanager.material_id, chatmanager.chapter_name, chatmanager.lesson_name)
chatmanager.load_next_chapter()
time.sleep(0.5)
chatmanager.ase_client.send_text("chapter-start", "")
def on_initiative(self,data): # 主动call
print("User active function call")
ex = current_app.extensions
uuid = session.get('user_uuid')
manager = ex["user_uuid2chatmanager"][uuid]
@@ -240,10 +251,32 @@ class AgentNamespace(Namespace):
def on_disconnect(self,data):
self.app.logger.info(f'AgentNamespace client disconnected, session user_uuid: {session.get("user_uuid", "unknown")}')
ex = current_app.extensions
uuid = session.get('user_uuid')
user_uuid2chatmanager = ex["user_uuid2chatmanager"]
user_uuid2chatmanager[uuid].disconnect(uuid)
user_uuid2chatmanager = ex.get("user_uuid2chatmanager", {})
print("VSCode client disconnected")
print("Disconnect reason:"+str(data))
# 保存学习进度到MongoDB
try:
# 获取用户ID
uuid2username = ex.get("uuid2username", {})
user_id = uuid2username.get(uuid, "unknown")
self.app.logger.debug(f"get user's dialog his user_id={user_id}")
# 调用保存学习进度功能
chatmanager = user_uuid2chatmanager.get(uuid)
if chatmanager:
save_result = save_learning_progress(chatmanager, user_id)
except Exception as e:
self.app.logger.error(f"保存学习进度时发生异常: {str(e)}")
# 执行原有的断开连接逻辑
if uuid in user_uuid2chatmanager:
user_uuid2chatmanager[uuid].disconnect(uuid)
# 离开房间
if uuid:
leave_room(uuid, namespace='/agent')
self.app.logger.info("VSCode client disconnected")
self.app.logger.info(f"Disconnect reason: {str(data)}")

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>新7周课时</h1>
<h2>阶段一:示例阶段</h2>
<h3>步骤A</h3>
<p>在这里编写该步骤的教学内容(文字/代码/图片链接等)。</p>
<h3>步骤B</h3>
<p>继续补充该阶段的其它步骤内容。</p>
</body>
</html>

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>基于比较的排序</h1>
<h2>基于比较的排序</h2>
<h3>优先队列的原理</h3>
<p><strong>优先队列Priority Queue</strong> 是一种抽象数据结构,它支持以最高(或最低)优先级为先进行元素的插入和取出操作。
<br>
在优先队列中,每次检索得到的都是当前权重最大(或最小)的元素。
<br><br>
为了实现优先队列,最直接的思路是维护一个内部元素有序的数组:每次取数就是第一个数,存数时将元素放入合适位置,并调整序列。
<br><br>
<strong>Heap结构</strong>是实现优先队列的首选。</p>
<h3>数组模拟堆实现的优先队列</h3>
<h4>堆结构与取存原理</h4>
<p>Heap本质上是一个完全二叉树结构
(当然也可以是多叉树,但没有必要)
<img src="https://hsamooc-cdn-1374354408.cos.ap-guangzhou.myqcloud.com/image_b73d7df5-018e-4542-b891-b3711c42c56a" alt="图1大根堆的二叉树结构图示" /></p>
<p>这里用“大根堆”为例,从图中可以看到,每一个节点都比它的子节点更大。
既然“优先队列”可以理解为一种特殊的“队列”,那么我们先用堆实现这个队列的出和入:</p>
<h5>取数</h5>
<p>从优先队列中取数,显然堆顶的数就是要的那个最大者。
但是将这个数取出后还不能结束,因为需要维护堆的性质。
<br>
为了维护堆性质,一般通过将末尾的元素放到堆顶,然后将其不断与左右儿子进行替换,直到他比两个儿子都大或儿子不存在为止,称为“下滤”。
<br></p>
<h5>存数</h5>
<p>与取数类似,重要的是维护堆的性质。将新数放到最后(上图中的第一个黑色节点中)后,将这个数进行“上浮”。</p>
<h4>数组模拟堆</h4>
<p>为了实现堆,其实不需要真的写一个二叉树,用数组就可以做到。
<img src="https://hsamooc-cdn-1374354408.cos.ap-guangzhou.myqcloud.com/image_b4142d35-630c-4d12-a147-d514cca9e0d5" alt="图2左边是堆的数组表示右边是其对应的二叉树" />
如上图,左边是堆的数组表示,右边是其对应的二叉树。</p>
<p>数组下标从1开始任意一个下标i其左右儿子下标刚好就是&quot;i*2&quot;和“i*2+1”。这样在后续代码实现时代码写起来就会简单很多。</p>
<h6>建堆</h6>
<p>用数组模拟堆还有一个好处,就是可以“原地建堆”。
对于一个元素随机的数组只需要O(n)的时间复杂度就可以完成随机数组向堆的转化。</p>
<p>具体做法为“从后向前”遍历,对于每一个非叶子节点,就将其进行“下滤”,这样以它为根的子树就变成一个小堆。往前遍历即可。</p>
<h3>优先队列的算法实现</h3>
<h4>练习:堆操作</h4>
<p>在进行代码实现之前,做一个问题练习:
对于一个随机队列&quot;[3, 32, 6, 43, 5, 8, 0, 9]&quot;,经过一轮反向扫描下滤建大根堆操作,得到的堆的序列是什么?
将答案告知AI教师。</p>
<h4>任务:城市事件优先调度</h4>
<p>现在,让我们通过编程实践来掌握堆排序的实现。假设我们需要对城市中发生的一系列事件按照紧急程度(以数值大小表示优先级)排序,从而依次处理最高优先级的事件。这相当于将一组数字按从大到小排序的过程,与堆排序的机制完全一致。</p>
<p><br><br></p>
<h4>题目:实现堆排序</h4>
<p>请你实现一个堆排序算法 heap_sort(arr),将传入的数组利用堆排序方法排序(从大到小)。
<br>
你可以通过实现max_heapify和build_max_heap等函数来完成这一任务。
<br>
完成编码后,我们将对算法的性能进行测试,比较不同规模输入下堆排序运行时间的增长情况。
<br></p>
<h5>代码框架</h5>
<p>请在下方代码编辑区完成 max_heapify、build_max_heap 和 heap_sort 函数的实现。</p>
<pre><code class="language-python">import random
import time
def max_heapify(arr, n, i):
&quot;&quot;&quot;
维护最大堆性质:假设结点 i 的左右子树已经是最大堆,
调整结点 i 使以其为根的子树成为最大堆
参数:
arr: 存储堆的数组
n: 堆的有效大小(长度)
i: 需要下滤调整的节点索引
&quot;&quot;&quot;
# TODO: 在此处实现 &quot;下滤&quot; 操作,将 arr[i] 下沉到正确位置
def build_max_heap(arr):
&quot;&quot;&quot;
将无序数组原地建成最大堆,从后往前进行下滤
参数:
arr: 待调整的数组
&quot;&quot;&quot;
# TODO: 调用 max_heapify 将 arr 调整为堆
def heap_sort(arr):
&quot;&quot;&quot;
利用堆排序算法排序数组(降序)
参数:
arr: 待排序数组
返回:
排序后的数组(从大到小)
&quot;&quot;&quot;
# TODO: 完成堆排序的实现
n = len(arr)
# 1. 原地建堆
build_max_heap(arr)
# 2. 依次将当前堆顶(最大值)交换到数组末尾,并缩小堆的范围,然后下滤
#性能测试:对比不同规模输入的堆排序用时
def measure(sort_func, data):
start = time.perf_counter_ns()
sort_func(data.copy())
end = time.perf_counter_ns()
return (end - start) / 10**6 # 毫秒
sizes = [1000, 5000, 10000]
print(&quot;堆排序性能测试:&quot;)
for n in sizes:
data = [random.randint(0, 1000000) for _ in range(n)]
t = measure(heap_sort, data)
print(f&quot;数据规模 n={n}: 排序耗时 {t:.2f} ms&quot;)
</code></pre>
<h5>实验结果分析</h5>
<p>请完成并运行上述代码,观察不同输入规模下算法的执行时间。理论上,堆排序的时间复杂度为<eq>O(n \log n)</eq>当输入规模增大时运行时间应呈现近似线性乘以对数的增长趋势。具体来说若将输入规模扩大10倍运行时间将增加约<eq>10 \times \log_2(10) \approx 10 \times 3.3 \approx 33</eq>倍左右。
<br>
相比之下,简单的<eq>O(n^2)</eq>排序算法在相同扩大量级下耗时会增加约100倍。通过与之前插入排序实验的对比你会发现堆排序对规模扩大的响应增长显著缓慢得多。
<br>
这印证了堆排序的效率优势:在最坏情况和平均情况下它都能维持<eq>O(n \log n)</eq>的性能,不会出现如快速排序在极端情况下退化为<eq>O(n^2)</eq>的尴尬局面。
<br>
此外,堆排序是一种原地排序(只需要常数级别的额外空间),这也是相对于归并排序的一个优势。综合来看,利用优先队列实现的堆排序在效率和空间上都表现出色,是一种成熟可靠的排序方法。</p>
<h3>比较排序的决策树模型</h3>
<p>前面的内容介绍了多种基于元素比较的排序算法(比较排序),包括快速排序、堆排序等。接下来,我们讨论一个重要的理论结果:
<br>
在比较模型下,任意排序算法的最优时间复杂度下界为<eq>Ω(n \log n)</eq>
这个结论意味着,无论设计何种巧妙的比较排序算法,都无法突破这一定义上的效率极限。证明这一点的经典工具是决策树模型。</p>
<p>决策树是描述比较排序过程的一种抽象模型。
在排序过程中,每进行一次比较(例如“<eq>A[i] \le A[j]</eq>?”)就相当于根据结果(二叉决策:是/否)将可能的输入情况划分到两个分支。
<br>
整个排序算法的运行过程可以被看作是在这样一棵决策树上从根节点走向某个叶节点的过程。决策树的每个叶节点对应一种可能的输入集合及其确定的输出顺序。
<br>
当有<eq>n</eq>个待排序元素时,假设它们两两各不相同,则可能的输入排列情况共有<eq>n!种(所有元素的全排列)。为了正确地将每种输入排列映射到唯一的输出(即排好序的有序序列),排序算法的决策树必须至少具备</eq>n!个叶节点——每个叶子对应一种输入排列的判别结果。</p>
<p>对于一棵二叉决策树,若包含<eq>L</eq>个叶节点,其高度<eq>h</eq>满足<eq>L \le 2^h</eq>,因此<eq>h \ge \lceil \log_2 L \rceil</eq>。在排序问题中,<eq>L</eq>最少取<eq>n!</eq>,因此最优情况下决策树高度也满足:
<br>
<eq>$h_{\min} \geq \lceil \log_2(n!) \rceil.</eq>$
利用对数运算的性质,可以估计<eq>\log_2(n!)</eq>的数量级。根据斯特林公式近似,<eq>n!</eq>大约为<eq>(n/e)^n</eq>的数量级,那么:
<br>
<eq>$\log_2(n!) \approx \log_2\left((n/e)^n\sqrt{2\pi n}\right) = n\log_2 n - n\log_2 e + O(\log n).</eq>$
<br>
可以看出,当<eq>n</eq>较大时,<eq>\log_2(n!) = Θ(n \log n)</eq>。这意味着决策树的高度下界<eq>h_{\min} = Ω(n \log n)</eq>。换言之,任何基于比较的排序算法在最理想情况下也需要执行与<eq>n \log n</eq>同数量级的比较操作。
<br>
例如,对于$ n=3$的简单情况,<eq>3! = 6</eq>,满足<eq>2^2 < 6 < 2^3</eq>因此判定3个元素的任意排列需要至少3次比较。这与我们已知的事实相符对三个无任何特殊性质的数进行排序最少需要3次比较才能确定它们的正确顺序。</p>
</body>
</html>

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>非比较型排序</h1>
<h2>非比较型排序</h2>
<h3>基数排序原理</h3>
<h4>排序算法复杂度背景</h4>
<p>基于比较的排序算法,理论上无法突破(Ω(n \log n))时间复杂度下界
特殊场景下,不依赖元素间比较、利用元素结构特征,可实现线性时间排序</p>
<h4>基数排序核心思想</h4>
<p>将元素视为由多个 “位”digit组成
按位进行多轮排序,每轮针对一个关键位
依赖稳定的子排序算法,保证先前排好的顺序不被打乱
关键保障:<strong>排序的稳定性(相同元素相对顺序不变)</strong></p>
<h4>经典示例(十进制整数的基数排序):</h4>
<p>按最低有效位LSB→ 最高有效位MSB 逐轮排序
第一轮按个位数排序0-9 分组)
后续轮次:依次按十位数、百位数等排序</p>
<h4>实操流程:</h4>
<p>初始序列:[329, 457, 657, 839, 436]
第一轮(按个位排序):
按个位 0-9 分组 → [436(6), 457(7), 657(7), 329(9), 839(9)]
457 与 657 个位相同,保持原顺序)
第二轮(按十位排序):
按十位 0-9 分组 → [329(2), 436(3), 839(3), 457(5), 657(5)]
436 与 839 十位相同,保持第一轮后的顺序)
第三轮(按百位排序):
按百位 0-9 分组 → [329(3), 436(4), 457(4), 657(6), 839(8)]
436 与 457 百位相同,保持第二轮后的顺序)</p>
<p>每轮排序通过稳定性保留前序结果,最终实现 “高位决定整体顺序”,完成正确排序。</p>
<h4>时间复杂度:</h4>
<p>每轮扫一遍序列O(n)总共轮数就是基数位数设为k。
时间复杂度为O(kn)其中k一般不太大。</p>
<h3>基数排序的实现</h3>
<p>为了加深对基数排序的理解我们通过一个编程任务来实践其实现。我们将以整数排序为例并采用从最低有效位开始的方法对数字进行排序LSD 基数排序)。</p>
<p>在实现过程中,我们需要编写一个按某个位数进行计数排序的辅助函数,然后在主函数中对每一位循环调用该辅助函数。</p>
<h4>用计数排序稳定地对基数排序</h4>
<p>每选定一个基数后需要用O(n)的时间复杂度将当前序列按照这个基数进行排序;或说<strong>找到按照当前基数下,当前序列的每一个数要前往的位置</strong>
这里用“计数排序”这个算法。
具体做法如下:</p>
<ol>
<li>建一个新数组count记录当前基数的每个不同的数有多少个计数</li>
<li>从前往后遍历序列,取出序列每一个元素在当前基数位置上的值,对应计数值+1count[number]+=1</li>
<li>将count转变为前缀和这样就得到了每一个基数对应的原始数的最后一个在新序列中的位置</li>
<li>从后往前遍历原序列将每一个数基数上的数为number放到对应新序列count[number]-1的位置并让count[number]-=1。</li>
</ol>
<h5>练习:按位计数排序</h5>
<p>作为练习,[170, 45, 75, 90, 802, 24, 2, 66]按个位排序exp=1进行计数排序请写下count数组的计数结果2.步后和前缀和结果。并表述在第4步的利用方式从而得到序列结果。</p>
<h4>任务:实现 LSD 基数排序</h4>
<p>请实现 radix_sort(arr) 函数,对传入的整数列表进行从小到大的排序。你可以假设所有整数为非负且位数相对固定。为了简化问题,我们以十进制为基数(基数=10实现并假定输入整数的最大位数为<eq>d</eq>(例如<eq>10^d</eq>数量级)。算法思路如下:</p>
<p>从最低位个位exp=1开始对数组进行计数排序Counting Sort按该位的数值对元素排序。</p>
<p>然后依次向更高一位exp=10exp=100...)进行排序,直到处理完最高位。</p>
<h4>代码框架</h4>
<p>请在下方代码编辑区完成 counting_sort_by_digit 和 radix_sort 函数的实现。</p>
<pre><code class="language-python">import random
import time
def counting_sort_by_digit(arr, exp):
&quot;&quot;&quot;
对数组按某个数位进行计数排序(稳定的)。
参数:
arr: 要排序的数组
exp: 位标志,表示当前要按照 (exp位) 上的数值进行排序,
例如 exp=1 表示按个位排序exp=10 表示按十位排序。
返回:
按指定位排序后的数组副本
&quot;&quot;&quot;
n = len(arr)
output = [0] * n # 存放排序结果
count = [0] * 10 # 计数数组十进制位范围0-9
# TODO: 计算每个数组元素在该位上的值,并计数
def radix_sort(arr):
&quot;&quot;&quot;
基数排序(按从低位到高位逐位排序)
参数:
arr: 待排序的整数列表
返回:
按非递减顺序排序的列表
&quot;&quot;&quot;
#TODO: 利用 counting_sort_by_digit 按位排序数组
#测试与性能分析
def measure(sort_func, data):
start = time.perf_counter_ns()
sort_func(data)
end = time.perf_counter_ns()
return (end - start) / 10**6 # 转换为毫秒
#生成随机测试数据
sizes = [10000, 50000, 100000]
print(&quot;基数排序性能测试:&quot;)
for n in sizes:
data = [random.randrange(0, 1000000) for _ in range(n)]
t = measure(radix_sort, data)
print(f&quot;数据规模 n={n}: 排序耗时 {t:.2f} ms&quot;)
</code></pre>
<p>性能讨论</p>
<p>在完成代码并运行测试后,可以看到基数排序在不同规模输入下运行时间增长相对温和。理想情况下,对于固定位数<eq>d</eq>的整数,基数排序的时间复杂度为<eq>O(d \times (n + k))</eq>,其中<eq>k</eq>为每位可能的取值数量(对十进制整数而言<eq>k=10</eq>)。
<br>
<eq>d</eq>视作常数时,复杂度近似<eq>O(n)</eq>,因此输入规模扩大倍数时,运行时间应近似线性增长。
<br>
然而需注意基数排序的实际常数因子不小多次稳定排序和额外空间对中等规模数据Python实现未必比C语言内建排序更快。
<br>
更重要的是,基数排序并非万能:如果元素位数<eq>d</eq><eq>n</eq>增长(例如排序<eq>1</eq><eq>N</eq>范围的数,<eq>N</eq>约为<eq>n</eq>量级),则<eq>d = O(\log n)</eq>,此时基数排序复杂度<eq>≈O(n \log n)</eq>,并没有打破比较排序下界。因此,基数排序的线性性能依赖于位数相对较小这一前提。</p>
<h3>桶排序的原理与实现</h3>
<h4>桶排序核心定位</h4>
<p>典型的线性时间排序算法
利用输入数据的<strong>分布特性(假设大致均匀分布在某范围)</strong></p>
<h4>桶排序原理与类比</h4>
<h5>核心逻辑(三步):</h5>
<p>第一步:创建若干 “桶”,将元素按值映射到对应桶中(实现粗分类)
第二步:对每个桶内元素单独排序(可选用任意排序算法)
第三步:按桶的顺序合并所有元素,得到有序序列
<br>
映射:将小数按值投入对应桶,每个桶仅覆盖原始区间 1/10数据量少
桶内排序:因元素少,插入排序等简单算法足够快
合并:按桶序号收集,因 “i&lt;j 时,第 i 号桶元素<第 j 号桶元素”,天然有序
<br></p>
<h5>案例练习</h5>
<p>使用桶排序算法对以下 8 个数字进行排序
[35, 12, 48, 27, 5, 39, 18, 42]
以48为分母将其他所有数划分到0-0.2-0.4-0.6-0.8-1这5个桶中输出每个桶里面的数字是什么。
<br><br></p>
<h4>代码实现</h4>
<p>请实现 bucket_sort(arr) 函数,将传入的[0,1)区间的小数数组排序。思路如下:
<br>
创建若干个空桶列表桶的数量可以根据数据规模n选择这里取<eq>n/10</eq>(为整数)个桶。
<br>
将每个元素按照其值乘以桶数量后的整数部分映射到对应的桶中。例如值为0.23、桶数为10时<eq>0.23 \times 10 = 2.3</eq>放入索引2号桶。
<br>
对每个非空桶内部进行排序。你可以直接使用 Python 内置排序sorted或实现简单排序算法。</p>
<p>按桶的顺序依次合并桶内元素,得到排序后的结果。</p>
<h5>代码框架</h5>
<pre><code class="language-python">import random
import time
def bucket_sort(arr):
&quot;&quot;&quot;
桶排序适用于0到1区间的小数
参数:
arr: 介于[0,1)的浮点数列表
返回:
升序排序后的列表
&quot;&quot;&quot;
# TODO: 按原理实现桶排序
n = len(arr)
if n == 0:
return arr
# 1. 创建桶
# 2. 分配元素到各桶
# 3. 桶内排序
#简单测试与性能评估
sizes = [1000, 5000, 10000]
print(&quot;桶排序性能测试:&quot;)
for n in sizes:
#生成n个[0,1)均匀分布的小数
data = [random.random() for _ in range(n)]
start = time.perf_counter_ns()
sorted_data = bucket_sort(data)
end = time.perf_counter_ns()
elapsed = (end - start) / 10**6
#验证排序正确性
is_correct = &quot;&quot; if sorted_data == sorted(data) else &quot;×&quot;
print(f&quot;数据规模 n={n}: 排序耗时 {elapsed:.2f} ms, 排序正确性 {is_correct}&quot;)
</code></pre>
<h4>桶排序时间复杂度分析</h4>
<h5>平均情况(理想分布):</h5>
<p>前提n 个元素、m 个桶,数据均匀分布 → 每个桶平均含 n/m 个元素
优化:选 m 接近 n 的量级 → 桶内排序成本视为常数
结果整体期望运行时间为O(n)(线性时间)</p>
<h5>最坏情况(分布不均):</h5>
<p>极端场景:所有元素落入同一个桶
复杂度取决于桶内排序算法O (n log n) 或 O (n²)+ 分配桶的 O (n)
结果退化至O (n log n) 甚至更差,失去线性时间优势
结论:理想分布下性能出众,数据分布不均时无优势</p>
<h3>桶排序与哈希</h3>
<h4>核心相通点:映射分散思想</h4>
<p>通过函数映射将元素分散到不同 “容器”(桶排序的 “桶”/ 哈希算法的 “哈希桶”),把原问题拆解为小的子问题
<br>
桶排序:用映射函数(如 index = int(num * bucket_count))划分元素所属桶,依据元素值分配
哈希算法:用哈希函数计算键的哈希桶索引,确定元素存储位置
这种映射也意味着都对<strong>分布均匀性的依赖</strong></p>
<h4>关键区别:映射函数的目标差异</h4>
<p>对比维度
桶排序的映射函数:一般直接做除法:保留元素大小顺序(按值范围划分)
哈希算法的哈希函数:随机映射函数,实现元素均匀分布,不能用于排序(除非特殊设计有序哈希)</p>
<h4>总结</h4>
<p>桶排序可看作哈希思想在排序问题中的应用:均通过 “先分类、后处理” 的范式优化性能,但因目标不同,映射函数设计存在本质差异。</p>
</body>
</html>

View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>分治策略进阶与主方法</h1>
<blockquote>
<p>Auto-generated at 2025-09-22 15:44</p>
</blockquote>
<h2>分治策略进阶与主方法</h2>
<h3>最大子数组问题</h3>
<h4>任务:编码与分析</h4>
<h5>场景介绍</h5>
<p>我们的任务是分析每日交通流量的<strong>变化数组</strong>(正数代表流量增加,负数代表减少),并找到哪一个<strong>连续时段</strong>的流量总增量最大。这在算法上被称为“最大子数组问题” 。</p>
<p>例如,对于流量变化数组<code>[13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7]</code>总增量最大的连续时段是从第8天到第11天总增量为 <code>18 + 20 - 7 + 12 = 43</code>
虽然这个问题可以通过O(n2) 的暴力法求解,但我们将使用更高效的分治策略。</p>
<h5>题目:最大交通流量增量</h5>
<p>你需要实现一个分治算法来解决最大子数组问题。
同时,为了对比,你也会实现一个暴力求解算法。</p>
<h5>代码框架</h5>
<p>在下方代码编辑区,完成 <code>find_maximum_subarray</code>(分治法)和 <code>find_max_crossing_subarray</code> 以及 <code>find_maximum_subarray_brute</code>(暴力法)三个函数。</p>
<pre><code class="language-python">import time
import random
def find_maximum_subarray_brute(arr):
&quot;&quot;&quot;
使用暴力法求解最大子数组问题
返回: (最大和, 开始索引, 结束索引)
&quot;&quot;&quot;
#--- 本周任务:请在下方实现分治算法 ---
def find_max_crossing_subarray(arr, low, mid, high):
&quot;&quot;&quot;
找到跨越中点的最大子数组
返回: (最大和, 开始索引, 结束索引)
&quot;&quot;&quot;
# TODO: 实现寻找跨越中点的最大子数组的逻辑
# 提示: 从mid向左和向右分别扫描找到各自的最大和
def find_maximum_subarray(arr, low, high):
&quot;&quot;&quot;
使用分治法求解最大子数组问题
返回: (最大和, 开始索引, 结束索引)
&quot;&quot;&quot;
# TODO: 实现分治递归逻辑
# 提示: 递归基是当数组只有一个元素时
#--- 测试与对比部分 --- 以下内容无需修改
traffic_changes = [13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7]
print(f&quot;交通流量变化数据: {traffic_changes}&quot;)
#使用分治法
max_sum_dc, start_dc, end_dc = find_maximum_subarray(traffic_changes, 0, len(traffic_changes) - 1)
print(f&quot;分治法结果: 最大增量 = {max_sum_dc}, 时段 = Day {start_dc} to Day {end_dc}&quot;)
#使用暴力法验证
max_sum_brute, start_brute, end_brute = find_maximum_subarray_brute(traffic_changes)
print(f&quot;暴力法结果: 最大增量 = {max_sum_brute}, 时段 = Day {start_brute} to Day {end_brute}&quot;)
#性能测试
large_data = [random.randint(-50, 50) for _ in range(2000)]
start_time = time.perf_counter()
find_maximum_subarray(large_data, 0, len(large_data) - 1)
dc_time = (time.perf_counter() - start_time) * 1000
print(f&quot;\n在 n=2000 的数据集上,分治法耗时: {dc_time:.2f} ms&quot;)
start_time = time.perf_counter()
find_maximum_subarray_brute(large_data)
brute_time = (time.perf_counter() - start_time) * 1000
print(f&quot;在 n=2000 的数据集上,暴力法耗时: {brute_time:.2f} ms&quot;)
</code></pre>
<h3>分治经典算法:快速排序</h3>
<h4>快速排序原理</h4>
<p>快速排序Quick Sort是分治法的经典应用之一。与归并排序不同的是快速排序通过原地划分数组来完成排序无需额外的同规模辅助存储空间。它在平均情况下表现极为高效是实际应用中常用的排序算法之一。</p>
<p>快速排序的分治核心思路非常好理解对于一个未排序的数组希望分解为左区间和一个“枢轴pivot”元素和右区间其中左区间的数都小于等于枢轴右区间的都大于等于枢轴。
我们这里用一种固定枢轴法来将序列变成上述的区间情况:
”选取数组第一个元素作为枢轴,用一个移动变量指向数组末尾元素并不断向前移动,直到找到第一个小于枢轴的元素,将该元素与枢轴位置交换,然后从枢轴原位置的下一位个元素开始往后找第一大于的交替,重复上述过程,最终实现以枢轴为界划分出左小右大的区间,再对左右区间递归执行此操作完成排序“</p>
<h4>任务:编码与实验</h4>
<p>现在你需要亲手实现快速排序算法,并通过实验验证其在不同输入情况下的性能差异。</p>
<p>实现 quick_sort(arr) 函数,使用分治策略对传入的数组进行排序。为简单起见,可选择每次固定使用数组的第一个元素作为枢轴,将比枢轴小的元素放在左侧,比枢轴大的放在右侧,然后递归地排序左右子数组。完成实现后,我们将利用性能测试函数对比有序输入(近似最坏情况)和随机输入(平均情况)下算法的运行时间。
代码框架
请在下方代码编辑区完成 quick_sort(arr) 的实现。</p>
<pre><code>import random
import time
def quick_sort(arr):
&quot;&quot;&quot;
实现快速排序算法(固定枢轴策略)
参数arr: 待排序的数组
返回: 排序后的数组
&quot;&quot;&quot;
# TODO: 实现快速排序的分治逻辑
# 提示:选取第一个元素为枢轴,递归排序左右子数组
def measure_performance(sort_func, data):
start_time = time.perf_counter_ns()
sort_func(data.copy()) # 对数据副本排序,避免影响原数据
end_time = time.perf_counter_ns()
return (end_time - start_time) / 10**6 # 转换为毫秒
#性能测试:对比有序输入和随机输入
sizes = [1000, 5000, 10000]
print(&quot;快速排序性能测试(固定枢轴):&quot;)
for n in sizes:
sorted_data = list(range(n))
random_data = [random.randint(0, 10**6) for _ in range(n)]
time_sorted = measure_performance(quick_sort, sorted_data)
time_random = measure_performance(quick_sort, random_data)
print(f&quot;数据规模 n={n}: 有序输入 {time_sorted:.2f} ms, 随机输入 {time_random:.2f} ms&quot;)
</code></pre>
<p>完成编码并运行测试,报告时间差异。</p>
<h3>快速排序的复杂度分析</h3>
<h4>时间复杂度</h4>
<p>基于 “划分 - 递归” 核心逻辑,时间复杂度由<strong>划分效率(枢轴选择)<strong></strong>递归深度</strong>共同决定
三种典型场景:最坏情况、平均情况、最佳情况</p>
<p>最坏情况:每次划分得规模 n-1 和 0 的子数组
平均情况:输入随机 / 等概率选枢轴
最佳情况:每次划分平分数组</p>
<h4>空间复杂度</h4>
<p>快速排序比归并排序更常用的一点在于,快排是“原地的”。</p>
<h3>注入随机进行优化:随机快排与期望复杂度</h3>
<h4>分析</h4>
<p>为了避免固定枢轴选择导致的最坏情况,我们可以引入随机化策略优化快速排序。
随机快排在每次划分时随机选择枢轴,从概率上保证划分均衡,从而将最坏情况表现转化为极低概率事件。
理论上可以证明,随机快排对任何输入的期望时间复杂度为<eq>O(n \log n)</eq>,且大幅降低了出现<eq>O(n^2)</eq>耗时的概率。</p>
<h4>实践</h4>
<pre><code>import random
import time
def random_quick_sort(arr):
&quot;&quot;&quot;
实现随机快速排序算法(随机选择枢轴)
参数arr: 待排序的数组
返回: 排序后的数组
&quot;&quot;&quot;
def quick_sort(arr):
&quot;&quot;&quot;
你在上一章实现的快排内容
&quot;&quot;&quot;
#验证随机快排在极端有序输入下的性能
n = 10000
sorted_data = list(range(n))
time_fixed = measure_performance(quick_sort, sorted_data)
time_random = measure_performance(random_quick_sort, sorted_data)
print(f&quot;数据规模 n={n}: 固定枢轴快排 {time_fixed:.2f} ms, 随机枢轴快排 {time_random:.2f} ms&quot;)
</code></pre>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>动态规划例题训练</h1>
<h2>动态规划例题训练</h2>
<h3>步骤A</h3>
<p>在这里编写该步骤的教学内容(文字/代码/图片链接等)。</p>
<h3>步骤B</h3>
<p>继续补充该阶段的其它步骤内容。</p>
</body>
</html>

View File

@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>动态规划原理</h1>
<h2>动态规划原理</h2>
<h3>自顶而下的分治 vs. 自底向上的动态规划</h3>
<p>分治法:面对规模为 <em>n</em> 的问题,从<strong>顶层</strong>出发,将其拆为若干个更小的子问题,分别求解后<strong>合并</strong></p>
<p>动态规划DP<strong>同一子问题重复出现</strong>时,与其一再从顶层“把问题拆碎”,不如记录子问题的答案,并按规模<strong>从小到大</strong>把所有需要的子问题一次性求出来。
<br></p>
<h4>什么才算“同一”子问题</h4>
<p>与AI教师讨论如何区分“相同规模的子问题”和“同一可复用的子问题”</p>
<h3>切绳Rod Cutting问题</h3>
<p>给定一根长度为 <em>n</em> 的绳子,价格表 <code>price[i]</code> 表示长度为 <em>i</em> 的一段可以卖出的价格。允许将绳子切成多段出售,目标是使总收益最大。</p>
<h4>分治法与时间复杂度</h4>
<p><code>R(n)</code> 为长度 <em>n</em> 的最大收益:</p>
<section>
<eqn> R(0) = 0, R(1)=price[1] </eqn>
</section>
<section>
<eqn> R(n) = max_{1 ≤ i ≤ n} ( price[i] + R(n - i) ) </eqn>
</section>
<p><strong>纯递归分治</strong>会对同一规模的子问题多次求解,子问题规模组合数呈指数级,时间复杂度为 <strong>O(n^n)</strong> 量级。</p>
<h4>记忆化(自顶向下)</h4>
<p>思想:用哈希表/数组 <code>memo[n]</code> 记录 <code>R(n)</code>。当再次需要 <code>R(n)</code> 时,直接返回已存结果,避免重复计算。</p>
<ul>
<li><strong>复杂度</strong>:时间 <strong>O(n^2)</strong>(外层 n内层枚举切第一刀 i空间 <strong>O(n)</strong></li>
</ul>
<p><strong>代码任务 A实现记忆化递归Top-Down</strong></p>
<pre><code class="language-python">def rod_cut_topdown(price: dict[int, int], n: int, memo: list[int]) -&gt; int:
&quot;&quot;&quot;
返回长度 n 的最大收益(记忆化递归)。
TODO:
1) 处理 n==02) 命中 memo 直接返回3) 枚举第一刀长度 i4) 写回 memo[n]。
&quot;&quot;&quot;
pass
</code></pre>
<h4>自底向上(反向记忆化)</h4>
<p>将规模从小到大推进:<code>dp[x]</code> 表示长度 <code>x</code> 的最优收益。
<strong>代码任务 B实现自底向上Bottom-Up</strong></p>
<pre><code class="language-python">def rod_cut_bottomup(price: dict[int, int], n: int) -&gt; int:
&quot;&quot;&quot;
返回长度 n 的最大收益(自底向上)。
TODO:
1) 初始化 dp[0]=02) for x in 1..ndp[x] = max_{1..x}( price[i] + dp[x-i] )
&quot;&quot;&quot;
&quot;&quot;&quot;
以下内容无需修改注意将你实现的rod_cut_topdown代码复制过来
&quot;&quot;&quot;
import time
import random
import signal
from functools import wraps
#超时异常定义
class TimeoutError(Exception):
pass
#超时装饰器
def timeout(seconds):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 定义超时处理函数
def handle_timeout(signum, frame):
raise TimeoutError(f&quot;Function {func.__name__} timed out after {seconds} seconds&quot;)
# 设置信号处理
signal.signal(signal.SIGALRM, handle_timeout)
signal.alarm(seconds) # 触发超时
try:
result = func(*args, **kwargs)
return result
finally:
signal.alarm(0) # 取消超时
return wrapper
return decorator
#带超时的纯递归实现(用于对比)
@timeout(1) # 1秒超时
def rod_cut_recursive(price: dict[int, int], n: int) -&gt; int:
if n == 0:
return 0
max_rev = -float('inf')
for i in range(1, n + 1):
if i in price:
max_rev = max(max_rev, price[i] + rod_cut_recursive(price, n - i))
return max_rev
#对比实验
def compare_algorithms():
# 生成测试用的价格表随机生成1到10的价格
max_length = 50
price = {i: random.randint(1, 10) for i in range(1, max_length + 1)}
print(f&quot;{'n':&lt;5} {'递归(ms)':&lt;10} {'记忆化(ms)':&lt;12} {'自底向上(ms)':&lt;15}&quot;)
print(&quot;-&quot; * 50)
# 测试n从10到50的情况
for n in range(10, 51, 5):
# 纯递归(带超时处理)
try:
start = time.time()
recursive_result = rod_cut_recursive(price, n)
recursive_time = (time.time() - start) * 1000
except TimeoutError:
recursive_result = &quot;超时&quot;
recursive_time = &quot;&gt;1000&quot;
# 记忆化递归
memo = [-1] * (n + 1)
start = time.time()
topdown_result = rod_cut_topdown(price, n, memo)
topdown_time = (time.time() - start) * 1000
# 自底向上
start = time.time()
bottomup_result = rod_cut_bottomup(price, n)
bottomup_time = (time.time() - start) * 1000
# 验证结果一致性(仅当递归未超时)
if isinstance(recursive_result, int):
assert recursive_result == topdown_result == bottomup_result, f&quot;结果不一致 for n={n}&quot;
# 输出结果
print(f&quot;{n:&lt;5} {recursive_time:&lt;10} {topdown_time:&lt;12.4f} {bottomup_time:&lt;15.4f}&quot;)
if __name__ == &quot;__main__&quot;:
compare_algorithms()
</code></pre>
<p>完成上面的代码,讨论实验对比结果。</p>
<h3>动态规划的“组成”:如何写出一个 DP</h3>
<h4><strong>四大组成</strong></h4>
<ul>
<li><strong>状态空间</strong>:用最少的下标(或维度)刻画子问题(如 <code>dp[i]</code><code>dp[i][j]</code>)。</li>
<li><strong>状态转移</strong>:写出“从更小状态到当前状态”的递推/转移式。</li>
<li><strong>边界条件</strong>:初始已知的最小规模答案(如 <code>dp[0]=0</code>)。</li>
<li><strong>解的恢复(部分题目可能不需要)</strong>:若需输出方案/路径,记录子问题选择来源(从那个子问题的答案转移而来)(如 <code>choice[i][j]</code>)。</li>
</ul>
<h4><strong>两大性质</strong></h4>
<ul>
<li><strong>最优子结构</strong>:全局最优由若干子问题的最优解组合而成;</li>
<li><strong>重复子问题</strong>:不同路径会遇到同一个(或等价的)子问题。</li>
</ul>
<h3>例题矩阵连乘Matrix-Chain Multiplication, MCM</h3>
<h4>问题描述</h4>
<p>矩阵乘法有严格的维度匹配要求:只有前一个矩阵的列数 = 后一个矩阵的行数,才能相乘。
即:
矩阵 A维度为 m × k共 m 行、k 列)
矩阵 B维度为 k × n共 k 行、n 列)
矩阵 C = A×B维度为 m × n
产生标量乘法次数为 <code>m×n×k</code>
给定矩阵链 <code>A₁A₂…Aₖ</code>,维度数组 <code>p[0..k]</code> 满足 <code>Aᵢ</code> 大小为 <code>p[i-1] × p[i]</code>。目标:只改变乘法<strong>括号化顺序</strong>,最小化标量乘法次数。</p>
<h4>递推与 DP 表</h4>
<p><code>m[i][j]</code> 表示从 <code>Aᵢ…Aⱼ</code> 的最小乘法次数1-index。则</p>
<section>
<eqn> m[i][i] = 0 </eqn>
</section>
<section>
<eqn> m[i][j] = min_{i ≤ k < j} ( 尝试推导一下这里的转移式 ) </eqn>
</section>
<h4>记录断点</h4>
<p><code>s[i][j]</code> 存储从 <code>Aᵢ…Aⱼ</code> 的最优断点。
<eq>$ s[i][j] = k \text{ if } m[i][j] == 与上面的式子一样 $</eq></p>
<h4>代码任务</h4>
<pre><code class="language-python">def matrix_chain_order(p: list[int]) -&gt; tuple[list[list[int]], list[list[int]]]:
&quot;&quot;&quot;
返回 (m, s)m[i][j] 为最小代价s[i][j] 为最优断点。
TODO: 长度 n = len(p)-1按区间长度 L=2..n 填表。
&quot;&quot;&quot;
...
def print_optimal_parens(s: list[list[int]], i: int, j: int) -&gt; str:
&quot;&quot;&quot;根据断点矩阵 s 输出最优括号化方案&quot;&quot;&quot;
if i == j:
return f&quot;A{i}&quot;
else:
return f&quot;({print_optimal_parens(s, i, s[i][j])}&quot; \
f&quot;{print_optimal_parens(s, s[i][j]+1, j)})&quot;
#测试验证部分
if __name__ == &quot;__main__&quot;:
# 经典测试案例
p = [30, 35, 15, 5, 10, 20, 25]
expected_result = 15125
# 计算最优解
m, s = matrix_chain_order(p)
n = len(p) - 1
result = m[1][n]
# 输出测试结果
print(f&quot;矩阵维度数组: {p}&quot;)
print(f&quot;矩阵数量: {n}&quot;)
print(f&quot;计算得到的最小标量乘法次数: {result}&quot;)
print(f&quot;预期的最小标量乘法次数: {expected_result}&quot;)
print(f&quot;测试{'通过' if result == expected_result else '失败'}&quot;)
print(f&quot;最优括号化方案: {print_optimal_parens(s, 1, n)}&quot;)
# 额外测试案例
p2 = [40, 20, 30, 10, 30]
m2, s2 = matrix_chain_order(p2)
print(&quot;\n第二个测试案例:&quot;)
print(f&quot;矩阵维度数组: {p2}&quot;)
print(f&quot;最小标量乘法次数: {m2[1][4]}&quot;) # 预期结果为 26000
print(f&quot;最优括号化方案: {print_optimal_parens(s2, 1, 4)}&quot;)
</code></pre>
<h4>综合讨论</h4>
<ul>
<li>何时选“记忆化 Top-Down”何时选“自底向上表格法”</li>
<li>如何从“纯递归”快速判断是否值得改造成 DP</li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<!-- 你的其他样式 -->
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<script>
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\(', '\)']]
},
svg: {
fontCache: 'global'
}
};
</script>
</head>
<body>
<h1>分位统计量</h1>
<h2>分位统计量</h2>
<h3>第K大的数</h3>
<p>在许多算法与数据分析任务中,我们不仅需要排序整个数组,还需要找出某个特定顺序统计量,例如:
• 一个数组中第 K 大的元素;
• 所有元素的中位数K = n/2
• 某个分位点25%、75%等)…
<br>
最直接的办法是先对数组进行排序,然后取第 K 个位置的元素。这种方法的时间复杂度为 <eq>O(n \log n)</eq>,因为我们需要对所有元素进行排序。
<br>
但我们真正关心的,只是一个位置的元素 —— 我们是否有更快的方法?
<br></p>
<h4>快速选择QuickSelect</h4>
<p><strong>快排中的“轴枢”启发</strong>
我们在快速排序中,每次选一个枢轴 pivot通过划分操作把数组分成两部分
• 左边元素 &lt;= pivot
• 右边元素 &gt;= pivot。
最终 pivot 会被放置固定位置上。
<br>
可见:</p>
<blockquote>
<p>若 pivot 恰好是我们要找的第 K 大元素,就不必继续递归。</p>
</blockquote>
<br>
因此可得快速选择的具体做法先令target等于N-K即找第N-K+1小的数其下标为target
1. 随机选一个枢轴;
2. 利用快速排序的 partition 划分函数,找出 pivot 的位置 idx
3. 比较该位置与目标索引:
• 若 idx == target返回 pivot
• 若 idx > target在左半边递归查找
• 否则在右半边递归查找。
<br>
理解做法并分析时间复杂度。
### 快速选择的实现
<p>实现一个 <code>quickselect(arr, k)</code> 函数,返回第 K 大的元素
也就是增序列下标N-K位置的数</p>
<pre><code class="language-python">import random
def partition(arr, low, high):
&quot;&quot;&quot;
对arr序列从low到high下标找到一个枢轴并返回其下标。在arr中比枢轴小的数都在idx左边大的在右边
&quot;&quot;&quot;
def quickselect(arr, k):
&quot;&quot;&quot;
快速选择第K大元素转换为第n-k小
:param arr: 输入数组
:param k: 要找的第K大元素
:return: 该元素值
&quot;&quot;&quot;
n = len(arr)
target = n - k # 第k大转为第n-k小序列下标
low, high = 0, n - 1
while low &lt;= high:
idx = partition(arr, low, high)
if idx == target:
return arr[idx]
elif idx &lt; target:
low = idx + 1
else:
high = idx - 1
if __name__ == &quot;__main__&quot;:
#测试1随机数组
random.seed(42) # 设置随机种子,确保结果可重现
arr1 = [random.randint(1, 100) for _ in range(10)]
k1 = 3
result1 = quickselect(arr1.copy(), k1) # 使用副本避免修改原数组
sorted_arr1 = sorted(arr1)
expected1 = sorted_arr1[-k1]
print(f&quot;测试1 - 随机数组:&quot;)
print(f&quot;原数组: {arr1}&quot;)
print(f&quot;第{k1}大元素: {result1}, 预期值: {expected1}&quot;)
print(f&quot;测试{'通过' if result1 == expected1 else '失败'}\n&quot;)
#测试2包含重复元素的数组
arr2 = [5, 3, 8, 8, 1, 5, 9, 3, 5]
k2 = 2
result2 = quickselect(arr2.copy(), k2)
sorted_arr2 = sorted(arr2)
expected2 = sorted_arr2[-k2]
print(f&quot;测试2 - 包含重复元素:&quot;)
print(f&quot;原数组: {arr2}&quot;)
print(f&quot;第{k2}大元素: {result2}, 预期值: {expected2}&quot;)
print(f&quot;测试{'通过' if result2 == expected2 else '失败'}\n&quot;)
#测试3边界条件 - k=1最大元素
arr3 = [10, 20, 30, 40, 50]
k3 = 1
result3 = quickselect(arr3.copy(), k3)
sorted_arr3 = sorted(arr3)
expected3 = sorted_arr3[-k3]
print(f&quot;测试3 - 最大元素:&quot;)
print(f&quot;原数组: {arr3}&quot;)
print(f&quot;第{k3}大元素: {result3}, 预期值: {expected3}&quot;)
print(f&quot;测试{'通过' if result3 == expected3 else '失败'}\n&quot;)
</code></pre>
<h3>BFPRT 算法:中位数的中位数算法</h3>
<p>快速选择的平均复杂度为 <eq>O(n)</eq>,但可能退化为 <eq>O(n^2)</eq></p>
<h4>BFPRT分治法</h4>
<p>BFPRT算法和二分查找中对“矩阵”进行分治的做法很像也是通过矩阵的中心数来排除一部分数据。
<img src="https://hsamooc-cdn-1374354408.cos.ap-guangzhou.myqcloud.com/image_4d626b28-8e7e-4c19-8238-daaaba079a5f" alt="image" />
<br>
我们通过以下几个步骤确保选出的“轴枢”可以高概率地“削减”足够多的元素,从而保证递归过程每次至少减少 30% 左右的输入规模:
<br></p>
<ol>
<li>将原始数组划分为若干个 5 个元素的组
请看图中所有的竖列(粉红色背景小矩形):每个小矩形内有 5 个点,这就是我们将原始数据分成的小组。
组的个数 ≈ n / 5设 n 是总元素数量)
不足 5 个的组,可以用一个全局最小值(如 -1补全。
<br></li>
<li>对每组内部排序,取出中位数
图中每个竖列的小组中,绿色圆点表示每组的元素。中间处于红框中的数为每组中位数。
每列上两个数小于中位数,下两个大于。
<br></li>
<li>将所有组的中位数组成新数组,对其递归调用本算法找中位数。
图中红色矩形框住的内容,正是“所有组中位数”的集合。这些点来自所有小组的中位点,组成新的长度为 n/5 的数组。
对这个新数组再次使用本算法,递归找出它的中位数,作为我们最终要用的“枢轴”。即图中棕色点。
<br></li>
<li>这个中位数(棕色圆点)就是我们分治的“枢轴”
有如下重要性质:
a. 棕点及左上角(图中蓝框的点)均小于等于枢轴
b. 棕点及右下角(图中黑框的点)均大于等于枢轴
<br></li>
<li>进行排除分解先令x=n-k+1找第k大数就是找第x小数
a. 若x&gt;蓝框数数量则在非蓝框数中找第k大
b. 若k&gt;黑框数数量则在非黑框数中找第k-黑框数数量)大
c. 当k=1或n'n'是当前子问题的数数量或n'&lt;=5均可在不大于O(n')的用时完成找数。
可以证明在c不成立的情况下a,b两点至少成立一个。
<br></li>
</ol>
<p>与AI教师讨论每一步在做的事和最终想要实现的效果。可以联想你之前学习到的二分查找中的矩阵分治。</p>
<h3>BFPRT 复杂度分析</h3>
<p>在理解了“中位数的中位数”算法的流程之后,我们接下来要正式分析它的<strong>时间复杂度</strong>,并证明该算法的整体运行时间为 <eq>O(n)</eq></p>
<hr />
<h4>写出递推式</h4>
<p>根据 BFPRT 算法的执行过程依次与AI教师讨论每步的时间复杂度
<br></p>
<ol>
<li><strong><eq>n</eq> 个元素分组、找出每组中位数</strong>
<ul>
<li>分成 <eq>n/5</eq> 组,每组排序并取中位数。
<br></li>
</ul>
</li>
<li><strong>递归地找所有中位数的中位数(枢轴)</strong>
<ul>
<li>子问题规模为 <eq>n/5</eq>
<br></li>
</ul>
</li>
<li><strong>额外一次线性 partition</strong>
<ul>
<li>对全数组按枢轴划分:蓝框部分、黑框部分,此外递归时是对非蓝框或非黑框的补集部分。
<br></li>
</ul>
</li>
<li><strong>将原始数组按枢轴划分,并在一侧继续递归</strong>
<ul>
<li>对非蓝框或非黑框数进行递归查询。
<br></li>
</ul>
</li>
</ol>
<p><img src="https://hsamooc-cdn-1374354408.cos.ap-guangzhou.myqcloud.com/image_4d626b28-8e7e-4c19-8238-daaaba079a5f" alt="image" /></p>
<p>从而得到最终的递推式。</p>
<hr />
<h4>证明 <eq>T(n) = O(n)</eq></h4>
<p>这里的证明并不复杂,使用高中学习的数学归纳法即可。
提示先假设满足T(n)&lt;=cn然后证明递推式&lt;=d*cn&lt;=cn其中d小于1。</p>
<p>与AI教师探讨证明流程。</p>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,51 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.fit = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function proposeGeometry(term) {
if (!term.element.parentElement) {
return null;
}
var parentElementStyle = window.getComputedStyle(term.element.parentElement);
var parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
var parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
var elementStyle = window.getComputedStyle(term.element);
var elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
var elementPaddingVer = elementPadding.top + elementPadding.bottom;
var elementPaddingHor = elementPadding.right + elementPadding.left;
var availableHeight = parentElementHeight - elementPaddingVer;
var availableWidth = parentElementWidth - elementPaddingHor - term._core.viewport.scrollBarWidth;
var geometry = {
cols: Math.floor(availableWidth / term._core.renderer.dimensions.actualCellWidth),
rows: Math.floor(availableHeight / term._core.renderer.dimensions.actualCellHeight)
};
return geometry;
}
exports.proposeGeometry = proposeGeometry;
function fit(term) {
var geometry = proposeGeometry(term);
if (geometry) {
if (term.rows !== geometry.rows || term.cols !== geometry.cols) {
term._core.renderer.clear();
term.resize(geometry.cols, geometry.rows);
}
}
}
exports.fit = fit;
function apply(terminalConstructor) {
terminalConstructor.prototype.proposeGeometry = function () {
return proposeGeometry(this);
};
terminalConstructor.prototype.fit = function () {
fit(this);
};
}
exports.apply = apply;
},{}]},{},[1])(1)
});
//# sourceMappingURL=fit.js.map

View File

@@ -0,0 +1,27 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.fullscreen = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function toggleFullScreen(term, fullscreen) {
var fn;
if (typeof fullscreen === 'undefined') {
fn = (term.element.classList.contains('fullscreen')) ? 'remove' : 'add';
}
else if (!fullscreen) {
fn = 'remove';
}
else {
fn = 'add';
}
term.element.classList[fn]('fullscreen');
}
exports.toggleFullScreen = toggleFullScreen;
function apply(terminalConstructor) {
terminalConstructor.prototype.toggleFullScreen = function (fullscreen) {
toggleFullScreen(this, fullscreen);
};
}
exports.apply = apply;
},{}]},{},[1])(1)
});
//# sourceMappingURL=fullscreen.js.map

View File

@@ -0,0 +1,126 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.search = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var SearchHelper = (function () {
function SearchHelper(_terminal) {
this._terminal = _terminal;
}
SearchHelper.prototype.findNext = function (term) {
if (!term || term.length === 0) {
return false;
}
var result;
var startRow = this._terminal._core.buffer.ydisp;
if (this._terminal._core.selectionManager.selectionEnd) {
startRow = this._terminal._core.selectionManager.selectionEnd[1];
}
for (var y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) {
result = this._findInLine(term, y);
if (result) {
break;
}
}
if (!result) {
for (var y = 0; y < startRow; y++) {
result = this._findInLine(term, y);
if (result) {
break;
}
}
}
return this._selectResult(result);
};
SearchHelper.prototype.findPrevious = function (term) {
if (!term || term.length === 0) {
return false;
}
var result;
var startRow = this._terminal._core.buffer.ydisp;
if (this._terminal._core.selectionManager.selectionStart) {
startRow = this._terminal._core.selectionManager.selectionStart[1];
}
for (var y = startRow - 1; y >= 0; y--) {
result = this._findInLine(term, y);
if (result) {
break;
}
}
if (!result) {
for (var y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) {
result = this._findInLine(term, y);
if (result) {
break;
}
}
}
return this._selectResult(result);
};
SearchHelper.prototype._findInLine = function (term, y) {
var lowerStringLine = this._terminal._core.buffer.translateBufferLineToString(y, true).toLowerCase();
var lowerTerm = term.toLowerCase();
var searchIndex = lowerStringLine.indexOf(lowerTerm);
if (searchIndex >= 0) {
var line = this._terminal._core.buffer.lines.get(y);
for (var i = 0; i < searchIndex; i++) {
var charData = line[i];
var char = charData[1];
if (char.length > 1) {
searchIndex -= char.length - 1;
}
var charWidth = charData[2];
if (charWidth === 0) {
searchIndex++;
}
}
return {
term: term,
col: searchIndex,
row: y
};
}
};
SearchHelper.prototype._selectResult = function (result) {
if (!result) {
return false;
}
this._terminal._core.selectionManager.setSelection(result.col, result.row, result.term.length);
this._terminal.scrollLines(result.row - this._terminal._core.buffer.ydisp);
return true;
};
return SearchHelper;
}());
exports.SearchHelper = SearchHelper;
},{}],2:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var SearchHelper_1 = require("./SearchHelper");
function findNext(terminal, term) {
var addonTerminal = terminal;
if (!addonTerminal.__searchHelper) {
addonTerminal.__searchHelper = new SearchHelper_1.SearchHelper(addonTerminal);
}
return addonTerminal.__searchHelper.findNext(term);
}
exports.findNext = findNext;
function findPrevious(terminal, term) {
var addonTerminal = terminal;
if (!addonTerminal.__searchHelper) {
addonTerminal.__searchHelper = new SearchHelper_1.SearchHelper(addonTerminal);
}
return addonTerminal.__searchHelper.findPrevious(term);
}
exports.findPrevious = findPrevious;
function apply(terminalConstructor) {
terminalConstructor.prototype.findNext = function (term) {
return findNext(this, term);
};
terminalConstructor.prototype.findPrevious = function (term) {
return findPrevious(this, term);
};
}
exports.apply = apply;
},{"./SearchHelper":1}]},{},[2])(2)
});
//# sourceMappingURL=search.js.map

View File

@@ -0,0 +1,41 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.webLinks = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var protocolClause = '(https?:\\/\\/)';
var domainCharacterSet = '[\\da-z\\.-]+';
var negatedDomainCharacterSet = '[^\\da-z\\.-]+';
var domainBodyClause = '(' + domainCharacterSet + ')';
var tldClause = '([a-z\\.]{2,6})';
var ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
var localHostClause = '(localhost)';
var portClause = '(:\\d{1,5})';
var hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
var pathClause = '(\\/[\\/\\w\\.\\-%~]*)*';
var queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
var queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
var hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
var negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
var bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
var start = '(?:^|' + negatedDomainCharacterSet + ')(';
var end = ')($|' + negatedPathCharacterSet + ')';
var strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
function handleLink(event, uri) {
window.open(uri, '_blank');
}
function webLinksInit(term, handler, options) {
if (handler === void 0) { handler = handleLink; }
if (options === void 0) { options = {}; }
options.matchIndex = 1;
term.registerLinkMatcher(strictUrlRegex, handler, options);
}
exports.webLinksInit = webLinksInit;
function apply(terminalConstructor) {
terminalConstructor.prototype.webLinksInit = function (handler, options) {
webLinksInit(this, handler, options);
};
}
exports.apply = apply;
},{}]},{},[1])(1)
});
//# sourceMappingURL=webLinks.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -107,13 +107,7 @@
.btn-group-toggle .btn {
padding: 5px 10px;
}
#leftSidebar, .vscode-web, #rightSidebar {
height: 100%;
overflow: auto;
}
/* 主容器,使用 grid 布局 */
/* 主容器,使用 grid 布局 */
#maxcontainer {
display: grid;
grid-template-columns: 2fr 5px 3fr 5px 2fr; /* 默认宽度比例 */
@@ -122,7 +116,20 @@
/* 各区域基础样式 */
.sidebar {
overflow: auto; /* 确保内容可以滚动 */
display: flex;
flex-direction: column;
overflow: hidden; /* 隐藏溢出内容,让内部元素单独滚动 */
}
/* 左侧边栏样式 */
#leftSidebar {
height: 100%;
}
/* 中间和右侧边栏样式 */
.vscode-web, #rightSidebar {
height: 100%;
overflow: auto;
}
.vscode-web {
@@ -162,55 +169,75 @@
/* Markdown内容区域样式 */
#markdown-content {
flex: 1; /* 占满剩余空间 */
overflow-y: auto; /* 只有内容区域可以垂直滚动 */
overflow-x: hidden; /* 隐藏水平滚动条 */
}
/* 设置进度条容器样式 */
#markdown-content-process {
display: flex;
align-items: flex-end; /* 让进度条靠底部对齐 */
padding: 0 10px 10px 10px; /* 添加底部内边距,调整与底部的距离 */
box-sizing: border-box;
flex-shrink: 0; /* 防止进度条被压缩 */
}
/* 设置每个子框的基本样式 */
.progress-box {
height: 100%; /* 度为 100%,撑满容器 */
width: 100%; /* 度为 100%,撑满容器 */
display: flex;
flex-direction: row; /* 使子框横向排列 */
justify-content: flex-start;
gap: 2px; /* 进度条之间的间距 */
margin-bottom: 0; /* 移除底部边距 */
}
.progress-title {
height: 60px;
text-align: center;
line-height: 60px;
margin-right: 2px;
border: 1px solid #ddd;
transition: background-color 0.3s ease;
height: 8px; /* 更细的进度条 */
flex: 1; /* 均匀分配宽度 */
border-radius: 4px; /* 圆角设计,更扁平化 */
transition: all 0.3s ease; /* 平滑过渡效果 */
position: relative;
/* 新增以下属性实现文字溢出省略 */
white-space: nowrap; /* 防止文字换行 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 溢出部分显示为省略号 */
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); /* 轻微阴影,增加层次感 */
overflow: visible; /* 允许文字溢出 */
}
/* 颜色方案:已学习为蓝色,当前学习为绿色,未学习为白色 */
.blue {
background-color: #007bff; /* 蓝色 - 已学习 */
}
.green {
background-color: green;
color: white;
background-color: #28a745; /* 绿色 - 当前学习 */
}
.white {
background-color: white;
color: black;
background-color: #e9ecef; /* 浅灰色 - 未学习,比纯白色更美观 */
}
.progress-box > .progress-title:last-child {
margin-bottom: 0; /* 防止最后一个子框出现多余的间距 */
/* 进度条悬停效果 */
.progress-title:hover {
transform: translateY(-1px); /* 轻微上浮效果 */
box-shadow: 0 2px 5px rgba(0,0,0,0.2); /* 增强阴影 */
}
/* 进度条详细信息的样式 */
#progress-detail {
position: absolute;
top: 0;
left: 0;
display: none;
padding: 10px;
background-color: rgba(0, 0, 0, 0.7);
background-color: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 5px;
border-radius: 6px;
max-width: 200px;
font-size: 14px;
z-index: 1000;
pointer-events: none; /* 防止干扰鼠标事件 */
box-shadow: 0 2px 10px rgba(0,0,0,0.3); /* 增强阴影 */
}
.button {

View File

@@ -0,0 +1,51 @@
/* HelpTip 组件样式 */
/* 问号图标样式 */
.help-icon {
display: inline-block;
margin-left: 8px;
color: #888;
font-weight: bold;
cursor: help;
font-size: 14px;
line-height: 1;
transition: color 0.2s ease;
}
.help-icon:hover {
color: #4CAF50;
}
/* 全局提示框样式(挂在 body 下) */
.tooltip-portal {
position: fixed; /* 不受父级 overflow 影响 */
left: 0;
top: 0;
z-index: 999999; /* 足够高,覆盖所有元素 */
display: none;
opacity: 0;
transition: opacity 0.08s linear;
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
line-height: 1.4;
pointer-events: none; /* 鼠标穿透,避免闪烁 */
white-space: pre-wrap; /* 支持换行符 */
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
word-wrap: break-word;
}
/* 提示框显示状态 */
.tooltip-portal.show {
display: block;
opacity: 1;
}
/* 提示框动画效果 */
.tooltip-portal.fade {
transition: opacity 0.2s ease;
}

View File

@@ -1,35 +1,3 @@
/* 问号图标 */
.help-icon {
display: inline-block;
margin-left: 8px;
color: #888;
font-weight: bold;
cursor: help;
}
.help-icon:hover { color: #4CAF50; }
/* Portal Tooltip挂在 body 下) */
.tooltip-portal {
position: fixed; /* 不受父级 overflow 影响 */
left: 0; top: 0;
z-index: 999999; /* 足够高,覆盖侧栏等 */
display: none;
opacity: 0;
transition: opacity .08s linear;
background: rgba(0,0,0,.9);
color: #fff;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
line-height: 1.4;
pointer-events: none; /* 鼠标穿透,避免闪烁 */
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0,0,0,.25);
}
.add-step-btn, .add-phase-btn {
background-color: #4CAF50;
color: white;

View File

@@ -3,13 +3,28 @@
background: linear-gradient(135deg, var(--primary-blue), var(--accent-blue));
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 0.8rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.navbar .logo h1 {
color: rgb(245, 245, 245);
font-size: 24px;
font-size: 18px;
font-weight: bold;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.navbar-links {
display: flex;
justify-content: center;
flex: 1;
margin: 0 20px;
}
.navbar-links a {
@@ -70,3 +85,40 @@
.dropdown:hover .dropdown-content {
display: block;
}
/* 响应式设计 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
}
.navbar-links {
margin: 0;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.navbar-links a {
margin: 0 0.75rem;
font-size: 16px;
}
.navbar .logo h1 {
font-size: 16px;
max-width: 250px;
}
}
@media (max-width: 480px) {
.navbar .logo h1 {
font-size: 14px;
max-width: 200px;
}
.navbar-links a {
margin: 0 0.5rem;
font-size: 14px;
}
}

View File

@@ -2,6 +2,57 @@
var socket;
let system_message_idx = 0
const activeApprovals = {};
let chatManager; // 全局聊天管理器实例
// 聊天消息管理器 - 更现代的数据结构
class ChatMessageManager {
constructor() {
this.chatbox = document.getElementById('chatbox');
this.messageTemplate = {
user: `<div class="chat-message user-message"><b>你:</b> {{content}}</div>`,
assistant: `<div><b>华实君:</b></div><div class="chat-message server-message"> {{content}}</div>`
};
}
// 渲染单个消息
renderMessage(message) {
let html;
let content;
// 处理不同的数据格式
if (typeof message === 'string') {
// 旧格式:直接字符串
content = marked.parse(message);
html = this.messageTemplate.assistant.replace('{{content}}', content);
} else if (typeof message === 'object' && message !== null) {
// 新格式:{role, content} 对象
content = marked.parse(message.content);
if (message.role === 'user') {
html = this.messageTemplate.user.replace('{{content}}', content);
} else {
html = this.messageTemplate.assistant.replace('{{content}}', content);
}
}
if (html) {
const messageDiv = document.createElement('div');
messageDiv.innerHTML = html;
this.chatbox.appendChild(messageDiv);
this.scrollToBottom();
}
}
// 渲染多条消息(用于恢复历史)
renderMessages(messages) {
messages.forEach(message => this.renderMessage(message));
}
// 滚动到底部
scrollToBottom() {
this.chatbox.scrollTop = this.chatbox.scrollHeight;
}
}
// 清理过期的批准记录
function cleanupExpiredApprovals() {
const now = Date.now();
@@ -24,6 +75,10 @@ document.addEventListener('DOMContentLoaded', function () {
folder: data.folder
}
});
// 初始化聊天消息管理器
chatManager = new ChatMessageManager();
socket.on('connect', function () {
console.log('Connected to server');
socket.emit('login',JSON.stringify(data));
@@ -38,12 +93,13 @@ document.addEventListener('DOMContentLoaded', function () {
input.value += data;
});
socket.on('message', function (data) {
// 显示服务器的回复消息
const serverMessage = document.createElement('div');
serverMessage.innerHTML = `<div><b>华实君:</b></div><div class="chat-message server-message"> ${marked.parse(data)}</div>`;
document.getElementById('chatbox').appendChild(serverMessage);
// 滚动到最新消息
document.getElementById('chatbox').scrollTop = document.getElementById('chatbox').scrollHeight;
// 显示服务器的回复消息 - 单个消息
chatManager.renderMessage(data);
});
socket.on('messages', function (data) {
// 显示服务器发送的消息列表 - 用于恢复对话历史
chatManager.renderMessages(data);
});
socket.on('message-hint', function(data){
console.log('get message-hint');
@@ -90,7 +146,7 @@ document.addEventListener('DOMContentLoaded', function () {
}
const approveButton = document.createElement('button');
approveButton.innerHTML = '<i class="fas fa-check-circle-check"></i>';
approveButton.innerHTML = '<i class="bi bi-check-circle-fill"></i>';
approveButton.data = element;
approveButton.className = 'approve-button';
approveButton.onclick = function (event) {
@@ -105,29 +161,7 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('chatbox').appendChild(requestMessage);
document.getElementById('chatbox').scrollTop = document.getElementById('chatbox').scrollHeight;
}
// for (let i = 0; i < data.length; i++) {
// const element = data[i]
// const requestMessage = document.createElement('div');
// requestMessage.className = 'chat-message server-message';
// const approveButton = document.createElement('button');
// approveButton.innerText = '批准';
// approveButton.data = element;
// approveButton.style.width = '100%';
// approveButton.style.borderRadius = '4px';
// approveButton.onclick = function(event) {
// console.log(element)
// socket.emit('message', JSON.stringify({type:'function', data:element}));
// event.currentTarget.disabled = true;
// event.currentTarget.style.backgroundColor = 'gray';
// event.currentTarget.style.color = 'white'; // 可选:设置文字颜色为白色,以便更清晰地显示
// event.currentTarget.style.border = 'none'; // 可选:去掉边框
// };
// // 将按钮添加到消息气泡中
// requestMessage.innerHTML = `<b>Function:</b> ${element.name}(${JSON.stringify(element.arguments)})<br>`;
// requestMessage.appendChild(approveButton);
// document.getElementById('chatbox').appendChild(requestMessage);
// document.getElementById('chatbox').scrollTop = document.getElementById('chatbox').scrollHeight;
// }
} catch (error) {
console.log(error)
}
@@ -232,142 +266,19 @@ function sendMessage() {
const input = document.getElementById('messageInput').value;
if (input.trim() === '') return;
// 使用 marked.js 解析 markdown
const markdownHtml = marked.parse(input);
// 显示用户发送的消息
const userMessage = document.createElement('div');
userMessage.innerHTML = `<div class="chat-message user-message"><b>你:</b> ${markdownHtml}</div>`;
document.getElementById('chatbox').appendChild(userMessage);
// 发送消息到服务器
socket.emit('message', JSON.stringify({ data: input, type: 'text' }));
// 使用聊天管理器显示用户消息
chatManager.renderMessage({ role: 'user', content: input });
// 清空输入框
document.getElementById('messageInput').value = '';
// 滚动到最新消息
document.getElementById('chatbox').scrollTop = document.getElementById('chatbox').scrollHeight;
}
// let voice_stream = null;
// let audio_chunks = [];
// let isRecording = false;
// let recordingInterval = null;
// //鼠标点击终止事件
// function handleMouseClick(event) {
// event.stopPropagation();
// stopRecording();
// }
// //发送语音
// const startAudioRecording = async () => {
// try {
// const stream = await navigator.mediaDevices.getUserMedia({
// audio: {
// sampleRate: 16000,
// channelCount: 1,
// echoCancellation: true,
// noiseSuppression: true
// }
// });
// const mediaRecorder = new MediaRecorder(stream);
// const audioContext = new AudioContext({
// sampleRate: 16000
// });
// // Load the AudioWorklet
// await audioContext.audioWorklet.addModule('/static/js/audio-processor.js');
// const source = audioContext.createMediaStreamSource(stream);
// const workletNode = new AudioWorkletNode(audioContext, 'audio-processor');
// let audioChunks = [];
// workletNode.port.onmessage = (e) => {
// audioChunks.push(new Float32Array(e.data));
// if (audioChunks.length >= 16) {
// // 合并所有音频块
// const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
// const mergedData = new Float32Array(totalLength);
// let offset = 0;
// for (const chunk of audioChunks) {
// mergedData.set(chunk, offset);
// offset += chunk.length;
// }
// const audioMessage = {
// id: window.data.user_uuid,
// type: 'audio',
// data: mergedData,
// sampleRate: 16000,
// timestamp: Date.now()
// };
// socket.emit('voice', audioMessage);
// console.log("voice sended")
// audioChunks = [];
// }
// };
// source.connect(workletNode);
// workletNode.connect(audioContext.destination);
// mediaRecorder.value = {
// stop: () => {
// source.disconnect();
// workletNode.disconnect();
// stream.getTracks().forEach(track => track.stop());
// audioContext.close();
// }
// }
// isRecording.value = true;
// } catch (error) {
// console.error('Error starting audio recording:', error);
// }
// };
// function sendVoiceMessage() {
// startAudioRecording()
// //变换录音中图标
// document.getElementById('recordIcon').className = 'bi bi-record-circle';
// document.addEventListener('click', handleMouseClick);
// }
// //停止录音
// function stopRecording() {
// if (!isRecording) return;
// isRecording = false;
// //取消监听
// document.removeEventListener('click', handleMouseClick);
// //取消定时器
// if (recordingInterval) {
// clearInterval(recordingInterval);
// recordingInterval = null;
// }
// //变换图标
// document.getElementById('recordIcon').className = 'bi bi-mic';
// //发送剩下的数据
// if (audio_chunks.length > 0) {
// const merged_data = new Uint8Array(audio_chunks.reduce((acc, chunk) => acc + chunk.length, 0));
// let offset = 0;
// for (const chunk of audio_chunks) {
// merged_data.set(new Uint8Array(chunk), offset);
// offset += chunk.length;
// }
// socket.emit('VoiceMessage', merged_data, { binary: true });
// audio_chunks = [];
// }
// overlay.style.display = 'none';
// //关闭音频流
// if (voice_stream) {
// voice_stream.stop_stream();
// voice_stream.close();
// voice_stream = null;
// }
// if (p) {
// p.terminate();
// p = null;
// }
// }
//语音这一块//
@@ -513,14 +424,11 @@ window.addEventListener('beforeunload', () => {
let currentChapterIndex = -1;
function next_chapter(data) {
// 获取所有的 h3 元素
var iframe = document.getElementById('markdown-content-iframe');
var container = document.getElementById('markdown-content-container');
var titles = [];
var h3Elements = container.querySelectorAll('h3');
console.log("h3Elements next");
// 访问 iframe 的文档对象
var iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
var titles = []
var h3Elements = iframeDocument.querySelectorAll('h3');
console.log("h3Elements next")
if (currentChapterIndex >= h3Elements.length) {
return; // 如果已经到了最后一个章节,则不进行任何操作
}
@@ -528,7 +436,7 @@ function next_chapter(data) {
// 隐藏当前章节及其后的内容
for (let i = 0; i < h3Elements.length; i++) {
h3Elements[i].style.display = 'none';
titles.push(h3Elements[i].innerText)
titles.push(h3Elements[i].innerText);
let nextSibling = h3Elements[i].nextElementSibling;
while (nextSibling && nextSibling.tagName !== 'H3') {
nextSibling.style.display = 'none';
@@ -558,48 +466,31 @@ function generateProgressBar(N, idx, titles) {
const progressBox = document.createElement('div');
progressBox.className = 'progress-box';
// 获取详细信息容器
const detailBox = document.getElementById('progress-detail');
// 根据N值动态调整每个进度节点的宽度
const nodeWidth = 100 / N; // 每个节点的宽度为 100% / N
// 根据N值生成子框并设置颜色
for (let i = 0; i < N; i++) {
const titleBox = document.createElement('div');
titleBox.className = 'progress-title';
// 设置进度条的颜色
// 设置进度条的颜色:已学习为蓝色,当前学习为绿色,未学习为白色
if (i < idx) {
titleBox.classList.add('green'); // 已完成部分
titleBox.classList.add('blue'); // 已完成部分 - 蓝色
} else if (i === idx) {
titleBox.classList.add('green'); // 当前正在学习 - 绿色
} else {
titleBox.classList.add('white'); // 未完成部分
titleBox.classList.add('white'); // 未完成部分 - 白色
}
// 设置每个子框的标题
titleBox.innerHTML = titles[i] || `Step ${i + 1}`;
// 移除进度条上的文字,因为现在进度条很细
titleBox.innerHTML = '';
// 设置每个进度节点的宽度
titleBox.style.width = `${nodeWidth}%`;
// 监听鼠标移入事件来显示详情
titleBox.addEventListener('mouseenter', function () {
detailBox.innerHTML = `Step ${i + 1}: ${titles[i] || "No title"}`;// - Additional details here...`;
detailBox.style.display = 'block';
// 根据进度条位置显示详情
const rect = titleBox.getBoundingClientRect();
detailBox.style.top = `${rect.top + window.scrollY - 100}px`; // 微调位置
detailBox.style.left = `${rect.left + window.scrollX + rect.width / 2 - detailBox.offsetWidth / 2}px`;
});
// 监听鼠标移出事件来隐藏详情
titleBox.addEventListener('mouseleave', function () {
detailBox.style.display = 'none';
});
// 使用attachImmediateTooltip函数使提示框跟随鼠标移动
const tooltipText = `Step ${i + 1}: ${titles[i] || "No title"}`;
attachImmediateTooltip(titleBox, tooltipText);
progressBox.appendChild(titleBox);
}
// 将生成的进度条添加到容器中
container.appendChild(progressBox);
}

View File

@@ -0,0 +1,58 @@
// helpTip.js - 全局tooltip功能可在不同页面复用
// 在页面里复用一个全局 tooltip 节点
let __globalTooltip;
// 确保tooltip节点存在
function ensureTooltip() {
if (!__globalTooltip) {
__globalTooltip = document.createElement('div');
__globalTooltip.className = 'tooltip-portal';
document.body.appendChild(__globalTooltip);
}
return __globalTooltip;
}
// 绑定到任意图标:移入→显示;移动→跟随;移出→隐藏
function attachImmediateTooltip(anchorEl, text) {
const tip = ensureTooltip();
let shown = false;
function show(e) {
tip.textContent = text;
tip.style.display = 'block';
tip.style.opacity = '1';
position(e);
shown = true;
}
function position(e) {
// 以鼠标位置为准
const padding = 8;
const tipRect = tip.getBoundingClientRect();
let x = e.clientX + 12;
let y = e.clientY - tipRect.height - 12;
// 简易防出界
const vw = window.innerWidth, vh = window.innerHeight;
if (x + tipRect.width + padding > vw) x = vw - tipRect.width - padding;
if (y < padding) y = e.clientY + 16; // 放到下面
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
function hide() {
tip.style.opacity = '0';
// 用小延迟避免闪烁
setTimeout(() => { if (!shown) return; tip.style.display = 'none'; }, 100);
shown = false;
}
anchorEl.addEventListener('mouseenter', show);
anchorEl.addEventListener('mousemove', position);
anchorEl.addEventListener('mouseleave', hide);
}
// 导出函数,供其他文件使用
window.attachImmediateTooltip = attachImmediateTooltip;

View File

@@ -4,60 +4,6 @@ window.addEventListener('load', ()=>{
editLesson(window.material.material_id, window.chapter.chapter_name, window.lesson.lesson_name);
});
// 在页面里复用一个全局 tooltip 节点
let __globalTooltip;
function ensureTooltip() {
if (!__globalTooltip) {
__globalTooltip = document.createElement('div');
__globalTooltip.className = 'tooltip-portal';
document.body.appendChild(__globalTooltip);
}
return __globalTooltip;
}
// 绑定到任意图标:移入→显示;移动→跟随;移出→隐藏
function attachImmediateTooltip(anchorEl, text) {
const tip = ensureTooltip();
let shown = false;
function show(e) {
tip.textContent = text;
tip.style.display = 'block';
tip.style.opacity = '1';
position(e);
shown = true;
}
function position(e) {
// 以鼠标位置为准(也可用 anchorEl.getBoundingClientRect()
const padding = 8;
const tipRect = tip.getBoundingClientRect();
let x = e.clientX + 12;
let y = e.clientY - tipRect.height - 12;
// 简易防出界
const vw = window.innerWidth, vh = window.innerHeight;
if (x + tipRect.width + padding > vw) x = vw - tipRect.width - padding;
if (y < padding) y = e.clientY + 16; // 放到下面
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
function hide() {
tip.style.opacity = '0';
// 用小延迟避免闪烁
setTimeout(() => { if (!shown) return; tip.style.display = 'none'; }, 100);
shown = false;
}
anchorEl.addEventListener('mouseenter', show);
anchorEl.addEventListener('mousemove', position);
anchorEl.addEventListener('mouseleave', hide);
}
// 你的 renderOutline 里调用:
// if (outline.theme) addThemeWithHelp(tree, outline.theme);
@@ -196,6 +142,7 @@ function parseHeadings(mdText) {
help.className = 'help-icon';
help.textContent = ' ?';
li.appendChild(help);
// 新增:铅笔编辑按钮
const editBtn = createEditButton(() => {
openTitleEditor({

View File

@@ -0,0 +1,116 @@
// 动态加载 Markdown 文件
function loadMarkdown(course_id, chapter_name, lesson_name) {
fetch(`/${course_id}-${chapter_name}-${lesson_name}-markdown`)
.then(response => response.json())
.then(data => {
if (data.markdown_content) {
// 使用 marked 解析 Markdown 内容
const html = marked.parse(data.markdown_content);
// 创建包含 MathJax 支持的 HTML 结构
const markdownContainer = document.createElement('div');
markdownContainer.id = 'markdown-content-container';
markdownContainer.innerHTML = html;
// 处理代码块,实现缩略显示
const codeBlocks = markdownContainer.querySelectorAll('pre code');
codeBlocks.forEach(codeBlock => {
const preBlock = codeBlock.parentElement;
// 保存原始代码内容
const originalContent = preBlock.innerHTML;
// 创建缩略提示元素
const codeHint = document.createElement('div');
codeHint.className = 'code-hint';
codeHint.innerHTML = '<div style="padding: 10px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; color: #666; font-size: 14px; text-align: center;">💡 代码已放置在代码区中,点击展开查看</div>';
// 创建展开/折叠按钮
const toggleButton = document.createElement('button');
toggleButton.className = 'toggle-code';
toggleButton.innerHTML = '展开代码';
toggleButton.style.cssText = 'margin: 5px; padding: 5px 10px; background-color: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px;';
// 添加到提示元素中
codeHint.appendChild(toggleButton);
// 替换原始代码块为提示
preBlock.innerHTML = '';
preBlock.appendChild(codeHint);
// 保存原始代码,用于切换
preBlock.dataset.originalContent = originalContent;
preBlock.dataset.isExpanded = 'false';
// 添加切换事件
toggleButton.addEventListener('click', () => {
if (preBlock.dataset.isExpanded === 'false') {
// 展开代码
preBlock.innerHTML = preBlock.dataset.originalContent;
toggleButton.innerHTML = '折叠代码';
preBlock.appendChild(toggleButton);
preBlock.dataset.isExpanded = 'true';
// 如果MathJax已加载重新排版
if (typeof MathJax !== 'undefined') {
MathJax.typesetPromise([preBlock]);
}
} else {
// 折叠代码
preBlock.innerHTML = '';
preBlock.appendChild(codeHint.cloneNode(true));
preBlock.appendChild(toggleButton.cloneNode(true));
preBlock.dataset.isExpanded = 'false';
// 重新添加事件监听
const newToggleButton = preBlock.querySelector('.toggle-code');
newToggleButton.addEventListener('click', arguments.callee);
}
});
});
// 清空内容容器并添加渲染后的内容
const contentElement = document.getElementById('markdown-content');
contentElement.innerHTML = '';
contentElement.appendChild(markdownContainer);
// 配置并加载 MathJax
if (typeof MathJax === 'undefined') {
// 动态加载 MathJax
const script = document.createElement('script');
script.src = '/static/cdnback/js/tex-mml-chtml.js';
script.async = true;
script.onload = function() {
MathJax.config = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']]
},
svg: {
fontCache: 'global'
}
};
MathJax.typesetPromise([markdownContainer]);
// 初始化课程进度
next_chapter();
};
document.head.appendChild(script);
} else {
// MathJax 已加载,直接排版
MathJax.typesetPromise([markdownContainer]);
// 初始化课程进度
next_chapter();
}
} else {
console.error('Markdown file not found');
}
})
.catch(error => {
console.error('Error loading markdown:', error);
});
}
// 文档加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
// 从全局变量获取课程信息
if (window.appData) {
loadMarkdown(window.appData.course_id, window.appData.chapter_name, window.appData.lesson_name);
}
});

View File

@@ -8,6 +8,7 @@
</style>
<link rel="stylesheet" href="/static/css/desktop.css">
<link rel="stylesheet" href="/static/css/chatbox_approve_icon.css">
<link rel="stylesheet" href="/static/css/helpTip.css">
<script>
document.cookie = "vscode-tkn=44edc269-6f88-46ab-9790-dc253f66ac36; path=/; SameSite=None; Secure";
</script>
@@ -85,6 +86,22 @@
folder:"{{workspace_path}}"
}
</script>
<script>
// 检测页面刷新事件
window.addEventListener('beforeunload', function(e) {
// 这里不弹出提示,只在页面刷新时执行跳转
// 但是beforeunload中不能直接跳转所以我们需要在页面加载时检测是否是刷新
});
// 检测是否是页面刷新
if (performance.navigation.type === 1 || performance.getEntriesByType("navigation")[0].type === "reload") {
// 是刷新操作,跳转到确认页面
const course_id = "{{course_id}}";
const chapter_name = "{{chapter_name}}";
const lesson_name = "{{lesson_name}}";
window.location.href = `/desktop_nouser/${course_id}/${chapter_name}/${lesson_name}`;
}
</script>
</div>
</div>
@@ -124,9 +141,7 @@
</div>
</div>
<script src="/static/js/process_show_floating_window.js"></script>
<script src="/static/js/helpTip.js"></script>
<script src="/static/js/chatbox.js"></script>
<script>
@@ -177,7 +192,6 @@
// 使用 fetch 发送请求到 Flask 服务器,获取 session 信息
fetch('/get_session', {
method: 'GET',
@@ -214,31 +228,6 @@
}
// 动态加载 Markdown 文件
function loadMarkdown(course_id, chapter_name, lesson_name) {
fetch(`/${course_id}-${chapter_name}-${lesson_name}-markdown`)
.then(response => response.json())
.then(data => {
if (data.html_url) {
document.getElementById('markdown-content').innerHTML = `<iframe id="markdown-content-iframe" src="${data.html_url}"style="width: 100%;height: 100%;"></iframe>`;
document.getElementById('markdown-content-iframe').addEventListener('load', function() {
// 在这里执行你想要的操作
console.log('Iframe has finished loading');
next_chapter();
});
} else {
console.error('Markdown file not found');
}
})
.catch(error => {
console.error('Error loading markdown:', error);
});
}
document.addEventListener('DOMContentLoaded', function() {
loadMarkdown('{{course_id}}','{{chapter_name}}','{{lesson_name}}');
});
// 侧边工具栏切换
const sidebarTools = document.getElementById('sidebarTools');
const toggleButton = document.getElementById('toggleButton');
@@ -254,5 +243,7 @@
});
</script>
<!-- 引入 Markdown 渲染脚本 -->
<script src="/static/js/render-desktop-markdown.js"></script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>确认加载历史数据</title>
<link rel="stylesheet" href="/static/cdnback/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.confirm-container {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 30px;
max-width: 500px;
width: 100%;
text-align: center;
}
h1 {
color: #343a40;
margin-bottom: 20px;
}
p {
color: #6c757d;
margin-bottom: 30px;
font-size: 16px;
}
.btn-group {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.btn {
padding: 10px 30px;
font-size: 16px;
border-radius: 5px;
}
.btn-primary {
background-color: #007bff;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
border: none;
}
.btn-secondary:hover {
background-color: #545b62;
}
</style>
</head>
<body>
<div class="confirm-container">
{% if is_course_finished %}
<h1>重新学习确认</h1>
<p>您已经完成了该课程的学习,是否要重新开始学习?</p>
<div class="btn-group">
<a href="{{ url_for('vscode.desktop', user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history='false') }}" class="btn btn-primary">
重新开始学习
</a>
</div>
{% else %}
<h1>加载历史数据确认</h1>
<p>您即将进入学习环境,是否需要加载您之前的对话历史记录和学习进度数据?</p>
<div class="btn-group">
<a href="{{ url_for('vscode.desktop', user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history='true') }}" class="btn btn-primary">
是,加载历史数据
</a>
<a href="{{ url_for('vscode.desktop', user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history='false') }}" class="btn btn-secondary">
否,重新开始
</a>
</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/css/teacherboard.css">
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/lesson.css">
<link rel="stylesheet" href="/static/css/helpTip.css">
<link rel="stylesheet" href="/static/css/teacher_course_setting.css">
<link rel="stylesheet" href="/static/css/lesson_setting.css">
<link rel="stylesheet" href="/static/css/modelinput.css">
@@ -100,6 +101,7 @@
<script src="/static/js/teacherboard.js"></script>
<script src="/static/js/teacher_course_setting.js"></script>
<script src="/static/js/lesson_markdown.js"></script>
<script src="/static/js/helpTip.js"></script>
<script src="/static/js/lesson.js"></script>
<script>

View File

@@ -1,7 +1,6 @@
<link rel="stylesheet" href="/static/css/navbar.css">
<link rel="stylesheet" href="/static/cdnback/all.min.css">
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/navbar.css">
<header class="navbar">
<div class="logo">
<h1>华实学伴:教学练评一体的虚拟助教</h1>

View File

@@ -156,7 +156,6 @@ def load_chapters(markdown, markdown_prompt, score_prompt):
chapter_chain = []
No = 1
print (chapter_dict)
for chapter_name in chapter_sequence:
chapter_chain.append(Chapter( No, CHAPTER_LATTER, chapter_name, chapter_dict[chapter_name]["markdown"], chapter_dict[chapter_name]["markdown_prompt"], chapter_dict[chapter_name]["score_prompt"],chapter_dict[chapter_name]["require_tools"]))
No+=1

View File

@@ -0,0 +1,58 @@
import json
from datetime import datetime, date, time
from bson import ObjectId
from typing import Any, Dict, List, Union
class JSONEncoder(json.JSONEncoder):
"""
通用JSON编码器处理常见的非JSON序列化类型
支持的特殊类型:
- datetime: 转换为ISO格式字符串
- date: 转换为ISO格式字符串
- time: 转换为ISO格式字符串
- ObjectId: 转换为字符串
- 其他不可序列化类型: 转换为字符串表示
"""
def default(self, obj: Any) -> Any:
if isinstance(obj, (datetime, date, time)):
return obj.isoformat()
elif isinstance(obj, ObjectId):
return str(obj)
elif isinstance(obj, bytes):
return obj.decode('utf-8', errors='replace')
elif isinstance(obj, (set, frozenset)):
return list(obj)
elif hasattr(obj, '__dict__'):
return obj.__dict__
else:
return str(obj)
def json_serialize(obj: Any) -> str:
"""
通用JSON序列化函数处理常见的非JSON序列化类型
参数:
- obj: 要序列化的对象
返回:
- str: JSON字符串
"""
return json.dumps(obj, cls=JSONEncoder, ensure_ascii=False)
def json_serialize_dict(obj: Any) -> Dict:
"""
通用JSON序列化函数将对象转换为可JSON序列化的字典
参数:
- obj: 要序列化的对象
返回:
- Dict: 可JSON序列化的字典
"""
json_str = json_serialize(obj)
return json.loads(json_str)

View File

@@ -14,17 +14,8 @@ bp = Blueprint("markdown", __name__)
def convert_md(course_id, chapter_name, lesson_name):
print(f"convert_md: {course_id}, {chapter_name}, {lesson_name}")
md_file = load_markdown_file(course_id, chapter_name, lesson_name)
# 配置Markdown解析器,启用数学公式支持
md = MarkdownIt()
md.use(texmath.texmath_plugin) # 添加数学公式插件
# 转换markdown为html包含公式标记
html = md.render(md_file)
# 包装样式时添加MathJax支持用于在浏览器中渲染公式
html_with_styles = wrap_with_styles(html)
# 保存HTML文件到static
html_output_path = os.path.join(current_app.config["STATIC_DIR"], f"{course_id}-{chapter_name}-{lesson_name}.html")
save_html(html_with_styles, html_output_path)
return jsonify({"html_url": f"/static/{course_id}-{chapter_name}-{lesson_name}.html"})
# 直接返回Markdown源文件内容,不进行服务器端渲染
return jsonify({"markdown_content": md_file})
# 提供静态文件访问
@bp.route("/static/<path:filename>")

View File

@@ -6,6 +6,7 @@ from ..services.backboard_service import realtime_response
from ..extension_ase.ase_client.manager import ChatManager
import subprocess
from ..auth.decorators import require_role
from ..services.course_service import load_learning_progress
bp = Blueprint("vscode", __name__)
@@ -17,6 +18,16 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
userid_recorder = current_app.extensions["userid_recorder"]
if "user_uuid" not in session:
return redirect(url_for("auth.login"))
# 检查是否有 load_history 参数
load_history_str = request.args.get('load_history')
if load_history_str is None:
# 如果没有参数,跳转到确认页面
return redirect(url_for("vscode.desktop_nouser", course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name))
# 将字符串转换为布尔值
load_history = load_history_str.lower() == 'true'
# 全局映射
user_id = uuid2username[user_uuid]
userid_recorder[f"{user_id}&{course_id}"] = session["user_uuid"]
@@ -58,8 +69,7 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
chatmanager = ChatManager()
print(f"now user uuid {user_uuid}")
chatmanager = ChatManager(restart=not load_history)
current_app.extensions["user_uuid2chatmanager"][user_uuid] = chatmanager
code_server_port = 10000 + int(uuid.uuid4().int % 10000)
# chatmanager.start_code_server(user_uuid, user_id, path_dir, code_server_port)
@@ -136,11 +146,38 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
)
@bp.route("/desktop_nouser/<course_id>/<chapter_name>/<lesson_name>")
@require_role
def desktop_nouser(course_id, chapter_name, lesson_name):
if "user_uuid" not in session:
return redirect(url_for("auth.login")) # 如果你有 auth 蓝图
user_uuid = session["user_uuid"]
return redirect(url_for("vscode.desktop", user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name))
uuid2username = current_app.extensions["uuid2username"]
user_id = uuid2username[user_uuid]
# 检查是否有学习记录
progress_result = load_learning_progress(user_id, course_id, chapter_name, lesson_name)
if not progress_result["exists"]:
# 没有学习记录,直接跳转到学习页面,不显示确认加载历史的页面
return redirect(url_for("vscode.desktop", user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history="false"))
# 获取学习记录数据
progress_data = progress_result["data"]
chapter_chain_now = progress_data.get("chapter_chain_now", -1)
chapter_chain_length = progress_data.get("chapter_chain_length", 0)
# 检查是否已经学完所有章节
is_course_finished = chapter_chain_now >= chapter_chain_length - 1
return render_template(
"confirm_load_history.html",
user_uuid=user_uuid,
course_id=course_id,
chapter_name=chapter_name,
lesson_name=lesson_name,
is_course_finished=is_course_finished
)
@bp.route("/vscode_data", methods=["POST"])
def vscode_data():

View File

@@ -43,13 +43,22 @@ class Backboard:
f"- File tree: {self.file_tree}{endl}")
# f"- Activated file content (current editing 10 lines): {endl}"
def add_history(self, realtime_action):
if(realtime_action['type']=='config'):return
if len(self.history)!=0:
if (self.history[-1]['type']=='fileEdit'):
if(self.history[-1]['filePath'] == realtime_action['filePath']):
# 检查必要的键是否存在避免KeyError
if realtime_action.get('type') == 'config':
return
if len(self.history) > 0 and realtime_action.get('type') == 'fileEdit':
# 安全地获取filePath并进行比较
current_file_path = realtime_action.get('filePath')
# 检查最近的历史记录是否包含filePath
if current_file_path and self.history[-1].get('filePath') == current_file_path:
# 安全地更新content只有当content存在时才更新
if 'content' in realtime_action:
self.history[-1]['content'] = realtime_action['content']
return
return
self.history.append(realtime_action)
# 限制历史记录的数量
if len(self.history) > 500: # 假设最多保留500条记录
self.history.pop(0) # 移除最旧的记录
def get_deltatime_mmss(self, start_time, end_time):

View File

@@ -5,9 +5,9 @@ api_key = sk-aMpnWklN2IbsK44d1kNpy6YOP9bk1pdPJjFeEmbb0a5ytEFf
model=gpt-4.1-nano
[VSCODE_WEB]
url = https://hsamooc.com
url = https://hsamooc.cn
[CODE_LIKE]
url = https://hsamooc.com/vsc-like
url = /vsc-like
#http://asengine.net:8282
[VSCODE_WEB_PATH]
is_wsl = true
@@ -27,5 +27,6 @@ bucket = hsamooc-cdn-1374354408
region = ap-guangzhou
[ASE_ENGINE]
url = https://asengine.net
namespace = /socket.io/c159a41d238b00f35ff33e036d007a0dec4d03197084b5557949a2f7f5ffce03
url = https://test.asengine.net
namespace = /socket.io/7100eebf7a909e1ee3f4e06766f512f0358ecb6ac51d5434c17d31dd70890dc1
url_token = 7100eebf7a909e1ee3f4e06766f512f0358ecb6ac51d5434c17d31dd70890dc1

View File

@@ -0,0 +1 @@
{"username": "_10235101550"}

View File

@@ -0,0 +1 @@
{"username": "a10235101450"}

View File

@@ -0,0 +1 @@
{"username": "ddd"}

View File

@@ -0,0 +1 @@
{"username": "dvd"}

View File

@@ -0,0 +1 @@
{"username": "emmeta"}

View File

@@ -0,0 +1 @@
{"username": "exceptedgoat"}

View File

@@ -0,0 +1 @@
{"username": "huo"}

View File

@@ -0,0 +1 @@
{"username": "jqs"}

View File

@@ -0,0 +1 @@
{"username": "kktq"}

View File

@@ -0,0 +1 @@
{"username": "luyu"}

View File

@@ -0,0 +1 @@
{"username": "not_shy_10235101481"}

View File

@@ -0,0 +1 @@
{"username": "sjm666"}

View File

@@ -0,0 +1 @@
{"username": "tian"}

View File

@@ -0,0 +1 @@
{"username": "wawacai"}

View File

@@ -0,0 +1 @@
{"username": "yy"}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime
# Add the project root to the Python path
sys.path.insert(0, '/Users/cakecai/Documents/gitea/hsa/Html')
# Test the JSON serializer
try:
from apps.utils.json_serializer import json_serialize, json_serialize_dict, JSONEncoder
import json
# Create a test dictionary with datetime object
test_data = {
"user_id": "test_user",
"material_id": "test_course",
"chapter_name": "test_chapter",
"lesson_name": "test_lesson",
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"updated_at": datetime.now()
}
print("Testing datetime serialization...")
print(f"Original data: {test_data}")
print(f"Type of updated_at: {type(test_data['updated_at'])}")
# Test json_serialize function
json_str = json_serialize(test_data)
print(f"\nSerialized JSON string: {json_str}")
# Test json_serialize_dict function
serialized_dict = json_serialize_dict(test_data)
print(f"\nSerialized dictionary: {serialized_dict}")
print(f"Type of updated_at in serialized dict: {type(serialized_dict['updated_at'])}")
# Test direct json.dumps with custom encoder
json_str2 = json.dumps(test_data, cls=JSONEncoder, ensure_ascii=False)
print(f"\nDirect json.dumps with custom encoder: {json_str2}")
# Verify it can be parsed back
parsed = json.loads(json_str)
print(f"\nParsed back from JSON: {parsed}")
print("\n✅ All tests passed! datetime objects can be correctly serialized to JSON.")
except Exception as e:
print(f"\n❌ Test failed with error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

41
Html/test_progress.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import sys
import os
# Add the project root to the Python path
sys.path.insert(0, '/Users/cakecai/Documents/gitea/hsa/Html')
from flask import Flask
from flask_pymongo import PyMongo
# Create a minimal Flask app for testing
app = Flask(__name__)
app.config['MONGO_URI'] = 'mongodb://localhost:27017/test_db'
mongo = PyMongo(app)
# Register the mongo extension with the app
app.extensions = {'mongo': mongo}
# Import the function to test
with app.app_context():
from apps.services.course_services.learning_progess_process import load_learning_progress
# Test with non-existent record
print("Testing with non-existent record:")
result = load_learning_progress("test_user", "test_course", "test_chapter", "test_lesson")
print(f"Result: {result}")
print(f"Exists: {result['exists']}")
print(f"Data keys: {list(result['data'].keys())}")
print(f"Message: {result['message']}")
print("\n")
# Test that default values are correctly set
print("Testing default values:")
print(f"Chat historys: {result['data']['chat_historys']}")
print(f"Scores: {result['data']['scores']}")
print(f"Chapter chain now: {result['data']['chapter_chain_now']}")
print(f"Updated at: {result['data']['updated_at']}")
print("\n")
print("Test completed successfully!")

View File

@@ -39,35 +39,56 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
"""
max_read_bytes = 1024 * 20
timeout=0.1
while True:
socketio.sleep(timeout)
timeout=min(timeout*2, 0.4)
# using flask default web server, or uwsgi production web server
# when the child process is terminated, it will not disappear from linux process list
# and keep staying as a zombie process until the parent exits.
try:
child_process = psutil.Process(pid)
except psutil.NoSuchProcess as err:
return
if child_process.status() not in ('running', 'sleeping'):
return
if fd:
timeout_sec = 0
(data_ready, _, _) = select.select([fd], [], [], timeout_sec)
if data_ready:
# output = os.read(fd, max_read_bytes).decode('ascii')
timeout=0.1
try:
while True:
socketio.sleep(timeout)
timeout=min(timeout*2, 0.4)
# using flask default web server, or uwsgi production web server
# when the child process is terminated, it will not disappear from linux process list
# and keep staying as a zombie process until the parent exits.
try:
child_process = psutil.Process(pid)
except psutil.NoSuchProcess as err:
# Process already terminated, clean up any zombie
try:
output = os.read(fd, max_read_bytes).decode()
except Exception as err:
output = """
***AQUI WEB TERM ERR***
{}
***********************
""".format(err)
# the key for different visitor to get different terminal (instead of mixing up)
# is to let the background task push pty response to each one's own (default) ROOM!
namespace.emit("pty_output", {"output": output}, room=room_id)
os.waitpid(pid, os.WNOHANG)
except Exception:
pass
return
if child_process.status() not in ('running', 'sleeping'):
# Process is terminated or in other state, clean up
try:
child_process.wait(timeout=1)
except Exception:
try:
os.waitpid(pid, os.WNOHANG)
except Exception:
pass
return
if fd:
timeout_sec = 0
(data_ready, _, _) = select.select([fd], [], [], timeout_sec)
if data_ready:
# output = os.read(fd, max_read_bytes).decode('ascii')
timeout=0.1
try:
output = os.read(fd, max_read_bytes).decode()
except Exception as err:
output = """
***AQUI WEB TERM ERR***
{}
***********************
""".format(err)
# the key for different visitor to get different terminal (instead of mixing up)
# is to let the background task push pty response to each one's own (default) ROOM!
namespace.emit("pty_output", {"output": output}, room=room_id)
finally:
# Clean up file descriptor if it's open
if fd:
try:
os.close(fd)
except Exception:
pass
class VSCLikeNameSpace(Namespace):
def on_connect(self):
@@ -156,15 +177,40 @@ class VSCLikeNameSpace(Namespace):
set_winsize(fd, data["rows"], data["cols"])
def on_disconnect(self):
try:
child_process = psutil.Process(session.get('terminal_config', {}).get('child_pid'))
except psutil.NoSuchProcess as err:
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return
if child_process.status() in ('running', 'sleeping'):
# if visitor just close the browser tab then left alone the pty here
# it should be terminated by the parent process after
child_process.terminate()
current_app.logger.debug('user left the pty alone, terminated')
child_pid = session.get('terminal_config', {}).get('child_pid')
if child_pid:
try:
child_process = psutil.Process(child_pid)
if child_process.status() in ('running', 'sleeping'):
# if visitor just close the browser tab then left alone the pty here
# it should be terminated by the parent process after
child_process.terminate()
# Wait for the process to terminate and collect its exit status
child_process.wait(timeout=2)
current_app.logger.debug('user left the pty alone, terminated and waited')
except psutil.NoSuchProcess as err:
# Process already terminated, try to wait anyway to clean up any zombie
try:
os.waitpid(child_pid, os.WNOHANG)
except Exception:
pass
except psutil.TimeoutExpired:
# If process didn't terminate in time, kill it forcefully
child_process.kill()
try:
child_process.wait(timeout=1)
except Exception:
pass
except Exception as err:
current_app.logger.error(f'Error terminating process: {err}')
finally:
# Close the file descriptor if it's open
fd = session.get('terminal_config', {}).get('fd')
if fd:
try:
os.close(fd)
except Exception:
pass
# Reset session config
session['terminal_config'] = TERM_INIT_CONFIG
current_app.logger.debug('Client disconnected')

View File

@@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloud IDE</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.0/min/vs/editor/editor.main.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.5.0/xterm.css" />
<link rel="stylesheet" href="static/cdnback/css/editor/editor.main.css">
<script src="static/cdnback/socket.io.min.js"></script>
<link rel="stylesheet" href="static/cdnback/all.min.css">
<link rel="stylesheet" href="static/cdnback/css/xterm.css" />
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/xterm/5.5.0/xterm.js"></script> -->
<script src="https://unpkg.com/xterm@3.6.0/dist/xterm.js"></script>
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/fit/fit.js"></script>
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/webLinks/webLinks.js"></script>
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/fullscreen/fullscreen.js"></script>
<script src="https://unpkg.com/xterm@3.6.0/dist/addons/search/search.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<script src="static/cdnback/js/xterm.js"></script>
<script src="static/cdnback/js/addons/fit/fit.js"></script>
<script src="static/cdnback/js/addons/webLinks/webLinks.js"></script>
<script src="static/cdnback/js/addons/fullscreen/fullscreen.js"></script>
<script src="static/cdnback/js/addons/search/search.js"></script>
<link rel="stylesheet" href="static/cdnback/all.min.css">
<link rel="stylesheet" href="/vsc-like/static/css/index.css">
<link rel="stylesheet" href="/vsc-like/static/css/notification.css">
@@ -61,7 +61,7 @@
</div>
<div id="notificationsContainer"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.0/min/vs/loader.js"></script>
<script src="static/cdnback/js/monaco-editor/0.34.0/min/vs/loader.js"></script>
<script src="/vsc-like/static/js/code-like-extension.js"></script>
<script src="/vsc-like/static/js/file.js"></script>
<script src="/vsc-like/static/js/terminal.js"></script>
@@ -148,12 +148,14 @@
});
// 防抖处理非粘贴的编辑操作
const content = changes.text;
// 确保始终使用完整的编辑器内容而非仅变更部分
const fullContent = editor.getValue();
debounceSendToServer({
type: 'fileEdit',
filePath: filePath,
content: content,
content: fullContent,
config: code_like_config,
timestamp: new Date().getTime() // 添加时间戳以便追踪
}, 5000
);
// saveFile

BIN
profile_stats Normal file

Binary file not shown.

BIN
profile_stats_extract Normal file

Binary file not shown.