.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;
<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>
async function showPicker() {
show('picker');
await renderTasks();
+ await renderSkillsSidebar();
}
// 列表页自动刷新:进入时启动,离开/退出时停止
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; }
}
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) => {
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 = '';