OctopusFlying commited on
Commit
2963e60
·
verified ·
1 Parent(s): db5fa49

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +20 -0
  2. index.html +895 -0
  3. main.py +215 -0
  4. 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>已点亮 (&gt;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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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