]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 首页常驻左侧技能面板(查看/上传/更新/编辑/删除/加载)
authorCodebuddy <codebuddy@localhost>
Sat, 15 Aug 2026 09:20:42 +0000 (17:20 +0800)
committerCodebuddy <codebuddy@localhost>
Sat, 15 Aug 2026 09:20:42 +0000 (17:20 +0800)
- 列表页改为左 300px 技能侧边栏 + 右任务网格布局
- 侧边栏常驻展示已加载技能,支持上传新增、zip 覆盖更新、内联编辑 SKILL.md、删除、加载到运行中会话
- 移除列表页右上角冗余的弹窗按钮;弹窗保留给终端页
- 侧边栏随列表页自动刷新(编辑/加载面板打开时跳过以免清掉输入)

frontend/index.html

index 5da59ca809d0468e38869e8e058c3e412fd4864a..55b8a46caf8bd5cd605e10f0697f3f09045813c3 100644 (file)
@@ -101,6 +101,29 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
 .picker-header h2 { font-size: 22px; color: #fff; }
 .picker-header h2 small { color: #888; font-size: 14px; font-weight: normal; margin-left: 10px; }
 .picker-body { flex: 1; overflow: auto; padding: 28px; }
+.picker-main { display: flex; flex: 1; min-height: 0; }
+#skills-sidebar {
+  width: 300px; flex-shrink: 0; background: #16213e; border-right: 1px solid rgba(255,255,255,0.08);
+  display: flex; flex-direction: column; padding: 14px; gap: 12px; overflow: auto;
+}
+#skills-sidebar .ss-head { color: #74c0fc; font-size: 15px; font-weight: 600; }
+#skills-sidebar .ss-upload { display: flex; gap: 8px; }
+#skills-sidebar .ss-upload input[type=file] { flex: 1; min-width: 0; color: #ccc; font-size: 12px; }
+.ss-btn { padding: 6px 12px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 13px; cursor: pointer; }
+.ss-btn:hover { background: rgba(255,255,255,0.12); }
+.ss-btn.ss-primary { background: #4ecca3; color: #1a1a2e; border-color: #4ecca3; font-weight: 600; }
+.ss-btn.ss-cancel { background: transparent; }
+.ss-btn.ss-danger { background: rgba(255,107,107,0.15); color: #ff6b6b; border-color: rgba(255,107,107,0.3); }
+.ss-card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px 12px; }
+.ss-card .ss-name { color: #e0e0e0; font-size: 14px; font-weight: 600; }
+.ss-card .ss-desc { color: #aaa; font-size: 12px; margin: 4px 0 8px; line-height: 1.4; }
+.ss-card .ss-actions { display: flex; gap: 8px; }
+.ss-card .ss-actions .ss-btn { flex: 1; }
+.ss-row-end { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
+#skills-sidebar textarea { width: 100%; box-sizing: border-box; background: #0f0f1e; color: #e0e0e0; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 10px; font-family: 'Cascadia Code', Menlo, monospace; font-size: 12px; line-height: 1.5; resize: vertical; }
+.ss-edit-title { color: #e0e0e0; font-size: 13px; margin-bottom: 6px; }
+#skills-sidebar label.ss-session { display: flex; gap: 8px; align-items: center; font-size: 13px; color: #e0e0e0; margin: 4px 0; }
+#skills-sidebar .ss-empty { color: #777; font-size: 13px; }
 .task-grid {
   display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px;
   max-width: 1200px; margin: 0 auto;
@@ -187,12 +210,38 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     <h2>任务清单 <small id="picker-subtitle"></small></h2>
     <div style="display:flex; gap:16px; align-items:center;">
       <label class="picker-toggle"><input type="checkbox" id="show-all-tasks"> 显示全部任务</label>
-      <button class="action-btn" id="btn-skills-picker" title="管理自定义 skill(全局)">🧩 技能</button>
       <button class="action-btn" id="btn-logout-picker">退出登录</button>
     </div>
   </div>
-  <div class="picker-body">
-    <div class="task-grid" id="task-grid"></div>
+  <div class="picker-main">
+    <aside id="skills-sidebar">
+      <div class="ss-head">🧩 技能(全局)</div>
+      <div class="ss-upload">
+        <input type="file" id="ss-file" accept=".zip">
+        <button class="ss-btn ss-primary" id="ss-upload">上传</button>
+      </div>
+      <input type="file" id="ss-update-file" accept=".zip" style="display:none;">
+      <div id="ss-list"></div>
+      <div id="ss-edit" style="display:none;">
+        <div class="ss-edit-title">编辑 <code id="ss-edit-name" style="color:#4ecca3;"></code> 的 SKILL.md</div>
+        <textarea id="ss-edit-area" rows="12"></textarea>
+        <div class="ss-row-end">
+          <button class="ss-btn ss-cancel" id="ss-edit-cancel">取消</button>
+          <button class="ss-btn ss-primary" id="ss-edit-save">保存</button>
+        </div>
+      </div>
+      <div id="ss-load" style="display:none;">
+        <div class="ss-edit-title">选择要热加载的会话:<code id="ss-load-name" style="color:#4ecca3;"></code></div>
+        <div id="ss-load-list"></div>
+        <div class="ss-row-end">
+          <button class="ss-btn ss-cancel" id="ss-load-cancel">取消</button>
+          <button class="ss-btn ss-primary" id="ss-load-confirm">确定加载</button>
+        </div>
+      </div>
+    </aside>
+    <div class="picker-body">
+      <div class="task-grid" id="task-grid"></div>
+    </div>
   </div>
 </div>
 
@@ -387,6 +436,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   async function showPicker() {
     show('picker');
     await renderTasks();
+    await renderSkillsSidebar();
   }
 
   // 列表页自动刷新:进入时启动,离开/退出时停止
@@ -402,7 +452,10 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   async function refreshTasks() {
     if (pickerRefreshing) return;
     pickerRefreshing = true;
-    try { await renderTasks(); } catch (e) { console.warn('refreshTasks failed', e); }
+    try {
+      await renderTasks();
+      await renderSkillsSidebar();
+    } catch (e) { console.warn('refreshTasks failed', e); }
     finally { pickerRefreshing = false; }
   }
 
@@ -839,7 +892,6 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     if (term && term.focus) term.focus();
   }
   document.getElementById('btn-skills').onclick = openSkillsModal;
-  document.getElementById('btn-skills-picker').onclick = openSkillsModal;
   document.getElementById('skills-close').onclick = closeSkillsModal;
   skillsModal.addEventListener('click', (e) => { if (e.target === skillsModal) closeSkillsModal(); });
   document.addEventListener('keydown', (e) => {
@@ -1010,6 +1062,180 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     loadSkills();
   };
 
+  // ========== 首页技能侧边栏(常驻面板) ==========
+  const ssList = document.getElementById('ss-list');
+  const ssFile = document.getElementById('ss-file');
+  const ssEdit = document.getElementById('ss-edit');
+  const ssEditArea = document.getElementById('ss-edit-area');
+  const ssEditName = document.getElementById('ss-edit-name');
+  const ssLoad = document.getElementById('ss-load');
+  const ssLoadList = document.getElementById('ss-load-list');
+  const ssLoadName = document.getElementById('ss-load-name');
+  const ssUpdateFile = document.getElementById('ss-update-file');
+  let ssEditTarget = null;
+  let ssLoadTarget = null;
+  let ssUpdateTarget = null;
+
+  async function renderSkillsSidebar() {
+    // 正在编辑/选择加载时,跳过自动刷新以免清掉用户输入
+    if (ssEdit.style.display === 'block' || ssLoad.style.display === 'block') return;
+    try {
+      const res = await fetch(API + '/api/skills', { headers: authHeaders() });
+      if (!res.ok) return;
+      const data = await res.json();
+      const skills = data.skills || [];
+      if (!skills.length) {
+        ssList.innerHTML = '<div class="ss-empty">暂无自定义 skill。上传一个 zip 开始。</div>';
+        return;
+      }
+      ssList.innerHTML = skills.map(s =>
+        '<div class="ss-card">'
+        + '<div class="ss-name">' + escHtml(s.name)
+        + (s.has_skill_md ? '' : ' <span style="color:#ff6b6b; font-size:12px;">(缺 SKILL.md)</span>') + '</div>'
+        + (s.description ? '<div class="ss-desc">' + escHtml(s.description) + '</div>' : '<div class="ss-desc"></div>')
+        + '<div class="ss-actions">'
+        + '<button class="ss-btn" data-act="update" data-name="' + escHtml(s.dir_name) + '">更新</button>'
+        + '<button class="ss-btn" data-act="edit" data-name="' + escHtml(s.dir_name) + '">编辑</button>'
+        + '<button class="ss-btn" data-act="load" data-name="' + escHtml(s.dir_name) + '">加载</button>'
+        + '<button class="ss-btn ss-danger" data-act="del" data-name="' + escHtml(s.dir_name) + '">删除</button>'
+        + '</div></div>'
+      ).join('');
+      ssList.querySelectorAll('button[data-act]').forEach(btn => {
+        btn.onclick = () => {
+          const act = btn.dataset.act, n = btn.dataset.name;
+          if (act === 'edit') ssEditSkill(n);
+          else if (act === 'update') ssUpdateSkill(n);
+          else if (act === 'del') ssDeleteSkill(n);
+          else if (act === 'load') ssLoadSkill(n);
+        };
+      });
+    } catch (e) { console.warn('renderSkillsSidebar failed', e); }
+  }
+
+  document.getElementById('ss-upload').onclick = async () => {
+    const f = ssFile.files && ssFile.files[0];
+    if (!f) { alert('请先选择一个 .zip 文件'); return; }
+    try {
+      const b64 = await readFileAsBase64(f);
+      const res = await fetch(API + '/api/skills', {
+        method: 'POST',
+        headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+        body: JSON.stringify({ data: b64 })
+      });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      ssFile.value = '';
+      renderSkillsSidebar();
+    } catch (e) { alert('上传失败: ' + e.message); }
+  };
+
+  // 更新:用新 zip 覆盖该 skill 目录(name 锁定为原目录名)
+  function ssUpdateSkill(name) {
+    ssUpdateTarget = name;
+    ssUpdateFile.click();
+  }
+  ssUpdateFile.onchange = async () => {
+    const f = ssUpdateFile.files && ssUpdateFile.files[0];
+    if (!f) { ssUpdateTarget = null; return; }
+    const target = ssUpdateTarget; ssUpdateTarget = null;
+    try {
+      const b64 = await readFileAsBase64(f);
+      const res = await fetch(API + '/api/skills', {
+        method: 'POST',
+        headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+        body: JSON.stringify({ data: b64, name: target })
+      });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      ssUpdateFile.value = '';
+      renderSkillsSidebar();
+    } catch (e) { alert('更新失败: ' + e.message); }
+  };
+
+  async function ssEditSkill(name) {
+    try {
+      const res = await fetch(API + '/api/skills', { headers: authHeaders() });
+      const data = await res.json();
+      const s = (data.skills || []).find(x => x.dir_name === name);
+      if (!s) throw new Error('未找到 skill');
+      const res2 = await fetch(API + '/api/skills/' + encodeURIComponent(name) + '/content', { headers: authHeaders() });
+      const d2 = await res2.json();
+      if (!res2.ok || !d2.ok) throw new Error(d2.error || '读取失败');
+      ssEditName.textContent = s.name || name;
+      ssEditTarget = name;
+      ssEditArea.value = d2.content;
+      ssLoad.style.display = 'none';
+      ssEdit.style.display = 'block';
+    } catch (e) { alert('打开编辑失败: ' + e.message); }
+  }
+  document.getElementById('ss-edit-cancel').onclick = () => { ssEdit.style.display = 'none'; ssEditTarget = null; };
+  document.getElementById('ss-edit-save').onclick = async () => {
+    if (!ssEditTarget) return;
+    const name = ssEditTarget;
+    try {
+      const res = await fetch(API + '/api/skills/' + encodeURIComponent(name), {
+        method: 'PUT', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+        body: JSON.stringify({ content: ssEditArea.value })
+      });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      ssEdit.style.display = 'none'; ssEditTarget = null;
+      renderSkillsSidebar();
+    } catch (e) { alert('保存失败: ' + e.message); }
+  };
+
+  async function ssDeleteSkill(name) {
+    if (!confirm('确认删除 skill: ' + name + '?该目录将被永久移除。')) return;
+    try {
+      const res = await fetch(API + '/api/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+      const d = await res.json();
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      renderSkillsSidebar();
+    } catch (e) { alert('删除失败: ' + e.message); }
+  }
+
+  async function ssLoadSkill(name) {
+    try {
+      const res = await fetch(API + '/api/tasks', { headers: authHeaders() });
+      const data = await res.json();
+      const tasks = data.tasks || [];
+      const living = tasks.filter(t => ['connected', 'background', 'background_idle'].includes(t.live_status));
+      if (!living.length) {
+        alert('当前没有运行中的会话,技能已全局就位,所有会话下次打开时将自动发现此技能。');
+        return;
+      }
+      ssLoadTarget = name;
+      ssLoadName.textContent = name;
+      ssLoadList.innerHTML = living.map(t =>
+        '<label class="ss-session"><input type="checkbox" class="ss-load-cb" value="' + escHtml(t.id) + '">'
+        + escHtml(t.name) + ' <span style="color:#888;">[' + escHtml(t.live_status) + ']</span></label>'
+      ).join('')
+      + '<label class="ss-session" style="color:#4ecca3;"><input type="checkbox" id="ss-load-all"> 全部运行中的会话</label>';
+      ssEdit.style.display = 'none';
+      ssLoad.style.display = 'block';
+    } catch (e) { alert('加载失败: ' + e.message); }
+  }
+  document.getElementById('ss-load-cancel').onclick = () => { ssLoad.style.display = 'none'; ssLoadTarget = null; };
+  document.getElementById('ss-load-confirm').onclick = async () => {
+    if (!ssLoadTarget) return;
+    const name = ssLoadTarget;
+    const all = document.getElementById('ss-load-all').checked;
+    const cbs = Array.from(ssLoadList.querySelectorAll('.ss-load-cb'));
+    const ids = all ? cbs.map(c => c.value) : cbs.filter(c => c.checked).map(c => c.value);
+    if (!ids.length) { alert('请至少选择一个会话'); return; }
+    let ok = 0, fail = 0;
+    for (const tid of ids) {
+      try {
+        const res = await fetch(API + '/api/tasks/' + tid + '/skills/' + encodeURIComponent(name) + '/notify', { method: 'POST', headers: authHeaders() });
+        const d = await res.json();
+        if (res.ok && d.ok && d.notified) ok++; else fail++;
+      } catch (e) { fail++; }
+    }
+    alert('已向 ' + ok + ' 个会话注入热加载通知' + (fail ? (',' + fail + ' 个失败') : '') + '。');
+    ssLoad.style.display = 'none'; ssLoadTarget = null;
+    renderSkillsSidebar();
+  };
+
   // ✕ 清空
   pasteClear.onclick = () => {
     pasteInput.value = '';