Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- Dockerfile +20 -0
- index.html +895 -0
- main.py +215 -0
- requirements.txt +4 -0
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用官方的 Python 3.11 镜像
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 赋予工作目录合适的权限 (Hugging Face 的安全要求)
|
| 8 |
+
RUN chmod 777 /app
|
| 9 |
+
|
| 10 |
+
# 将当前目录下的所有文件复制到容器的 /app 目录
|
| 11 |
+
COPY . /app/
|
| 12 |
+
|
| 13 |
+
# 安装依赖
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# 暴露 7860 端口
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
# 启动 FastAPI 服务
|
| 20 |
+
CMD ["python", "main.py"]
|
index.html
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>个人科技树 V6.0</title>
|
| 6 |
+
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vis-network@9.1.2/standalone/umd/vis-network.min.js"></script>
|
| 7 |
+
<style>
|
| 8 |
+
body { font-family: 'Segoe UI', sans-serif; background-color: #0d0d12; color: #fff; margin: 0; display: flex; height: 100vh; overflow: hidden; }
|
| 9 |
+
.sidebar { width: 340px; background: #15151e; border-right: 2px solid #2a2a35; display: flex; flex-direction: column; padding: 16px; box-sizing: border-box; z-index: 100; box-shadow: 5px 0 15px rgba(0,0,0,0.5); overflow-y: auto; }
|
| 10 |
+
.logo { font-size: 18px; font-weight: bold; color: #00d2ff; margin-bottom: 16px; border-bottom: 1px solid #333; padding-bottom: 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 11 |
+
.section { margin-bottom: 18px; }
|
| 12 |
+
.section-title { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; display: block; }
|
| 13 |
+
textarea, .cfg-input { width: 100%; background-color: #050508; color: #e0e0e0; border: 1px solid #333; border-radius: 6px; padding: 8px; font-size: 12px; box-sizing: border-box; }
|
| 14 |
+
textarea { height: 72px; resize: vertical; margin-bottom: 8px; }
|
| 15 |
+
.cfg-input { margin-bottom: 8px; padding: 8px; }
|
| 16 |
+
.cfg-label { font-size: 11px; color: #888; display: block; margin-bottom: 4px; }
|
| 17 |
+
.search-row { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
|
| 18 |
+
.search-box { flex: 1; padding: 8px; background: #000; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 13px; box-sizing: border-box; }
|
| 19 |
+
.btn-group { display: flex; flex-direction: column; gap: 6px; }
|
| 20 |
+
button { width: 100%; padding: 9px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: 0.2s; font-size: 12px; text-align: left; padding-left: 12px; }
|
| 21 |
+
button.inline { width: auto; padding: 8px 10px; flex-shrink: 0; }
|
| 22 |
+
button.primary { background: #00d2ff; color: #000; }
|
| 23 |
+
button.success { background: #00e676; color: #000; }
|
| 24 |
+
button.warning { background: #fac858; color: #000; }
|
| 25 |
+
button.danger { background: #ff4757; color: #fff; }
|
| 26 |
+
button.secondary { background: #2a2a35; color: #ddd; }
|
| 27 |
+
button:hover { opacity: 0.85; transform: translateX(3px); }
|
| 28 |
+
details.api-details { background: #0a0a10; border: 1px solid #2a2a35; border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; }
|
| 29 |
+
details.api-details summary { cursor: pointer; color: #00d2ff; font-size: 12px; font-weight: bold; }
|
| 30 |
+
#main-stage { flex: 1; position: relative; min-width: 0; }
|
| 31 |
+
#network-canvas { width: 100%; height: 100%; background-color: #0d0d12; background-image: radial-gradient(#222 1px, transparent 1px); background-size: 40px 40px; }
|
| 32 |
+
.stats-panel { margin-top: auto; padding: 12px; background: #050508; border-radius: 8px; border: 1px solid #222; flex-shrink: 0; }
|
| 33 |
+
.stat-item { font-size: 11px; color: #aaa; margin-bottom: 4px; display: flex; justify-content: space-between; }
|
| 34 |
+
#context-menu { display: none; position: absolute; background: rgba(20,20,25,0.95); border: 1px solid #00d2ff; border-radius: 8px; z-index: 1000; padding: 5px 0; min-width: 180px; box-shadow: 0 0 20px rgba(0,210,255,0.3); }
|
| 35 |
+
.menu-item { padding: 9px 14px; font-size: 12px; cursor: pointer; color: #ddd; }
|
| 36 |
+
.menu-item:hover { background: #00d2ff; color: #000; }
|
| 37 |
+
#edit-modal, #import-modal { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #15151e; border: 2px solid #00d2ff; padding: 24px; border-radius: 12px; z-index: 101; min-width: 320px; max-width: 90vw; box-shadow: 0 0 50px rgba(0,0,0,0.8); }
|
| 38 |
+
#overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 100; }
|
| 39 |
+
.hint { font-size: 10px; color: #555; margin-top: 4px; line-height: 1.4; }
|
| 40 |
+
#toast { position: fixed; bottom: 20px; right: 20px; max-width: 360px; padding: 12px 16px; background: #1a1a24; border: 1px solid #444; border-radius: 8px; font-size: 13px; z-index: 2000; display: none; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
|
| 41 |
+
#toast.err { border-color: #ff4757; color: #ffb4b4; }
|
| 42 |
+
#toast.ok { border-color: #00e676; color: #b8f5d0; }
|
| 43 |
+
</style>
|
| 44 |
+
</head>
|
| 45 |
+
<body>
|
| 46 |
+
|
| 47 |
+
<div class="sidebar">
|
| 48 |
+
<div class="logo">🚀 TECH TREE <span style="font-size: 10px; background: #333; padding: 2px 6px; border-radius: 3px;">V6.0</span></div>
|
| 49 |
+
|
| 50 |
+
<details class="api-details" open>
|
| 51 |
+
<summary>⚙️ 连接与模型配置(保存在本地浏览器)</summary>
|
| 52 |
+
<label class="cfg-label" for="cfgBackend">后端地址(本机 API)</label>
|
| 53 |
+
<input type="text" class="cfg-input" id="cfgBackend" placeholder="http://127.0.0.1:8000" autocomplete="off">
|
| 54 |
+
<label class="cfg-label" for="cfgLlmBase">LLM Base URL(OpenAI 兼容)</label>
|
| 55 |
+
<input type="text" class="cfg-input" id="cfgLlmBase" placeholder="https://api.example.com/v1" autocomplete="off">
|
| 56 |
+
<label class="cfg-label" for="cfgApiKey">API Key</label>
|
| 57 |
+
<input type="password" class="cfg-input" id="cfgApiKey" placeholder="sk-..." autocomplete="off">
|
| 58 |
+
<label class="cfg-label" for="cfgModelGen">生成树所用模型</label>
|
| 59 |
+
<input type="text" class="cfg-input" id="cfgModelGen" placeholder="例如 DeepSeek-V3.2">
|
| 60 |
+
<label class="cfg-label" for="cfgModelExpand">拓展分支所用模型</label>
|
| 61 |
+
<input type="text" class="cfg-input" id="cfgModelExpand" placeholder="可与上相同或另选">
|
| 62 |
+
<button type="button" class="secondary" onclick="saveApiConfig()">💾 保存配置到本地</button>
|
| 63 |
+
<p class="hint">API Key 仅存于本机 localStorage;请勿在公共电脑上保存敏感 Key。也可直接运行 <code>python main.py</code> 后打开本页并访问同一后端。</p>
|
| 64 |
+
</details>
|
| 65 |
+
|
| 66 |
+
<div class="section">
|
| 67 |
+
<span class="section-title">🔍 快速定位</span>
|
| 68 |
+
<div class="search-row">
|
| 69 |
+
<input type="search" class="search-box" id="nodeSearch" placeholder="搜索技能名称..." aria-label="搜索技能" oninput="searchNode(false)">
|
| 70 |
+
<button type="button" class="secondary inline" onclick="searchNode(true)" title="下一个匹配">下一个</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="section">
|
| 75 |
+
<span class="section-title">🧠 AI 助手</span>
|
| 76 |
+
<textarea id="userInput" placeholder="输入你学过的课程、技能等生成个人科技树..."></textarea>
|
| 77 |
+
<button type="button" class="primary" onclick="generateTree()">✨ 重新生成</button>
|
| 78 |
+
<div id="loading" style="display:none; font-size:11px; color:#00d2ff; margin-top:6px;">正在连接大模型...</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div class="section">
|
| 82 |
+
<span class="section-title">🛠️ 编辑工具</span>
|
| 83 |
+
<div class="btn-group">
|
| 84 |
+
<button type="button" class="success" onclick="addNewNode()">➕ 新增技能点</button>
|
| 85 |
+
<button type="button" class="warning" onclick="enableDrawEdge()">🔗 手动建立连接</button>
|
| 86 |
+
<button type="button" class="secondary" onclick="runPhysicsLayout()">🌐 力导向整理(约 2.5 秒)</button>
|
| 87 |
+
<button type="button" class="secondary" onclick="undo()" title="Ctrl+Z">↩️ 撤销</button>
|
| 88 |
+
<button type="button" class="secondary" onclick="redo()" title="Ctrl+Y">↪️ 重做</button>
|
| 89 |
+
<button type="button" class="secondary" onclick="exportJson()">📄 导出 JSON</button>
|
| 90 |
+
<button type="button" class="secondary" onclick="openImportModal()">📥 导入 JSON</button>
|
| 91 |
+
<button type="button" class="secondary" onclick="exportToImage()">📸 导出高清 PNG</button>
|
| 92 |
+
<button type="button" class="danger" onclick="clearAll()">🗑️ 清空当前画布</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div class="stats-panel">
|
| 97 |
+
<span class="section-title">📊 状态看板</span>
|
| 98 |
+
<div class="stat-item"><span>总技能数</span> <b id="stat-total">0</b></div>
|
| 99 |
+
<div class="stat-item"><span>边数</span> <b id="stat-edges">0</b></div>
|
| 100 |
+
<div class="stat-item"><span>叶子节点</span> <b id="stat-leaves">0</b></div>
|
| 101 |
+
<div class="stat-item"><span>连通分量</span> <b id="stat-components">0</b></div>
|
| 102 |
+
<div class="stat-item"><span>已点亮 (>80%)</span> <b id="stat-mastered" style="color:#00e676;">0</b></div>
|
| 103 |
+
<div class="stat-item"><span>平均熟练度</span> <b id="stat-avg">0%</b></div>
|
| 104 |
+
</div>
|
| 105 |
+
<div id="save-status" style="font-size: 10px; color: #555; margin-top: 8px; text-align: center;">存档已同步到本地</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div id="main-stage">
|
| 109 |
+
<div id="network-canvas"></div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div id="context-menu" role="menu" aria-label="节点菜单">
|
| 113 |
+
<div class="menu-item" role="menuitem" onclick="handleMenuExpand()">✨ AI 向下拓展分支</div>
|
| 114 |
+
<div class="menu-item" role="menuitem" onclick="handleMenuToggleCollapse()">📂 折叠 / 展开子项</div>
|
| 115 |
+
<div class="menu-item" role="menuitem" onclick="handleMenuEdit()">⚙️ 修改属性</div>
|
| 116 |
+
<div class="menu-item" style="color:#ff4757" role="menuitem" onclick="handleMenuDelete()">🗑️ 删除节点</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div id="overlay" onclick="closeAllModals()"></div>
|
| 120 |
+
<div id="edit-modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
| 121 |
+
<h3 id="edit-title" style="margin-top:0">配置技能属性</h3>
|
| 122 |
+
<input type="hidden" id="edit-id">
|
| 123 |
+
<div style="margin-bottom:12px">
|
| 124 |
+
<label class="cfg-label" for="edit-name">技能名称</label>
|
| 125 |
+
<input type="text" id="edit-name" class="cfg-input" style="margin-bottom:0">
|
| 126 |
+
</div>
|
| 127 |
+
<div style="margin-bottom:12px">
|
| 128 |
+
<label class="cfg-label" for="edit-mastery">熟练度 (<span id="mastery-value" style="color:#00d2ff">0</span>%)</label>
|
| 129 |
+
<input type="range" id="edit-mastery" min="0" max="100" style="width:100%; margin-top:6px;" oninput="document.getElementById('mastery-value').innerText = this.value" aria-valuemin="0" aria-valuemax="100">
|
| 130 |
+
</div>
|
| 131 |
+
<div style="margin-bottom:12px">
|
| 132 |
+
<label class="cfg-label" for="edit-note">备注</label>
|
| 133 |
+
<textarea id="edit-note" rows="2" style="height:auto; margin-bottom:0"></textarea>
|
| 134 |
+
</div>
|
| 135 |
+
<div style="margin-bottom:12px">
|
| 136 |
+
<label class="cfg-label" for="edit-link">相关链接</label>
|
| 137 |
+
<input type="url" id="edit-link" class="cfg-input" style="margin-bottom:0" placeholder="https://">
|
| 138 |
+
</div>
|
| 139 |
+
<div style="text-align:right">
|
| 140 |
+
<button type="button" class="primary" onclick="saveNode()" style="width:auto; padding:8px 20px;">💾 确认保存</button>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div id="import-modal" role="dialog" aria-modal="true" aria-labelledby="import-title">
|
| 145 |
+
<h3 id="import-title" style="margin-top:0">导入 JSON</h3>
|
| 146 |
+
<p class="hint" style="color:#888">将替换当前画布(可先导出备份)。支持旧版仅含 nodes/edges 的存档。</p>
|
| 147 |
+
<input type="file" id="import-file" accept="application/json,.json" style="margin-bottom:12px; color:#ccc;">
|
| 148 |
+
<div style="text-align:right; display:flex; gap:8px; justify-content:flex-end;">
|
| 149 |
+
<button type="button" class="secondary" onclick="closeImportModal()" style="width:auto;">取消</button>
|
| 150 |
+
<button type="button" class="primary" onclick="confirmImportJson()" style="width:auto;">导入</button>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div id="toast" role="status"></div>
|
| 155 |
+
|
| 156 |
+
<script>
|
| 157 |
+
var nodesDataset, edgesDataset, network = null;
|
| 158 |
+
var activeContextNode = null;
|
| 159 |
+
var collapsedSubtrees = {};
|
| 160 |
+
var undoStack = [];
|
| 161 |
+
var redoStack = [];
|
| 162 |
+
var MAX_HISTORY = 40;
|
| 163 |
+
var searchMatches = [];
|
| 164 |
+
var searchMatchIdx = -1;
|
| 165 |
+
var toastTimer = null;
|
| 166 |
+
|
| 167 |
+
var API_CFG_KEY = 'techTree_api_cfg';
|
| 168 |
+
var DATA_KEY = 'techTree_data';
|
| 169 |
+
|
| 170 |
+
function toast(msg, isErr) {
|
| 171 |
+
var el = document.getElementById('toast');
|
| 172 |
+
el.textContent = msg;
|
| 173 |
+
el.className = isErr ? 'err' : 'ok';
|
| 174 |
+
el.style.display = 'block';
|
| 175 |
+
clearTimeout(toastTimer);
|
| 176 |
+
toastTimer = setTimeout(function() { el.style.display = 'none'; }, 4500);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function escapeXml(s) {
|
| 180 |
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function nodeTitle(cd) {
|
| 184 |
+
var parts = [cd.name || ''];
|
| 185 |
+
if (cd.note) parts.push(cd.note);
|
| 186 |
+
if (cd.link) parts.push(cd.link);
|
| 187 |
+
return parts.join('\n');
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function createNodeSvg(name, mastery) {
|
| 191 |
+
mastery = parseInt(mastery, 10) || 0;
|
| 192 |
+
var safe = escapeXml(name);
|
| 193 |
+
var width = 240, height = 100, barWidth = 200;
|
| 194 |
+
var progressWidth = (mastery / 100) * barWidth;
|
| 195 |
+
var color = (mastery >= 80) ? '#00d2ff' : (mastery >= 40 ? '#fac858' : '#555');
|
| 196 |
+
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
|
| 197 |
+
'<rect x="5" y="5" width="230" height="90" rx="10" fill="#15151e" stroke="' + color + '" stroke-width="3"/>' +
|
| 198 |
+
'<text x="120" y="40" font-family="Segoe UI,Arial" font-size="16" font-weight="bold" fill="#fff" text-anchor="middle">' + safe + '</text>' +
|
| 199 |
+
'<rect x="20" y="70" width="' + barWidth + '" height="10" rx="5" fill="#2a2a35"/>' +
|
| 200 |
+
'<rect x="20" y="70" width="' + progressWidth + '" height="10" rx="5" fill="' + color + '"/>' +
|
| 201 |
+
'<text x="120" y="62" font-family="Segoe UI,Arial" font-size="12" fill="#888" text-anchor="middle">' + mastery + '%</text>' +
|
| 202 |
+
'</svg>';
|
| 203 |
+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function normalizeCustomData(cd) {
|
| 207 |
+
cd = cd || {};
|
| 208 |
+
var m = parseInt(cd.mastery, 10);
|
| 209 |
+
if (isNaN(m)) m = 0;
|
| 210 |
+
return {
|
| 211 |
+
name: cd.name != null ? String(cd.name) : '未命名',
|
| 212 |
+
mastery: m,
|
| 213 |
+
note: cd.note != null ? String(cd.note) : '',
|
| 214 |
+
link: cd.link != null ? String(cd.link) : ''
|
| 215 |
+
};
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function buildVisNode(raw) {
|
| 219 |
+
var cd = normalizeCustomData(raw.customData || raw);
|
| 220 |
+
var x = raw.x != null ? raw.x : 0;
|
| 221 |
+
var y = raw.y != null ? raw.y : 0;
|
| 222 |
+
return {
|
| 223 |
+
id: raw.id,
|
| 224 |
+
shape: 'image',
|
| 225 |
+
image: raw.image || createNodeSvg(cd.name, cd.mastery),
|
| 226 |
+
customData: cd,
|
| 227 |
+
x: x,
|
| 228 |
+
y: y,
|
| 229 |
+
title: nodeTitle(cd)
|
| 230 |
+
};
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function serializeNodeForSave(n) {
|
| 234 |
+
var x = n.x, y = n.y;
|
| 235 |
+
try {
|
| 236 |
+
if (network) {
|
| 237 |
+
var p = network.getPosition(n.id);
|
| 238 |
+
x = p.x;
|
| 239 |
+
y = p.y;
|
| 240 |
+
}
|
| 241 |
+
} catch (e) {}
|
| 242 |
+
return {
|
| 243 |
+
id: n.id,
|
| 244 |
+
x: x,
|
| 245 |
+
y: y,
|
| 246 |
+
customData: normalizeCustomData(n.customData)
|
| 247 |
+
};
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function getSnapshot() {
|
| 251 |
+
return {
|
| 252 |
+
version: 2,
|
| 253 |
+
nodes: nodesDataset.get().map(serializeNodeForSave),
|
| 254 |
+
edges: edgesDataset.get().map(function(e) {
|
| 255 |
+
return { id: e.id, from: e.from, to: e.to, arrows: e.arrows || 'to' };
|
| 256 |
+
}),
|
| 257 |
+
collapsed: JSON.parse(JSON.stringify(collapsedSubtrees))
|
| 258 |
+
};
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function applySnapshot(snap) {
|
| 262 |
+
nodesDataset.clear();
|
| 263 |
+
edgesDataset.clear();
|
| 264 |
+
collapsedSubtrees = snap.collapsed && typeof snap.collapsed === 'object' ? snap.collapsed : {};
|
| 265 |
+
(snap.nodes || []).forEach(function(raw) {
|
| 266 |
+
nodesDataset.add(buildVisNode(raw));
|
| 267 |
+
});
|
| 268 |
+
(snap.edges || []).forEach(function(e) {
|
| 269 |
+
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
|
| 270 |
+
});
|
| 271 |
+
updateStats();
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function pushHistory() {
|
| 275 |
+
if (!nodesDataset) return;
|
| 276 |
+
undoStack.push(getSnapshot());
|
| 277 |
+
if (undoStack.length > MAX_HISTORY) undoStack.shift();
|
| 278 |
+
redoStack = [];
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function undo() {
|
| 282 |
+
if (!undoStack.length) return;
|
| 283 |
+
redoStack.push(getSnapshot());
|
| 284 |
+
applySnapshot(undoStack.pop());
|
| 285 |
+
autoSave();
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function redo() {
|
| 289 |
+
if (!redoStack.length) return;
|
| 290 |
+
undoStack.push(getSnapshot());
|
| 291 |
+
applySnapshot(redoStack.pop());
|
| 292 |
+
autoSave();
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function apiBase() {
|
| 296 |
+
var b = document.getElementById('cfgBackend').value.trim().replace(/\/$/, '');
|
| 297 |
+
return b || 'http://127.0.0.1:8000';
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function readLlmConfig() {
|
| 301 |
+
return {
|
| 302 |
+
api_key: document.getElementById('cfgApiKey').value.trim(),
|
| 303 |
+
base_url: document.getElementById('cfgLlmBase').value.trim(),
|
| 304 |
+
model_gen: document.getElementById('cfgModelGen').value.trim(),
|
| 305 |
+
model_expand: document.getElementById('cfgModelExpand').value.trim()
|
| 306 |
+
};
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function saveApiConfig() {
|
| 310 |
+
var o = {
|
| 311 |
+
backend: document.getElementById('cfgBackend').value.trim(),
|
| 312 |
+
llmBase: document.getElementById('cfgLlmBase').value.trim(),
|
| 313 |
+
apiKey: document.getElementById('cfgApiKey').value,
|
| 314 |
+
modelGen: document.getElementById('cfgModelGen').value.trim(),
|
| 315 |
+
modelExpand: document.getElementById('cfgModelExpand').value.trim()
|
| 316 |
+
};
|
| 317 |
+
localStorage.setItem(API_CFG_KEY, JSON.stringify(o));
|
| 318 |
+
toast('配置已保存到本地', false);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function loadApiConfig() {
|
| 322 |
+
try {
|
| 323 |
+
var raw = localStorage.getItem(API_CFG_KEY);
|
| 324 |
+
if (!raw) return;
|
| 325 |
+
var o = JSON.parse(raw);
|
| 326 |
+
if (o.backend) document.getElementById('cfgBackend').value = o.backend;
|
| 327 |
+
if (o.llmBase) document.getElementById('cfgLlmBase').value = o.llmBase;
|
| 328 |
+
if (o.apiKey) document.getElementById('cfgApiKey').value = o.apiKey;
|
| 329 |
+
if (o.modelGen) document.getElementById('cfgModelGen').value = o.modelGen;
|
| 330 |
+
if (o.modelExpand) document.getElementById('cfgModelExpand').value = o.modelExpand;
|
| 331 |
+
} catch (e) {}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function parseApiError(res, result) {
|
| 335 |
+
var msg = (result && result.message) ? result.message : '';
|
| 336 |
+
if (!msg && result && result.detail) {
|
| 337 |
+
if (typeof result.detail === 'string') msg = result.detail;
|
| 338 |
+
else if (Array.isArray(result.detail))
|
| 339 |
+
msg = result.detail.map(function(x) { return (x.msg || '') + (x.loc ? ' @' + x.loc.join('.') : ''); }).join('; ');
|
| 340 |
+
else if (result.detail.message) msg = result.detail.message;
|
| 341 |
+
else msg = JSON.stringify(result.detail);
|
| 342 |
+
}
|
| 343 |
+
if (!msg) msg = res.statusText || ('HTTP ' + res.status);
|
| 344 |
+
return msg;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
function initNetwork() {
|
| 348 |
+
var container = document.getElementById('network-canvas');
|
| 349 |
+
var options = {
|
| 350 |
+
physics: { enabled: false },
|
| 351 |
+
edges: { smooth: { type: 'cubicBezier', forceDirection: 'horizontal' }, color: '#444', arrows: 'to', width: 2 },
|
| 352 |
+
interaction: { hover: true, dragNodes: true },
|
| 353 |
+
manipulation: {
|
| 354 |
+
enabled: false,
|
| 355 |
+
addEdge: function(d, c) {
|
| 356 |
+
pushHistory();
|
| 357 |
+
d.arrows = 'to';
|
| 358 |
+
edgesDataset.add(d);
|
| 359 |
+
c(null);
|
| 360 |
+
container.style.cursor = 'default';
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
};
|
| 364 |
+
network = new vis.Network(container, { nodes: nodesDataset, edges: edgesDataset }, options);
|
| 365 |
+
|
| 366 |
+
network.on('oncontext', function(p) {
|
| 367 |
+
p.event.preventDefault();
|
| 368 |
+
var nodeId = network.getNodeAt(p.pointer.DOM);
|
| 369 |
+
if (nodeId) {
|
| 370 |
+
activeContextNode = nodeId;
|
| 371 |
+
var menu = document.getElementById('context-menu');
|
| 372 |
+
menu.style.display = 'block';
|
| 373 |
+
menu.style.left = p.event.clientX + 'px';
|
| 374 |
+
menu.style.top = p.event.clientY + 'px';
|
| 375 |
+
}
|
| 376 |
+
});
|
| 377 |
+
network.on('click', function() {
|
| 378 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function searchNode(advance) {
|
| 383 |
+
var input = document.getElementById('nodeSearch');
|
| 384 |
+
var term = (input.value || '').trim().toLowerCase();
|
| 385 |
+
if (!term) {
|
| 386 |
+
searchMatches = [];
|
| 387 |
+
searchMatchIdx = -1;
|
| 388 |
+
return;
|
| 389 |
+
}
|
| 390 |
+
if (!advance || !searchMatches.length) {
|
| 391 |
+
searchMatches = nodesDataset.get({
|
| 392 |
+
filter: function(n) {
|
| 393 |
+
var name = (n.customData && n.customData.name) ? n.customData.name : '';
|
| 394 |
+
return name.toLowerCase().indexOf(term) !== -1;
|
| 395 |
+
}
|
| 396 |
+
});
|
| 397 |
+
searchMatchIdx = -1;
|
| 398 |
+
}
|
| 399 |
+
if (!searchMatches.length) {
|
| 400 |
+
toast('未找到匹配节点', true);
|
| 401 |
+
return;
|
| 402 |
+
}
|
| 403 |
+
searchMatchIdx = (searchMatchIdx + 1) % searchMatches.length;
|
| 404 |
+
var node = searchMatches[searchMatchIdx];
|
| 405 |
+
network.focus(node.id, { scale: 1.1, animation: { duration: 450, easingFunction: 'easeInOutQuad' } });
|
| 406 |
+
network.selectNodes([node.id]);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function countLeaves() {
|
| 410 |
+
var hasOut = {};
|
| 411 |
+
edgesDataset.get().forEach(function(e) { hasOut[e.from] = true; });
|
| 412 |
+
return nodesDataset.get().filter(function(n) { return !hasOut[n.id]; }).length;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function countComponents() {
|
| 416 |
+
var ids = nodesDataset.getIds();
|
| 417 |
+
var adj = {};
|
| 418 |
+
ids.forEach(function(id) { adj[id] = []; });
|
| 419 |
+
edgesDataset.get().forEach(function(e) {
|
| 420 |
+
adj[e.from].push(e.to);
|
| 421 |
+
adj[e.to].push(e.from);
|
| 422 |
+
});
|
| 423 |
+
var seen = {};
|
| 424 |
+
var c = 0;
|
| 425 |
+
ids.forEach(function(id) {
|
| 426 |
+
if (seen[id]) return;
|
| 427 |
+
c++;
|
| 428 |
+
var q = [id];
|
| 429 |
+
seen[id] = true;
|
| 430 |
+
for (var i = 0; i < q.length; i++) {
|
| 431 |
+
var u = q[i];
|
| 432 |
+
(adj[u] || []).forEach(function(v) {
|
| 433 |
+
if (!seen[v]) { seen[v] = true; q.push(v); }
|
| 434 |
+
});
|
| 435 |
+
}
|
| 436 |
+
});
|
| 437 |
+
return c;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
function updateStats() {
|
| 441 |
+
var all = nodesDataset.get();
|
| 442 |
+
document.getElementById('stat-total').innerText = all.length;
|
| 443 |
+
document.getElementById('stat-edges').innerText = edgesDataset.length;
|
| 444 |
+
document.getElementById('stat-leaves').innerText = all.length ? String(countLeaves()) : '0';
|
| 445 |
+
document.getElementById('stat-components').innerText = all.length ? String(countComponents()) : '0';
|
| 446 |
+
var mastered = all.filter(function(n) { return (parseInt(n.customData.mastery, 10) || 0) >= 80; }).length;
|
| 447 |
+
document.getElementById('stat-mastered').innerText = mastered;
|
| 448 |
+
var avg = all.length ? Math.round(all.reduce(function(s, n) { return s + (parseInt(n.customData.mastery, 10) || 0); }, 0) / all.length) : 0;
|
| 449 |
+
document.getElementById('stat-avg').innerText = avg + '%';
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function applyTreeFromAi(data) {
|
| 453 |
+
data.nodes.forEach(function(n, i) {
|
| 454 |
+
var cd = normalizeCustomData({ name: n.name, mastery: n.mastery });
|
| 455 |
+
nodesDataset.add({
|
| 456 |
+
id: n.id,
|
| 457 |
+
shape: 'image',
|
| 458 |
+
image: createNodeSvg(cd.name, cd.mastery),
|
| 459 |
+
customData: cd,
|
| 460 |
+
x: (i % 3) * 300,
|
| 461 |
+
y: Math.floor(i / 3) * 200,
|
| 462 |
+
title: nodeTitle(cd)
|
| 463 |
+
});
|
| 464 |
+
});
|
| 465 |
+
data.edges.forEach(function(e) {
|
| 466 |
+
edgesDataset.add({ from: e.source, to: e.target, arrows: 'to' });
|
| 467 |
+
});
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
async function generateTree() {
|
| 471 |
+
var text = document.getElementById('userInput').value.trim();
|
| 472 |
+
if (!text) return;
|
| 473 |
+
var cfg = readLlmConfig();
|
| 474 |
+
if (!cfg.api_key || !cfg.base_url || !cfg.model_gen) {
|
| 475 |
+
toast('请填写 API Key、LLM Base URL 与生成模型', true);
|
| 476 |
+
return;
|
| 477 |
+
}
|
| 478 |
+
document.getElementById('loading').style.display = 'block';
|
| 479 |
+
try {
|
| 480 |
+
var res = await fetch(apiBase() + '/generate_tree', {
|
| 481 |
+
method: 'POST',
|
| 482 |
+
headers: { 'Content-Type': 'application/json' },
|
| 483 |
+
body: JSON.stringify({
|
| 484 |
+
text: text,
|
| 485 |
+
api_key: cfg.api_key,
|
| 486 |
+
base_url: cfg.base_url,
|
| 487 |
+
model: cfg.model_gen
|
| 488 |
+
})
|
| 489 |
+
});
|
| 490 |
+
var result = await res.json().catch(function() { return {}; });
|
| 491 |
+
if (!res.ok || result.status !== 'success') {
|
| 492 |
+
toast(parseApiError(res, result), true);
|
| 493 |
+
return;
|
| 494 |
+
}
|
| 495 |
+
pushHistory();
|
| 496 |
+
nodesDataset.clear();
|
| 497 |
+
edgesDataset.clear();
|
| 498 |
+
collapsedSubtrees = {};
|
| 499 |
+
applyTreeFromAi(result.data);
|
| 500 |
+
toast('生成成功', false);
|
| 501 |
+
} catch (e) {
|
| 502 |
+
toast('请求失败:' + (e.message || String(e)), true);
|
| 503 |
+
} finally {
|
| 504 |
+
document.getElementById('loading').style.display = 'none';
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
function uniquifyExpandPayload(data, parentId) {
|
| 509 |
+
var existing = {};
|
| 510 |
+
nodesDataset.getIds().forEach(function(id) { existing[id] = true; });
|
| 511 |
+
var idMap = {};
|
| 512 |
+
data.nodes.forEach(function(n) {
|
| 513 |
+
var oid = n.id;
|
| 514 |
+
var nid = oid;
|
| 515 |
+
if (existing[nid]) {
|
| 516 |
+
var k = 1;
|
| 517 |
+
do { nid = oid + '_' + k++; } while (existing[nid]);
|
| 518 |
+
}
|
| 519 |
+
existing[nid] = true;
|
| 520 |
+
if (nid !== oid) idMap[oid] = nid;
|
| 521 |
+
});
|
| 522 |
+
var nodesOut = data.nodes.map(function(n) {
|
| 523 |
+
return {
|
| 524 |
+
id: idMap[n.id] || n.id,
|
| 525 |
+
name: n.name,
|
| 526 |
+
mastery: n.mastery
|
| 527 |
+
};
|
| 528 |
+
});
|
| 529 |
+
var edgesOut = data.edges.map(function(e) {
|
| 530 |
+
var s = e.source === parentId ? parentId : (idMap[e.source] || e.source);
|
| 531 |
+
var t = idMap[e.target] || e.target;
|
| 532 |
+
return { source: s, target: t };
|
| 533 |
+
});
|
| 534 |
+
return { nodes: nodesOut, edges: edgesOut };
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
async function handleMenuExpand() {
|
| 538 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 539 |
+
if (!activeContextNode) return;
|
| 540 |
+
var cfg = readLlmConfig();
|
| 541 |
+
if (!cfg.api_key || !cfg.base_url || !cfg.model_expand) {
|
| 542 |
+
toast('请填写 API Key、LLM Base URL 与拓展模型', true);
|
| 543 |
+
return;
|
| 544 |
+
}
|
| 545 |
+
var node = nodesDataset.get(activeContextNode);
|
| 546 |
+
var name = node.customData.name;
|
| 547 |
+
document.getElementById('loading').style.display = 'block';
|
| 548 |
+
try {
|
| 549 |
+
var res = await fetch(apiBase() + '/expand_node', {
|
| 550 |
+
method: 'POST',
|
| 551 |
+
headers: { 'Content-Type': 'application/json' },
|
| 552 |
+
body: JSON.stringify({
|
| 553 |
+
node_id: String(activeContextNode),
|
| 554 |
+
node_name: name,
|
| 555 |
+
api_key: cfg.api_key,
|
| 556 |
+
base_url: cfg.base_url,
|
| 557 |
+
model: cfg.model_expand
|
| 558 |
+
})
|
| 559 |
+
});
|
| 560 |
+
var result = await res.json().catch(function() { return {}; });
|
| 561 |
+
if (!res.ok || result.status !== 'success') {
|
| 562 |
+
toast(parseApiError(res, result), true);
|
| 563 |
+
return;
|
| 564 |
+
}
|
| 565 |
+
var merged = uniquifyExpandPayload(result.data, String(activeContextNode));
|
| 566 |
+
pushHistory();
|
| 567 |
+
var pos = network.getPosition(activeContextNode);
|
| 568 |
+
merged.nodes.forEach(function(n, i) {
|
| 569 |
+
var cd = normalizeCustomData({ name: n.name, mastery: n.mastery });
|
| 570 |
+
nodesDataset.add({
|
| 571 |
+
id: n.id,
|
| 572 |
+
shape: 'image',
|
| 573 |
+
image: createNodeSvg(cd.name, cd.mastery),
|
| 574 |
+
customData: cd,
|
| 575 |
+
x: pos.x + 280 + (i % 3) * 30,
|
| 576 |
+
y: pos.y + (i - 1) * 130,
|
| 577 |
+
title: nodeTitle(cd)
|
| 578 |
+
});
|
| 579 |
+
});
|
| 580 |
+
merged.edges.forEach(function(e) {
|
| 581 |
+
edgesDataset.add({ from: e.source, to: e.target, arrows: 'to' });
|
| 582 |
+
});
|
| 583 |
+
toast('已拓展分支', false);
|
| 584 |
+
} catch (e) {
|
| 585 |
+
toast('拓展失败:' + (e.message || String(e)), true);
|
| 586 |
+
} finally {
|
| 587 |
+
document.getElementById('loading').style.display = 'none';
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
function getDescendantsToCollapse(rootId) {
|
| 592 |
+
var edges = edgesDataset.get();
|
| 593 |
+
var out = {};
|
| 594 |
+
edges.forEach(function(e) {
|
| 595 |
+
if (!out[e.from]) out[e.from] = [];
|
| 596 |
+
out[e.from].push(e.to);
|
| 597 |
+
});
|
| 598 |
+
var reachable = {};
|
| 599 |
+
var q = [rootId];
|
| 600 |
+
reachable[rootId] = true;
|
| 601 |
+
for (var i = 0; i < q.length; i++) {
|
| 602 |
+
var u = q[i];
|
| 603 |
+
(out[u] || []).forEach(function(v) {
|
| 604 |
+
if (!reachable[v]) { reachable[v] = true; q.push(v); }
|
| 605 |
+
});
|
| 606 |
+
}
|
| 607 |
+
var pred = {};
|
| 608 |
+
edges.forEach(function(e) {
|
| 609 |
+
if (!pred[e.to]) pred[e.to] = [];
|
| 610 |
+
pred[e.to].push(e.from);
|
| 611 |
+
});
|
| 612 |
+
var toRemove = {};
|
| 613 |
+
Object.keys(reachable).forEach(function(v) {
|
| 614 |
+
if (v === rootId) return;
|
| 615 |
+
var ps = pred[v] || [];
|
| 616 |
+
var ok = ps.every(function(p) { return reachable[p]; });
|
| 617 |
+
if (ok) toRemove[v] = true;
|
| 618 |
+
});
|
| 619 |
+
return Object.keys(toRemove);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
function handleMenuToggleCollapse() {
|
| 623 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 624 |
+
if (!activeContextNode) return;
|
| 625 |
+
var rid = activeContextNode;
|
| 626 |
+
if (collapsedSubtrees[rid]) {
|
| 627 |
+
pushHistory();
|
| 628 |
+
var pack = collapsedSubtrees[rid];
|
| 629 |
+
delete collapsedSubtrees[rid];
|
| 630 |
+
pack.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
|
| 631 |
+
pack.edges.forEach(function(e) { edgesDataset.add({ from: e.from, to: e.to, arrows: 'to' }); });
|
| 632 |
+
toast('已展开子项', false);
|
| 633 |
+
return;
|
| 634 |
+
}
|
| 635 |
+
var removeIds = getDescendantsToCollapse(rid);
|
| 636 |
+
if (!removeIds.length) {
|
| 637 |
+
toast('没有可折叠的后继子图(或存在外部父节点指向的共享节点)', true);
|
| 638 |
+
return;
|
| 639 |
+
}
|
| 640 |
+
var removeSet = {};
|
| 641 |
+
removeIds.forEach(function(id) { removeSet[id] = true; });
|
| 642 |
+
var nodesToStore = [];
|
| 643 |
+
var edgesToStore = [];
|
| 644 |
+
removeIds.forEach(function(id) {
|
| 645 |
+
nodesToStore.push(serializeNodeForSave(nodesDataset.get(id)));
|
| 646 |
+
});
|
| 647 |
+
edgesDataset.get().forEach(function(e) {
|
| 648 |
+
if (removeSet[e.from] || removeSet[e.to]) {
|
| 649 |
+
edgesToStore.push({ id: e.id, from: e.from, to: e.to, arrows: e.arrows || 'to' });
|
| 650 |
+
}
|
| 651 |
+
});
|
| 652 |
+
pushHistory();
|
| 653 |
+
var edgeIds = edgesDataset.get({
|
| 654 |
+
filter: function(e) { return removeSet[e.from] || removeSet[e.to]; }
|
| 655 |
+
});
|
| 656 |
+
edgeIds.forEach(function(e) { edgesDataset.remove(e.id); });
|
| 657 |
+
removeIds.forEach(function(id) { nodesDataset.remove(id); });
|
| 658 |
+
collapsedSubtrees[rid] = { nodes: nodesToStore, edges: edgesToStore.map(function(e) {
|
| 659 |
+
return { from: e.from, to: e.to, arrows: e.arrows || 'to' };
|
| 660 |
+
}) };
|
| 661 |
+
toast('已折叠子项', false);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function handleMenuDelete() {
|
| 665 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 666 |
+
if (!activeContextNode) return;
|
| 667 |
+
var id = activeContextNode;
|
| 668 |
+
pushHistory();
|
| 669 |
+
edgesDataset.get({
|
| 670 |
+
filter: function(e) { return e.from === id || e.to === id; }
|
| 671 |
+
}).forEach(function(e) { edgesDataset.remove(e.id); });
|
| 672 |
+
nodesDataset.remove(id);
|
| 673 |
+
delete collapsedSubtrees[id];
|
| 674 |
+
Object.keys(collapsedSubtrees).forEach(function(k) {
|
| 675 |
+
if (k === id) delete collapsedSubtrees[k];
|
| 676 |
+
});
|
| 677 |
+
activeContextNode = null;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
function handleMenuEdit() {
|
| 681 |
+
var n = nodesDataset.get(activeContextNode);
|
| 682 |
+
document.getElementById('edit-id').value = n.id;
|
| 683 |
+
document.getElementById('edit-name').value = n.customData.name;
|
| 684 |
+
document.getElementById('edit-mastery').value = n.customData.mastery;
|
| 685 |
+
document.getElementById('mastery-value').innerText = n.customData.mastery;
|
| 686 |
+
document.getElementById('edit-note').value = n.customData.note || '';
|
| 687 |
+
document.getElementById('edit-link').value = n.customData.link || '';
|
| 688 |
+
document.getElementById('overlay').style.display = 'block';
|
| 689 |
+
document.getElementById('edit-modal').style.display = 'block';
|
| 690 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
function saveNode() {
|
| 694 |
+
var id = document.getElementById('edit-id').value;
|
| 695 |
+
var name = document.getElementById('edit-name').value.trim() || '未命名';
|
| 696 |
+
var m = document.getElementById('edit-mastery').value;
|
| 697 |
+
var note = document.getElementById('edit-note').value;
|
| 698 |
+
var link = document.getElementById('edit-link').value.trim();
|
| 699 |
+
pushHistory();
|
| 700 |
+
var old = nodesDataset.get(id);
|
| 701 |
+
var cd = normalizeCustomData({ name: name, mastery: m, note: note, link: link });
|
| 702 |
+
nodesDataset.update({
|
| 703 |
+
id: id,
|
| 704 |
+
image: createNodeSvg(cd.name, cd.mastery),
|
| 705 |
+
customData: cd,
|
| 706 |
+
x: old.x,
|
| 707 |
+
y: old.y,
|
| 708 |
+
title: nodeTitle(cd)
|
| 709 |
+
});
|
| 710 |
+
closeAllModals();
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
function closeAllModals() {
|
| 714 |
+
document.getElementById('overlay').style.display = 'none';
|
| 715 |
+
document.getElementById('edit-modal').style.display = 'none';
|
| 716 |
+
document.getElementById('import-modal').style.display = 'none';
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
function closeImportModal() {
|
| 720 |
+
document.getElementById('import-modal').style.display = 'none';
|
| 721 |
+
document.getElementById('overlay').style.display = 'none';
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
function openImportModal() {
|
| 725 |
+
document.getElementById('overlay').style.display = 'block';
|
| 726 |
+
document.getElementById('import-modal').style.display = 'block';
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
function autoSave() {
|
| 730 |
+
if (!nodesDataset || !network) return;
|
| 731 |
+
try {
|
| 732 |
+
var snap = getSnapshot();
|
| 733 |
+
localStorage.setItem(DATA_KEY, JSON.stringify(snap));
|
| 734 |
+
var el = document.getElementById('save-status');
|
| 735 |
+
el.textContent = '存档已同步到本地 ' + new Date().toLocaleTimeString();
|
| 736 |
+
el.style.color = '#666';
|
| 737 |
+
} catch (e) {}
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
function loadFromLocal() {
|
| 741 |
+
try {
|
| 742 |
+
var raw = localStorage.getItem(DATA_KEY);
|
| 743 |
+
if (!raw) return;
|
| 744 |
+
var saved = JSON.parse(raw);
|
| 745 |
+
if (!saved || !saved.nodes) return;
|
| 746 |
+
collapsedSubtrees = saved.collapsed && typeof saved.collapsed === 'object' ? saved.collapsed : {};
|
| 747 |
+
saved.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
|
| 748 |
+
(saved.edges || []).forEach(function(e) {
|
| 749 |
+
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
|
| 750 |
+
});
|
| 751 |
+
} catch (e) {
|
| 752 |
+
console.warn(e);
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
function exportToImage() {
|
| 757 |
+
var canvas = document.querySelector('#network-canvas canvas');
|
| 758 |
+
if (!canvas) {
|
| 759 |
+
toast('画布尚未就绪', true);
|
| 760 |
+
return;
|
| 761 |
+
}
|
| 762 |
+
var temp = document.createElement('canvas');
|
| 763 |
+
temp.width = canvas.width;
|
| 764 |
+
temp.height = canvas.height;
|
| 765 |
+
var ctx = temp.getContext('2d');
|
| 766 |
+
ctx.fillStyle = '#0d0d12';
|
| 767 |
+
ctx.fillRect(0, 0, temp.width, temp.height);
|
| 768 |
+
ctx.drawImage(canvas, 0, 0);
|
| 769 |
+
var a = document.createElement('a');
|
| 770 |
+
a.href = temp.toDataURL('image/png');
|
| 771 |
+
a.download = 'MyTechTree.png';
|
| 772 |
+
a.click();
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
function exportJson() {
|
| 776 |
+
var blob = new Blob([JSON.stringify(getSnapshot(), null, 2)], { type: 'application/json' });
|
| 777 |
+
var a = document.createElement('a');
|
| 778 |
+
a.href = URL.createObjectURL(blob);
|
| 779 |
+
a.download = 'tech-tree-backup.json';
|
| 780 |
+
a.click();
|
| 781 |
+
URL.revokeObjectURL(a.href);
|
| 782 |
+
toast('已导出 JSON', false);
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function confirmImportJson() {
|
| 786 |
+
var f = document.getElementById('import-file').files[0];
|
| 787 |
+
if (!f) {
|
| 788 |
+
toast('请选择文件', true);
|
| 789 |
+
return;
|
| 790 |
+
}
|
| 791 |
+
var reader = new FileReader();
|
| 792 |
+
reader.onload = function() {
|
| 793 |
+
try {
|
| 794 |
+
var data = JSON.parse(reader.result);
|
| 795 |
+
if (!data.nodes || !Array.isArray(data.nodes)) {
|
| 796 |
+
toast('JSON 缺少 nodes 数组', true);
|
| 797 |
+
return;
|
| 798 |
+
}
|
| 799 |
+
pushHistory();
|
| 800 |
+
nodesDataset.clear();
|
| 801 |
+
edgesDataset.clear();
|
| 802 |
+
collapsedSubtrees = data.collapsed && typeof data.collapsed === 'object' ? data.collapsed : {};
|
| 803 |
+
data.nodes.forEach(function(raw) { nodesDataset.add(buildVisNode(raw)); });
|
| 804 |
+
(data.edges || []).forEach(function(e) {
|
| 805 |
+
edgesDataset.add({ from: e.from, to: e.to, arrows: e.arrows || 'to' });
|
| 806 |
+
});
|
| 807 |
+
updateStats();
|
| 808 |
+
autoSave();
|
| 809 |
+
closeImportModal();
|
| 810 |
+
toast('导入成功', false);
|
| 811 |
+
} catch (e) {
|
| 812 |
+
toast('解析失败:' + (e.message || String(e)), true);
|
| 813 |
+
}
|
| 814 |
+
};
|
| 815 |
+
reader.readAsText(f);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
function runPhysicsLayout() {
|
| 819 |
+
if (!network) return;
|
| 820 |
+
network.setOptions({
|
| 821 |
+
physics: {
|
| 822 |
+
enabled: true,
|
| 823 |
+
solver: 'forceAtlas2Based',
|
| 824 |
+
forceAtlas2Based: { gravitationalConstant: -80, springLength: 200 }
|
| 825 |
+
}
|
| 826 |
+
});
|
| 827 |
+
setTimeout(function() {
|
| 828 |
+
network.setOptions({ physics: { enabled: false } });
|
| 829 |
+
autoSave();
|
| 830 |
+
}, 2500);
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
function addNewNode() {
|
| 834 |
+
pushHistory();
|
| 835 |
+
var id = 'n' + Date.now();
|
| 836 |
+
var cd = normalizeCustomData({ name: '新技能', mastery: 10 });
|
| 837 |
+
nodesDataset.add({
|
| 838 |
+
id: id,
|
| 839 |
+
shape: 'image',
|
| 840 |
+
image: createNodeSvg(cd.name, cd.mastery),
|
| 841 |
+
customData: cd,
|
| 842 |
+
x: 0,
|
| 843 |
+
y: 0,
|
| 844 |
+
title: nodeTitle(cd)
|
| 845 |
+
});
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
function enableDrawEdge() {
|
| 849 |
+
if (!network) return;
|
| 850 |
+
network.addEdgeMode();
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function clearAll() {
|
| 854 |
+
if (!confirm('确定清空画布与本地存档中的图数据?')) return;
|
| 855 |
+
pushHistory();
|
| 856 |
+
nodesDataset.clear();
|
| 857 |
+
edgesDataset.clear();
|
| 858 |
+
collapsedSubtrees = {};
|
| 859 |
+
localStorage.removeItem(DATA_KEY);
|
| 860 |
+
toast('已清空', false);
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
document.addEventListener('keydown', function(e) {
|
| 864 |
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
| 865 |
+
if (e.key === 'Escape') closeAllModals();
|
| 866 |
+
return;
|
| 867 |
+
}
|
| 868 |
+
if (e.ctrlKey && e.key === 'z') {
|
| 869 |
+
e.preventDefault();
|
| 870 |
+
undo();
|
| 871 |
+
}
|
| 872 |
+
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
| 873 |
+
e.preventDefault();
|
| 874 |
+
redo();
|
| 875 |
+
}
|
| 876 |
+
if (e.key === 'Escape') {
|
| 877 |
+
closeAllModals();
|
| 878 |
+
document.getElementById('context-menu').style.display = 'none';
|
| 879 |
+
}
|
| 880 |
+
});
|
| 881 |
+
|
| 882 |
+
window.onload = function() {
|
| 883 |
+
loadApiConfig();
|
| 884 |
+
nodesDataset = new vis.DataSet();
|
| 885 |
+
edgesDataset = new vis.DataSet();
|
| 886 |
+
initNetwork();
|
| 887 |
+
loadFromLocal();
|
| 888 |
+
|
| 889 |
+
nodesDataset.on('*', function() { updateStats(); autoSave(); });
|
| 890 |
+
edgesDataset.on('*', function() { updateStats(); autoSave(); });
|
| 891 |
+
updateStats();
|
| 892 |
+
};
|
| 893 |
+
</script>
|
| 894 |
+
</body>
|
| 895 |
+
</html>
|
main.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI, HTTPException
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 9 |
+
|
| 10 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 11 |
+
|
| 12 |
+
app = FastAPI(title="AI 科技树引擎 API V6.0")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=["*"],
|
| 18 |
+
allow_credentials=False,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TreeNode(BaseModel):
|
| 25 |
+
id: str = Field(..., min_length=1, max_length=128)
|
| 26 |
+
name: str = Field(..., min_length=1, max_length=200)
|
| 27 |
+
mastery: int = Field(..., ge=0, le=100)
|
| 28 |
+
|
| 29 |
+
@field_validator("id", "name", mode="before")
|
| 30 |
+
@classmethod
|
| 31 |
+
def strip_str(cls, v):
|
| 32 |
+
if isinstance(v, str):
|
| 33 |
+
return v.strip()
|
| 34 |
+
return v
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TreeEdge(BaseModel):
|
| 38 |
+
source: str = Field(..., min_length=1, max_length=128)
|
| 39 |
+
target: str = Field(..., min_length=1, max_length=128)
|
| 40 |
+
|
| 41 |
+
@field_validator("source", "target", mode="before")
|
| 42 |
+
@classmethod
|
| 43 |
+
def strip_str(cls, v):
|
| 44 |
+
if isinstance(v, str):
|
| 45 |
+
return v.strip()
|
| 46 |
+
return v
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TreePayload(BaseModel):
|
| 50 |
+
nodes: list[TreeNode] = Field(..., min_length=1)
|
| 51 |
+
edges: list[TreeEdge] = Field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class LlmConfigBody(BaseModel):
|
| 55 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 56 |
+
|
| 57 |
+
api_key: str = Field(..., min_length=1, max_length=2048)
|
| 58 |
+
base_url: str = Field(..., min_length=1, max_length=512)
|
| 59 |
+
llm_model: str = Field(..., min_length=1, max_length=128, alias="model")
|
| 60 |
+
|
| 61 |
+
@field_validator("api_key", "base_url", "llm_model", mode="before")
|
| 62 |
+
@classmethod
|
| 63 |
+
def strip_fields(cls, v):
|
| 64 |
+
if isinstance(v, str):
|
| 65 |
+
return v.strip()
|
| 66 |
+
return v
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class GenerateTreeRequest(LlmConfigBody):
|
| 70 |
+
text: str = Field(..., min_length=1, max_length=12000)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class ExpandNodeRequest(LlmConfigBody):
|
| 74 |
+
node_id: str = Field(..., min_length=1, max_length=128)
|
| 75 |
+
node_name: str = Field(..., min_length=1, max_length=200)
|
| 76 |
+
|
| 77 |
+
@field_validator("node_id", "node_name", mode="before")
|
| 78 |
+
@classmethod
|
| 79 |
+
def strip_fields(cls, v):
|
| 80 |
+
if isinstance(v, str):
|
| 81 |
+
return v.strip()
|
| 82 |
+
return v
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def clean_json_string(raw: str) -> str:
|
| 86 |
+
raw = raw.strip()
|
| 87 |
+
if raw.startswith("```"):
|
| 88 |
+
raw = raw.split("\n", 1)[-1]
|
| 89 |
+
if raw.rstrip().endswith("```"):
|
| 90 |
+
raw = raw.rstrip().rsplit("\n", 1)[0]
|
| 91 |
+
return raw.strip()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def make_client(api_key: str, base_url: str) -> OpenAI:
|
| 95 |
+
url = base_url.rstrip("/")
|
| 96 |
+
return OpenAI(api_key=api_key, base_url=url)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def parse_and_validate_tree(content: str) -> dict:
|
| 100 |
+
try:
|
| 101 |
+
raw = json.loads(clean_json_string(content))
|
| 102 |
+
except json.JSONDecodeError as e:
|
| 103 |
+
raise HTTPException(
|
| 104 |
+
status_code=502,
|
| 105 |
+
detail={"message": "模型返回不是合法 JSON", "error": str(e)},
|
| 106 |
+
) from e
|
| 107 |
+
try:
|
| 108 |
+
payload = TreePayload.model_validate(raw)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=502,
|
| 112 |
+
detail={"message": "JSON 结构不符合技能树约定", "error": str(e)},
|
| 113 |
+
) from e
|
| 114 |
+
return payload.model_dump()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.get("/health")
|
| 118 |
+
async def health():
|
| 119 |
+
return {"status": "ok"}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.get("/")
|
| 123 |
+
async def serve_index():
|
| 124 |
+
index = BASE_DIR / "index.html"
|
| 125 |
+
if not index.is_file():
|
| 126 |
+
raise HTTPException(status_code=404, detail="index.html 不存在")
|
| 127 |
+
return FileResponse(index)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@app.post("/generate_tree")
|
| 131 |
+
async def generate_tree(req: GenerateTreeRequest):
|
| 132 |
+
sys_prompt = """你是一个专业的技能树构建AI。你的任务是分析用户的学习经历和目标,提取出相关的技能节点,并构建它们之间的前置/后续关系(有向无环图)。
|
| 133 |
+
你必须且只能返回纯 JSON 格式的数据,不要包含任何 Markdown 标记(如 ```json)、解释说明或多余的废话。
|
| 134 |
+
|
| 135 |
+
JSON 结构必须严格如下:
|
| 136 |
+
{
|
| 137 |
+
"nodes": [
|
| 138 |
+
{"id": "唯一的英文ID", "name": "技能中文名", "mastery": 掌握度(0-100的整数)}
|
| 139 |
+
],
|
| 140 |
+
"edges": [
|
| 141 |
+
{"source": "前置技能的ID", "target": "后续技能的ID"}
|
| 142 |
+
]
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
规则:
|
| 146 |
+
1. mastery (掌握度):根据用户描述推断。如果已经学完/熟练掌握,给 80-100;正在学给 40-70;仅仅是未来目标给 0-20。
|
| 147 |
+
2. edges:必须符合逻辑。比如“微积分”通常是“信号与系统”的 source。
|
| 148 |
+
"""
|
| 149 |
+
client = make_client(req.api_key, req.base_url)
|
| 150 |
+
try:
|
| 151 |
+
response = client.chat.completions.create(
|
| 152 |
+
model=req.llm_model,
|
| 153 |
+
messages=[
|
| 154 |
+
{"role": "system", "content": sys_prompt},
|
| 155 |
+
{"role": "user", "content": req.text},
|
| 156 |
+
],
|
| 157 |
+
temperature=0.1,
|
| 158 |
+
)
|
| 159 |
+
content = response.choices[0].message.content or ""
|
| 160 |
+
data = parse_and_validate_tree(content)
|
| 161 |
+
return JSONResponse(status_code=200, content={"status": "success", "data": data})
|
| 162 |
+
except HTTPException:
|
| 163 |
+
raise
|
| 164 |
+
except Exception as e:
|
| 165 |
+
return JSONResponse(
|
| 166 |
+
status_code=502,
|
| 167 |
+
content={"status": "error", "message": str(e)},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.post("/expand_node")
|
| 172 |
+
async def expand_node(req: ExpandNodeRequest):
|
| 173 |
+
nname = json.dumps(req.node_name, ensure_ascii=False)
|
| 174 |
+
nid_lit = json.dumps(req.node_id, ensure_ascii=False)
|
| 175 |
+
sys_prompt = f"""用户目前正在学习或已经掌握了技能:{nname}。
|
| 176 |
+
当前节点 id 为 {nid_lit}(JSON 字符串形式,edges 里 source 字段必须等于去掉引号后的该 id,与现有图一致)。
|
| 177 |
+
请推断 2 到 3 个逻辑上最紧密的进阶技能或衍生方向。
|
| 178 |
+
返回严格的 JSON 格式。新节点的 mastery 默认设为 10(刚起步)。
|
| 179 |
+
必须包含 edges:每条边的 source 必须等于上述节点 id,target 为新节点 id。
|
| 180 |
+
格式如下:
|
| 181 |
+
{{
|
| 182 |
+
"nodes": [
|
| 183 |
+
{{"id": "唯一的英文ID", "name": "新技能中文名", "mastery": 10}}
|
| 184 |
+
],
|
| 185 |
+
"edges": [
|
| 186 |
+
{{"source": "与当前节点 id 完全一致", "target": "唯一的英文ID"}}
|
| 187 |
+
]
|
| 188 |
+
}}
|
| 189 |
+
"""
|
| 190 |
+
client = make_client(req.api_key, req.base_url)
|
| 191 |
+
try:
|
| 192 |
+
response = client.chat.completions.create(
|
| 193 |
+
model=req.llm_model,
|
| 194 |
+
messages=[{"role": "user", "content": sys_prompt}],
|
| 195 |
+
temperature=0.6,
|
| 196 |
+
)
|
| 197 |
+
content = response.choices[0].message.content or ""
|
| 198 |
+
data = parse_and_validate_tree(content)
|
| 199 |
+
for e in data["edges"]:
|
| 200 |
+
if e["source"] != req.node_id:
|
| 201 |
+
e["source"] = req.node_id
|
| 202 |
+
return JSONResponse(status_code=200, content={"status": "success", "data": data})
|
| 203 |
+
except HTTPException:
|
| 204 |
+
raise
|
| 205 |
+
except Exception as e:
|
| 206 |
+
return JSONResponse(
|
| 207 |
+
status_code=502,
|
| 208 |
+
content={"status": "error", "message": str(e)},
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
if __name__ == "__main__":
|
| 213 |
+
import uvicorn
|
| 214 |
+
|
| 215 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.100.0
|
| 2 |
+
uvicorn[standard]>=0.22.0
|
| 3 |
+
openai>=1.12.0
|
| 4 |
+
pydantic>=2.4.0
|