app.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. // 全局变量
  2. let allKnowledge = [];
  3. let availableTags = [];
  4. let currentPage = 1;
  5. let pageSize = 200;
  6. let totalPages = 1;
  7. let totalCount = 0;
  8. let isSearchMode = false;
  9. let selectedIds = new Set();
  10. let _allTools = [];
  11. let _activeCategory = 'all';
  12. // 加载 Tags
  13. async function loadTags() {
  14. const res = await fetch('/api/knowledge/meta/tags');
  15. const data = await res.json();
  16. availableTags = data.tags;
  17. renderTagsFilter();
  18. }
  19. function renderTagsFilter() {
  20. const container = document.getElementById('tagsFilterContainer');
  21. if (availableTags.length === 0) {
  22. container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
  23. return;
  24. }
  25. container.innerHTML = availableTags.map(tag =>
  26. `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
  27. ).join('');
  28. }
  29. // 加载知识列表
  30. async function loadKnowledge(page = 1) {
  31. const params = new URLSearchParams();
  32. params.append('page', page);
  33. params.append('page_size', pageSize);
  34. const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
  35. if (selectedTypes.length > 0) {
  36. params.append('types', selectedTypes.join(','));
  37. }
  38. const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
  39. if (selectedTags.length > 0) {
  40. params.append('tags', selectedTags.join(','));
  41. }
  42. const ownerFilter = document.getElementById('ownerFilter').value.trim();
  43. if (ownerFilter) {
  44. params.append('owner', ownerFilter);
  45. }
  46. const scopesFilter = document.getElementById('scopesFilter').value.trim();
  47. if (scopesFilter) {
  48. params.append('scopes', scopesFilter);
  49. }
  50. const selectedStatus = Array.from(document.querySelectorAll('.status-filter:checked')).map(el => el.value);
  51. if (selectedStatus.length > 0) {
  52. params.append('status', selectedStatus.join(','));
  53. }
  54. try {
  55. const res = await fetch(`/api/knowledge?${params.toString()}`);
  56. if (!res.ok) {
  57. console.error('加载失败:', res.status, res.statusText);
  58. document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载失败,请刷新页面重试</p>';
  59. return;
  60. }
  61. const data = await res.json();
  62. allKnowledge = data.results || [];
  63. currentPage = data.pagination.page;
  64. totalPages = data.pagination.total_pages;
  65. totalCount = data.pagination.total;
  66. renderKnowledge(allKnowledge);
  67. updatePagination();
  68. } catch (error) {
  69. console.error('加载错误:', error);
  70. document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载错误: ' + error.message + '</p>';
  71. }
  72. }
  73. function applyFilters() {
  74. currentPage = 1;
  75. loadKnowledge(currentPage);
  76. }
  77. function goToPage(page) {
  78. if (page < 1 || page > totalPages) return;
  79. loadKnowledge(page);
  80. }
  81. function updatePagination() {
  82. const paginationDiv = document.getElementById('pagination');
  83. const pageInfo = document.getElementById('pageInfo');
  84. const prevBtn = document.getElementById('prevBtn');
  85. const nextBtn = document.getElementById('nextBtn');
  86. if (totalPages <= 1) {
  87. paginationDiv.classList.add('hidden');
  88. } else {
  89. paginationDiv.classList.remove('hidden');
  90. pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页 (共 ${totalCount} 条)`;
  91. prevBtn.disabled = currentPage === 1;
  92. nextBtn.disabled = currentPage === totalPages;
  93. }
  94. }
  95. // 搜索功能
  96. async function performSearch() {
  97. const query = document.getElementById('searchInput').value.trim();
  98. if (!query) {
  99. alert('请输入搜索内容');
  100. return;
  101. }
  102. isSearchMode = true;
  103. const statusDiv = document.getElementById('searchStatus');
  104. statusDiv.textContent = '搜索中...';
  105. statusDiv.classList.remove('hidden');
  106. try {
  107. const params = new URLSearchParams();
  108. params.append('q', query);
  109. params.append('top_k', '20');
  110. params.append('min_score', '1');
  111. const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
  112. if (selectedTypes.length > 0) {
  113. params.append('types', selectedTypes.join(','));
  114. }
  115. const ownerFilter = document.getElementById('ownerFilter').value.trim();
  116. if (ownerFilter) {
  117. params.append('owner', ownerFilter);
  118. }
  119. const res = await fetch(`/api/knowledge/search?${params.toString()}`);
  120. if (!res.ok) {
  121. throw new Error(`搜索失败: ${res.status}`);
  122. }
  123. const data = await res.json();
  124. allKnowledge = data.results || [];
  125. statusDiv.textContent = `找到 ${allKnowledge.length} 条相关知识${data.reranked ? ' (已智能排序)' : ''}`;
  126. renderKnowledge(allKnowledge);
  127. document.getElementById('pagination').classList.add('hidden');
  128. } catch (error) {
  129. console.error('搜索错误:', error);
  130. statusDiv.textContent = '搜索失败: ' + error.message;
  131. statusDiv.classList.add('text-red-500');
  132. }
  133. }
  134. function clearSearch() {
  135. document.getElementById('searchInput').value = '';
  136. document.getElementById('searchStatus').classList.add('hidden');
  137. document.getElementById('searchStatus').classList.remove('text-red-500');
  138. isSearchMode = false;
  139. currentPage = 1;
  140. loadKnowledge(currentPage);
  141. }
  142. // 渲染知识列表
  143. function renderKnowledge(list) {
  144. const container = document.getElementById('knowledgeList');
  145. if (list.length === 0) {
  146. container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
  147. return;
  148. }
  149. container.innerHTML = list.map(k => {
  150. let types = [];
  151. if (Array.isArray(k.types)) {
  152. types = k.types;
  153. } else if (typeof k.types === 'string') {
  154. if (k.types.startsWith('[')) {
  155. try {
  156. types = JSON.parse(k.types);
  157. } catch (e) {
  158. console.error('解析types失败:', k.types, e);
  159. types = [k.types];
  160. }
  161. } else {
  162. types = [k.types];
  163. }
  164. }
  165. const eval_data = k.eval || {};
  166. const isChecked = selectedIds.has(k.id);
  167. const statusColor = {
  168. 'approved': 'bg-green-100 text-green-800',
  169. 'checked': 'bg-blue-100 text-blue-800',
  170. 'rejected': 'bg-red-100 text-red-800',
  171. 'pending': 'bg-yellow-100 text-yellow-800',
  172. 'processing': 'bg-orange-100 text-orange-800',
  173. };
  174. const statusClass = statusColor[k.status] || 'bg-gray-100 text-gray-800';
  175. const statusLabel = k.status || 'approved';
  176. const toolIds = (k.resource_ids || []).filter(id => id.startsWith('tools/'));
  177. const toolTagsHtml = toolIds.length > 0
  178. ? `<div class="flex gap-1 flex-wrap mt-2">
  179. ${toolIds.map(tid => {
  180. const name = tid.split('/').pop();
  181. return `<span onclick="event.stopPropagation(); openToolTableModal('${tid}')"
  182. class="text-[11px] px-2 py-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full cursor-pointer hover:bg-indigo-100 transition">
  183. 🔧 ${name}
  184. </span>`;
  185. }).join('')}
  186. </div>`
  187. : '';
  188. return `
  189. <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition relative">
  190. <div class="absolute top-4 left-4">
  191. <input type="checkbox" class="knowledge-checkbox w-5 h-5 cursor-pointer"
  192. data-id="${k.id}" ${isChecked ? 'checked' : ''}
  193. onclick="event.stopPropagation(); toggleSelect('${k.id}')">
  194. </div>
  195. <div class="ml-10 cursor-pointer" onclick="openEditModal('${k.id}')">
  196. <div class="flex justify-between items-start mb-2">
  197. <div class="flex gap-2 flex-wrap">
  198. ${types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
  199. </div>
  200. <div class="flex items-center gap-2">
  201. <span class="text-xs px-2 py-1 rounded ${statusClass}">${statusLabel}</span>
  202. <span class="text-sm text-gray-500">${eval_data.score || 3}/5</span>
  203. </div>
  204. </div>
  205. <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
  206. <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
  207. <div class="flex justify-between text-xs text-gray-500">
  208. <span>Owner: ${k.owner || 'N/A'}</span>
  209. <span>${new Date(k.created_at).toLocaleDateString()}</span>
  210. </div>
  211. ${toolTagsHtml}
  212. </div>
  213. <div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
  214. <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'approve', this)"
  215. class="${k.status === 'checked' ? 'bg-gray-300 hover:bg-gray-400 text-gray-700' : 'bg-green-400 hover:bg-green-500 text-white'} text-xs px-3 py-1 rounded transition-colors">
  216. ${k.status === 'checked' ? '取消验证' : '✓ 验证通过'}
  217. </button>
  218. <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'reject', this)"
  219. class="bg-red-400 hover:bg-red-500 text-white text-xs px-3 py-1 rounded transition-colors">
  220. ✗ 拒绝
  221. </button>
  222. </div>
  223. </div>
  224. `;
  225. }).join('');
  226. }
  227. // 选择相关函数
  228. function toggleSelect(id) {
  229. if (selectedIds.has(id)) {
  230. selectedIds.delete(id);
  231. } else {
  232. selectedIds.add(id);
  233. }
  234. updateBatchDeleteButton();
  235. }
  236. function toggleSelectAll() {
  237. if (selectedIds.size === allKnowledge.length) {
  238. selectedIds.clear();
  239. } else {
  240. selectedIds.clear();
  241. allKnowledge.forEach(k => selectedIds.add(k.id));
  242. }
  243. renderKnowledge(allKnowledge);
  244. updateBatchDeleteButton();
  245. }
  246. function updateBatchDeleteButton() {
  247. const count = selectedIds.size;
  248. document.getElementById('selectedCount').textContent = count;
  249. document.getElementById('verifyCount').textContent = count;
  250. document.getElementById('batchDeleteBtn').disabled = count === 0;
  251. document.getElementById('batchVerifyBtn').disabled = count === 0;
  252. document.getElementById('selectAllBtn').textContent =
  253. selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
  254. }
  255. // 批量删除
  256. async function batchDelete() {
  257. if (selectedIds.size === 0) return;
  258. if (!confirm(`确定要删除选中的 ${selectedIds.size} 条知识吗?此操作不可恢复!`)) return;
  259. try {
  260. const ids = Array.from(selectedIds);
  261. const res = await fetch('/api/knowledge/batch_delete', {
  262. method: 'POST',
  263. headers: {'Content-Type': 'application/json'},
  264. body: JSON.stringify(ids)
  265. });
  266. if (!res.ok) throw new Error(`删除失败: ${res.status}`);
  267. const data = await res.json();
  268. alert(`成功删除 ${data.deleted_count} 条知识`);
  269. selectedIds.clear();
  270. updateBatchDeleteButton();
  271. if (isSearchMode) {
  272. clearSearch();
  273. } else {
  274. loadKnowledge(currentPage);
  275. }
  276. } catch (error) {
  277. console.error('批量删除错误:', error);
  278. alert('删除失败: ' + error.message);
  279. }
  280. }
  281. // 批量验证
  282. async function batchVerify() {
  283. if (selectedIds.size === 0) return;
  284. if (!confirm(`确定要批量验证通过选中的 ${selectedIds.size} 条知识吗?`)) return;
  285. const btn = document.getElementById('batchVerifyBtn');
  286. if (btn) { btn.disabled = true; btn.textContent = `处理中...`; }
  287. try {
  288. const ids = Array.from(selectedIds);
  289. const res = await fetch('/api/knowledge/batch_verify', {
  290. method: 'POST',
  291. headers: {'Content-Type': 'application/json'},
  292. body: JSON.stringify({ knowledge_ids: ids, action: 'approve', verified_by: 'user' })
  293. });
  294. if (!res.ok) throw new Error('请求失败: ' + res.status);
  295. selectedIds.clear();
  296. updateBatchDeleteButton();
  297. if (isSearchMode) {
  298. clearSearch();
  299. } else {
  300. loadKnowledge(currentPage);
  301. }
  302. } catch (error) {
  303. console.error('批量验证错误:', error);
  304. alert('验证失败: ' + error.message);
  305. if (btn) { btn.disabled = false; updateBatchDeleteButton(); }
  306. }
  307. }
  308. // 验证单个知识
  309. async function verifyKnowledge(id, action, btn) {
  310. if (btn) {
  311. btn.disabled = true;
  312. btn._origText = btn.textContent;
  313. btn.textContent = '处理中...';
  314. }
  315. try {
  316. const res = await fetch('/api/knowledge/' + id + '/verify', {
  317. method: 'POST',
  318. headers: {'Content-Type': 'application/json'},
  319. body: JSON.stringify({ action })
  320. });
  321. if (!res.ok) throw new Error('请求失败: ' + res.status);
  322. if (isSearchMode) {
  323. clearSearch();
  324. } else {
  325. loadKnowledge(currentPage);
  326. }
  327. } catch (error) {
  328. console.error('验证错误:', error);
  329. alert('操作失败: ' + error.message);
  330. if (btn) {
  331. btn.disabled = false;
  332. btn.textContent = btn._origText;
  333. }
  334. }
  335. }
  336. // 模态框操作
  337. function openAddModal() {
  338. document.getElementById('modalTitle').textContent = '新增知识';
  339. document.getElementById('knowledgeForm').reset();
  340. document.getElementById('editId').value = '';
  341. document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
  342. document.getElementById('modal').classList.remove('hidden');
  343. }
  344. async function openEditModal(id) {
  345. let k = allKnowledge.find(item => item.id === id);
  346. if (!k) {
  347. try {
  348. const res = await fetch('/api/knowledge/' + encodeURIComponent(id));
  349. if (!res.ok) { alert('知识未找到: ' + id); return; }
  350. k = await res.json();
  351. } catch (e) { alert('获取知识失败: ' + e.message); return; }
  352. }
  353. document.getElementById('modalTitle').textContent = '编辑知识';
  354. document.getElementById('editId').value = k.id;
  355. document.getElementById('taskInput').value = k.task || '';
  356. document.getElementById('contentInput').value = k.content || '';
  357. document.getElementById('tagsInput').value = JSON.stringify(k.tags || {});
  358. const scopes = Array.isArray(k.scopes) ? k.scopes : [];
  359. document.getElementById('scopesInput').value = scopes.join(', ');
  360. document.getElementById('ownerInput').value = k.owner || '';
  361. const types = Array.isArray(k.types) ? k.types : [];
  362. document.querySelectorAll('.type-checkbox').forEach(el => {
  363. el.checked = types.includes(el.value);
  364. });
  365. let rels = [];
  366. if (Array.isArray(k.relationships)) {
  367. rels = k.relationships;
  368. } else if (typeof k.relationships === 'string' && k.relationships.startsWith('[')) {
  369. try { rels = JSON.parse(k.relationships); } catch(e) {}
  370. }
  371. const section = document.getElementById('relationshipsSection');
  372. if (rels.length > 0) {
  373. const typeColor = {
  374. superset: 'text-green-700', subset: 'text-orange-600',
  375. conflict: 'text-red-600', complement: 'text-blue-600',
  376. duplicate: 'text-gray-500'
  377. };
  378. document.getElementById('relationshipsList').innerHTML = rels.map(r =>
  379. `<div class="flex gap-2 items-center">
  380. <span class="font-medium ${typeColor[r.type] || 'text-gray-700'}">[${r.type}]</span>
  381. <span class="font-mono text-xs text-gray-500 cursor-pointer hover:underline"
  382. onclick="openEditModal('${r.target}')">${r.target}</span>
  383. </div>`
  384. ).join('');
  385. section.classList.remove('hidden');
  386. } else {
  387. section.classList.add('hidden');
  388. }
  389. document.getElementById('modal').classList.remove('hidden');
  390. }
  391. function closeModal() {
  392. document.getElementById('modal').classList.add('hidden');
  393. }
  394. // 表单提交处理
  395. document.addEventListener('DOMContentLoaded', function() {
  396. document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
  397. e.preventDefault();
  398. const editId = document.getElementById('editId').value;
  399. const task = document.getElementById('taskInput').value;
  400. const content = document.getElementById('contentInput').value;
  401. const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
  402. const tagsText = document.getElementById('tagsInput').value.trim();
  403. const scopesText = document.getElementById('scopesInput').value.trim();
  404. const owner = document.getElementById('ownerInput').value.trim();
  405. let tags = ;
  406. if (tagsText) {
  407. try {
  408. tags = JSON.parse(tagsText);
  409. } catch (e) {
  410. alert('Tags JSON 格式错误');
  411. return;
  412. }
  413. }
  414. const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
  415. if (editId) {
  416. const res = await fetch(`/api/knowledge/${editId}`, {
  417. method: 'PATCH',
  418. headers: {'Content-Type': 'application/json'},
  419. body: JSON.stringify({task, content, types, tags, scopes, owner})
  420. });
  421. if (!res.ok) {
  422. alert('更新失败');
  423. return;
  424. }
  425. } else {
  426. const res = await fetch('/api/knowledge', {
  427. method: 'POST',
  428. headers: {'Content-Type': 'application/json'},
  429. body: JSON.stringify({task, content, types, tags, scopes, owner})
  430. });
  431. if (!res.ok) {
  432. alert('新增失败');
  433. return;
  434. }
  435. }
  436. closeModal();
  437. await loadKnowledge();
  438. });
  439. // 初始化加载
  440. loadTags();
  441. loadKnowledge();
  442. });
  443. // 工具表相关函数
  444. async function openToolTableModal(targetToolId = null) {
  445. document.getElementById('toolTableModal').classList.remove('hidden');
  446. if (_allTools.length === 0) {
  447. await loadToolList();
  448. } else {
  449. renderCategoryTabs();
  450. renderToolList('all');
  451. }
  452. if (targetToolId) {
  453. const targetTool = _allTools.find(t => t.id === targetToolId);
  454. if (targetTool) {
  455. const cat = targetTool.metadata && targetTool.metadata.category ? targetTool.metadata.category : 'other';
  456. renderToolList(cat);
  457. }
  458. loadToolDetail(targetToolId);
  459. setTimeout(() => {
  460. const el = document.querySelector(`.tool-item[data-id="${targetToolId}"]`);
  461. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  462. }, 100);
  463. }
  464. }
  465. function closeToolTableModal() {
  466. document.getElementById('toolTableModal').classList.add('hidden');
  467. }
  468. async function loadToolList() {
  469. try {
  470. const res = await fetch('/api/resource?limit=1000');
  471. const data = await res.json();
  472. _allTools = (data.results || []).filter(r => r.id.startsWith('tools/'));
  473. renderCategoryTabs();
  474. renderToolList('all');
  475. } catch (err) {
  476. console.error('加载工具列表失败', err);
  477. document.getElementById('toolList').innerHTML = '<p class="text-red-500 text-sm text-center">加载失败</p>';
  478. }
  479. }
  480. function renderCategoryTabs() {
  481. const cats = ['all', ...new Set(_allTools.map(t => t.metadata && t.metadata.category ? t.metadata.category : 'other'))];
  482. document.getElementById('toolCategoryTabs').innerHTML = cats.map(cat => {
  483. const isActive = cat === _activeCategory;
  484. const activeClass = 'bg-indigo-600 text-white border-indigo-600';
  485. const inactiveClass = 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400';
  486. return `<button onclick="renderToolList('${cat}')"
  487. id="tab_${cat}"
  488. class="tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${isActive ? activeClass : inactiveClass}">
  489. ${cat === 'all' ? '全部' : cat}
  490. </button>`;
  491. }).join('');
  492. }
  493. function renderToolList(category) {
  494. _activeCategory = category;
  495. document.querySelectorAll('.tool-cat-tab').forEach(btn => {
  496. const isCurrent = btn.id === `tab_${category}`;
  497. btn.className = `tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${
  498. isCurrent ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
  499. }`;
  500. });
  501. const filtered = category === 'all'
  502. ? _allTools
  503. : _allTools.filter(t => (t.metadata && t.metadata.category ? t.metadata.category : 'other') === category);
  504. const listHtml = filtered.length === 0
  505. ? '<p class="text-sm text-gray-400 text-center mt-4">该分类下暂无工具</p>'
  506. : filtered.map(t => `
  507. <div onclick="loadToolDetail('${t.id}')"
  508. class="tool-item p-3 rounded-lg border border-gray-200 cursor-pointer hover:border-indigo-400 hover:shadow-sm bg-white transition"
  509. data-id="${t.id}">
  510. <div class="font-bold text-gray-800 text-sm truncate" title="${escapeHtml(t.title || t.id)}">${escapeHtml(t.title || t.id.split('/').pop())}</div>
  511. <div class="mt-1 flex items-center justify-between">
  512. <span class="text-xs px-2 py-0.5 rounded truncate max-w-[100px] ${(t.metadata && t.metadata.status === '已接入') ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">${escapeHtml(t.metadata && t.metadata.status ? t.metadata.status : '未设置')}</span>
  513. <span class="text-[10px] text-gray-400">${escapeHtml(t.content_type || '')}</span>
  514. </div>
  515. </div>`).join('');
  516. document.getElementById('toolList').innerHTML = listHtml;
  517. }
  518. async function loadToolDetail(id) {
  519. document.querySelectorAll('.tool-item').forEach(el => {
  520. if (el.dataset.id === id) {
  521. el.classList.add('border-indigo-500', 'ring-1', 'ring-indigo-500');
  522. el.classList.remove('border-gray-200');
  523. } else {
  524. el.classList.remove('border-indigo-500', 'ring-1', 'ring-indigo-500');
  525. el.classList.add('border-gray-200');
  526. }
  527. });
  528. const detailEl = document.getElementById('toolDetail');
  529. detailEl.innerHTML = '<div class="flex h-full items-center justify-center"><p class="text-gray-400 animate-pulse">加载详情中...</p></div>';
  530. try {
  531. const res = await fetch('/api/resource/' + id);
  532. const tool = await res.json();
  533. const knowledgeIds = (tool.metadata && tool.metadata.knowledge_ids) ? tool.metadata.knowledge_ids : [];
  534. const knowledgeHtml = knowledgeIds.length === 0
  535. ? '<span class="text-gray-400 text-xs">暂无</span>'
  536. : knowledgeIds.map(kid => `
  537. <span onclick="openKnowledgeDetailModal('${kid}')"
  538. class="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 border rounded cursor-pointer text-gray-700 font-mono transition">
  539. ${kid.length > 24 ? kid.slice(0, 24) + '...' : kid}
  540. </span>`).join('');
  541. const toolhubItems = (tool.metadata && tool.metadata.toolhub_items) ? tool.metadata.toolhub_items : [];
  542. const toolhubHtml = toolhubItems.length === 0
  543. ? '<span class="text-gray-400 text-xs">暂无</span>'
  544. : toolhubItems.map(item => {
  545. const [id, desc] = Object.entries(item)[0];
  546. return `<span class="text-xs px-2 py-1 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded text-blue-700 font-mono transition">
  547. ${escapeHtml(id)}: ${escapeHtml(desc)}
  548. </span>`;
  549. }).join('');
  550. const meta = tool.metadata || {};
  551. const scenariosMd = Array.isArray(meta.scenarios) && meta.scenarios.length > 0
  552. ? meta.scenarios.map(s => `<li>${escapeHtml(s)}</li>`).join('')
  553. : '<li class="text-gray-400">暂无</li>';
  554. detailEl.innerHTML = `
  555. <div class="mb-6 border-b pb-4">
  556. <h2 class="text-3xl font-black text-gray-900 mb-3">${escapeHtml(tool.title || id)}</h2>
  557. <div class="flex gap-2 flex-wrap text-sm mb-3">
  558. <span class="px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-md border border-indigo-100">
  559. 📁 分类: ${escapeHtml(meta.category || '–')}
  560. </span>
  561. <span class="px-2.5 py-1 rounded-md border ${(meta.status === '已接入') ? 'bg-green-50 text-green-700 border-green-200' : 'bg-gray-50 text-gray-700 border-gray-200'}">
  562. 🏷️ 状态: ${escapeHtml(meta.status || '–')}
  563. </span>
  564. <span class="px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md border border-blue-100">
  565. 📌 Slug: ${escapeHtml(meta.tool_slug || '–')}
  566. </span>
  567. </div>
  568. <div class="flex gap-1 flex-wrap items-center">
  569. <span class="text-xs text-gray-500 mr-1">🔗 关联知识:</span>
  570. ${knowledgeHtml}
  571. </div>
  572. <div class="flex gap-1 flex-wrap items-center mt-2">
  573. <span class="text-xs text-gray-500 mr-1">🔧 工具项:</span>
  574. ${toolhubHtml}
  575. </div>
  576. </div>
  577. <div class="text-gray-800 leading-relaxed max-w-none space-y-6">
  578. <div>
  579. <h3 class="text-lg font-bold border-b pb-2 mb-3">基础概览</h3>
  580. <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
  581. <div><span class="text-gray-500 font-semibold">工具版本:</span> ${escapeHtml(meta.version || '–')}</div>
  582. </div>
  583. <div class="mt-3">
  584. <span class="text-gray-500 font-semibold text-sm block mb-1">功能介绍:</span>
  585. <div class="bg-gray-50 p-3 rounded-md text-sm border">${escapeHtml(meta.description || '暂无')}</div>
  586. </div>
  587. </div>
  588. <div>
  589. <h3 class="text-lg font-bold border-b pb-2 mb-3">使用指南</h3>
  590. <div class="mb-4">
  591. <span class="text-gray-500 font-semibold text-sm block mb-1">用法:</span>
  592. <div class="bg-gray-50 p-3 rounded-md text-sm border whitespace-pre-wrap">${escapeHtml(meta.usage || '暂无')}</div>
  593. </div>
  594. <div>
  595. <span class="text-gray-500 font-semibold text-sm block mb-1">应用场景:</span>
  596. <ul class="list-disc pl-5 space-y-1 text-sm">${scenariosMd}</ul>
  597. </div>
  598. </div>
  599. <div>
  600. <h3 class="text-lg font-bold border-b pb-2 mb-3">技术规格</h3>
  601. <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  602. <div>
  603. <span class="text-gray-500 font-semibold text-sm block mb-1">输入:</span>
  604. <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.input || '暂无')}</div>
  605. </div>
  606. <div>
  607. <span class="text-gray-500 font-semibold text-sm block mb-1">输出:</span>
  608. <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.output || '暂无')}</div>
  609. </div>
  610. </div>
  611. </div>
  612. ${meta.source ? `
  613. <div>
  614. <h3 class="text-lg font-bold border-b pb-2 mb-3">消息信源</h3>
  615. <div class="text-sm overflow-hidden break-words text-blue-600 hover:underline">
  616. ${escapeHtml(meta.source)}
  617. </div>
  618. </div>` : ''}
  619. ${tool.body ? `
  620. <div class="pt-4 mt-6 border-t border-dashed">
  621. <h3 class="text-lg font-bold mb-3 text-gray-500">补充说明 (文档内容)</h3>
  622. <div class="markdown-body bg-gray-50 p-4 rounded-lg border text-sm">
  623. ${typeof marked !== 'undefined' ? marked.parse(tool.body) : escapeHtml(tool.body)}
  624. </div>
  625. </div>` : ''}
  626. </div>`;
  627. } catch (err) {
  628. detailEl.innerHTML = '<div class="text-red-500 flex h-full items-center justify-center">加载详情失败,请检查网络或日志</div>';
  629. console.error(err);
  630. }
  631. }
  632. async function openKnowledgeDetailModal(id) {
  633. document.getElementById('knowledgeDetailModal').classList.remove('hidden');
  634. const contentEl = document.getElementById('knowledgeDetailContent');
  635. contentEl.innerHTML = '<p class="text-gray-400 text-center animate-pulse">加载中...</p>';
  636. try {
  637. const res = await fetch(`/api/knowledge/${encodeURIComponent(id)}`);
  638. if (!res.ok) { contentEl.innerHTML = '<p class="text-red-500 text-center">知识未找到</p>'; return; }
  639. const k = await res.json();
  640. const statusColor = {
  641. 'approved': 'bg-green-100 text-green-800',
  642. 'checked': 'bg-blue-100 text-blue-800',
  643. 'rejected': 'bg-red-100 text-red-800',
  644. 'pending': 'bg-yellow-100 text-yellow-800',
  645. };
  646. const types = Array.isArray(k.types) ? k.types : [];
  647. const tags = k.tags || {};
  648. const tagKeys = Object.keys(tags);
  649. contentEl.innerHTML = `
  650. <div class="flex gap-2 flex-wrap mb-4">
  651. ${types.map(t => `<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">${escapeHtml(t)}</span>`).join('')}
  652. <span class="px-2 py-0.5 rounded text-xs ${statusColor[k.status] || 'bg-gray-100 text-gray-700'}">${escapeHtml(k.status || '–')}</span>
  653. </div>
  654. <h3 class="text-lg font-bold text-gray-900 mb-3">${escapeHtml(k.task || '')}</h3>
  655. <div class="text-sm text-gray-700 bg-gray-50 rounded-lg p-4 mb-4 whitespace-pre-wrap leading-relaxed">
  656. ${escapeHtml(k.content || '')}
  657. </div>
  658. <div class="text-xs text-gray-500 space-y-1 border-t pt-3">
  659. <div>📌 ID:<span class="font-mono">${escapeHtml(k.id || '')}</span></div>
  660. <div>👤 Owner:${escapeHtml(k.owner || '–')}</div>
  661. <div>🕐 创建:${k.created_at ? new Date(k.created_at * 1000).toLocaleString() : '–'}</div>
  662. ${tagKeys.length > 0 ? `<div>🏷️ Tags:${tagKeys.map(t => `<span class="bg-gray-100 px-1 rounded">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
  663. </div>`;
  664. } catch (err) {
  665. contentEl.innerHTML = '<p class="text-red-500 text-center">加载失败</p>';
  666. console.error(err);
  667. }
  668. }
  669. function closeKnowledgeDetailModal() {
  670. document.getElementById('knowledgeDetailModal').classList.add('hidden');
  671. }
  672. function escapeHtml(text) {
  673. const div = document.createElement('div');
  674. div.textContent = text;
  675. return div.innerHTML;
  676. }