]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 首页支持全局 skill 管理(查看/新增/更新/删除)及运行中会话热加载选择器
authorCodebuddy <codebuddy@localhost>
Sat, 15 Aug 2026 08:49:34 +0000 (16:49 +0800)
committerCodebuddy <codebuddy@localhost>
Sat, 15 Aug 2026 08:49:34 +0000 (16:49 +0800)
- 后端抽共享 helper,新增全局接口 GET/POST /api/skills、GET/PUT/DELETE /api/skills/{name}
- 前端将技能弹窗移到 body 级,首页与终端页共用;首页新增「🧩 技能」入口
- 首页「加载」按运行中会话弹出选择器(可多选/全部),无运行会话则提示已全局就位
- 修复 _extract_skill_zip 根级文件+单子目录混合结构的裁前缀 bug,并优先用 SKILL.md frontmatter 命名

backend/app.py
frontend/index.html

index 2488c58e8af34dc8a242451b5991ef1dd1e75f04..2dc80ad2a0b16859864050399d41807f4182c312 100644 (file)
@@ -873,7 +873,12 @@ def _extract_skill_zip(zf: zipfile.ZipFile, dest: Path):
         strip = False
     dest.mkdir(parents=True, exist_ok=True)
     for info, name, p in entries:
-        rel = name[len(top) + 1:] if (strip and top) else name
+        # 仅对确实以顶层目录开头的条目去掉前缀;根级文件(如 SKILL.md)原样保留,
+        # 避免“根级文件 + 单个子目录”混合结构时被错误地裁掉前缀(SKILL.md → .md)。
+        if strip and top and name.startswith(top + "/"):
+            rel = name[len(top) + 1:]
+        else:
+            rel = name
         rel = rel.rstrip("/")
         if rel == "":
             continue
@@ -925,11 +930,87 @@ def _decode_zip_body(body: dict):
         raise ValueError("不是有效的 zip 文件")
 
 
+# ---- 共享逻辑:任务级与全局 skill 管理都调用这些 helper ----
+def _do_list_skills():
+    return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)})
+
+
+def _do_upload_skill(body: dict):
+    try:
+        zf = _decode_zip_body(body)
+    except ValueError as e:
+        return JSONResponse({"error": str(e)}, status_code=400)
+    entries = _real_entries(zf)
+    tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
+    provided = body.get("name")
+    if provided:
+        name = provided
+    else:
+        # 优先用根目录 SKILL.md frontmatter 的 name(最权威);
+        # 根级无 SKILL.md 时(如整目录打包 my-skill/SKILL.md)才退回用顶层目录名,
+        # 否则退回上传文件名。这样“根级 SKILL.md + 单个子目录”的混合结构也能正确命名。
+        fm_name = _skill_name_from_zip(zf)
+        if fm_name:
+            name = fm_name
+        elif len(tops) == 1:
+            name = tops.pop()
+        else:
+            fallback = (body.get("filename") or "skill").rsplit(".", 1)[0]
+            name = fallback
+    if not _NAME_RE.match(name):
+        zf.close()
+        return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)
+    try:
+        with zf:
+            dest = _install_skill_zip(zf, name)
+    except ValueError as e:
+        return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+    logger.info(f"上传 skill: {name} -> {dest}")
+    return JSONResponse({"ok": True, "name": name, "path": str(dest)})
+
+
+def _do_update_skill(name: str, body: dict):
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    if body.get("data"):
+        try:
+            zf = _decode_zip_body(body)
+        except ValueError as e:
+            return JSONResponse({"error": str(e)}, status_code=400)
+        try:
+            with zf:
+                dest = _install_skill_zip(zf, name)
+        except ValueError as e:
+            return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
+        return JSONResponse({"ok": True, "name": name, "path": str(dest), "mode": "zip"})
+    content = body.get("content")
+    if content is None:
+        return JSONResponse({"error": "需提供 data(zip) 或 content 字段"}, status_code=400)
+    skill_md = p / "SKILL.md"
+    if not skill_md.is_file():
+        return JSONResponse({"error": "该 skill 不存在 SKILL.md,无法更新内容"}, status_code=404)
+    skill_md.write_text(content, encoding="utf-8")
+    return JSONResponse({"ok": True, "name": name, "mode": "content"})
+
+
+def _do_delete_skill(name: str):
+    p = _safe_skill_path(name)
+    if p is None:
+        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
+    if not p.exists():
+        return JSONResponse({"error": "skill 不存在"}, status_code=404)
+    shutil.rmtree(p)
+    logger.info(f"删除 skill: {name}")
+    return JSONResponse({"ok": True, "name": name})
+
+
+# ---- 任务级接口(保留,用于终端页热加载通知等)----
 @app.get("/api/tasks/{task_id}/skills")
 async def api_list_skills(task_id: str, request: Request):
     if not _auth_ok(request):
         return JSONResponse({"error": "unauthorized"}, status_code=401)
-    return JSONResponse({"ok": True, "skills": _list_skills(), "root": str(SKILLS_ROOT)})
+    return _do_list_skills()
 
 
 @app.get("/api/tasks/{task_id}/skills/{name}/content")
@@ -953,82 +1034,75 @@ async def api_upload_skill(task_id: str, request: Request):
         body = await request.json()
     except Exception:
         return JSONResponse({"error": "invalid json body"}, status_code=400)
-    try:
-        zf = _decode_zip_body(body)
-    except ValueError as e:
-        return JSONResponse({"error": str(e)}, status_code=400)
-    entries = _real_entries(zf)
-    tops = set(p[0] for info, name, p in entries if len(p) > 1 and p[0])
-    provided = body.get("name")
-    if provided:
-        name = provided
-    elif len(tops) == 1:
-        name = tops.pop()
-    else:
-        # 扁平结构(0 个或多个顶层目录,如技能内容直接压缩):优先用 SKILL.md frontmatter 的 name
-        fallback = (body.get("filename") or "skill").rsplit(".", 1)[0]
-        name = _skill_name_from_zip(zf) or fallback
-    if not _NAME_RE.match(name):
-        zf.close()
-        return JSONResponse({"error": f"skill 名称非法: {name}(仅允许字母数字 - _,长度 1-64)"}, status_code=400)
-    try:
-        with zf:
-            dest = _install_skill_zip(zf, name)
-    except ValueError as e:
-        return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
-    logger.info(f"上传 skill: {name} -> {dest}")
-    return JSONResponse({"ok": True, "name": name, "path": str(dest)})
+    return _do_upload_skill(body)
 
 
 @app.put("/api/tasks/{task_id}/skills/{name}")
 async def api_update_skill(task_id: str, name: str, request: Request):
     if not _auth_ok(request):
         return JSONResponse({"error": "unauthorized"}, status_code=401)
-    p = _safe_skill_path(name)
-    if p is None:
-        return JSONResponse({"error": "skill 名称非法"}, status_code=400)
-    ctype = request.headers.get("content-type", "")
-    # 模式1:zip 覆盖
-    if "multipart/form-data" in ctype or "application/json" in ctype:
-        try:
-            body = await request.json()
-        except Exception:
-            return JSONResponse({"error": "invalid json body"}, status_code=400)
-        if body.get("data"):
-            try:
-                zf = _decode_zip_body(body)
-            except ValueError as e:
-                return JSONResponse({"error": str(e)}, status_code=400)
-            try:
-                with zf:
-                    dest = _install_skill_zip(zf, name)
-            except ValueError as e:
-                return JSONResponse({"error": f"解压失败: {e}"}, status_code=400)
-            return JSONResponse({"ok": True, "name": name, "path": str(dest), "mode": "zip"})
-        # 模式2:直接更新 SKILL.md 内容
-        content = body.get("content")
-        if content is None:
-            return JSONResponse({"error": "需提供 data(zip) 或 content 字段"}, status_code=400)
-        skill_md = p / "SKILL.md"
-        if not skill_md.is_file():
-            return JSONResponse({"error": "该 skill 不存在 SKILL.md,无法更新内容"}, status_code=404)
-        skill_md.write_text(content, encoding="utf-8")
-        return JSONResponse({"ok": True, "name": name, "mode": "content"})
-    return JSONResponse({"error": "不支持的 content-type"}, status_code=400)
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json body"}, status_code=400)
+    return _do_update_skill(name, body)
 
 
 @app.delete("/api/tasks/{task_id}/skills/{name}")
 async def api_delete_skill(task_id: str, name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    return _do_delete_skill(name)
+
+
+# ---- 全局接口(任务列表页使用,不依赖某个任务)----
+@app.get("/api/skills")
+async def api_global_list_skills(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    return _do_list_skills()
+
+
+@app.get("/api/skills/{name}/content")
+async def api_global_skill_content(name: str, request: Request):
     if not _auth_ok(request):
         return JSONResponse({"error": "unauthorized"}, status_code=401)
     p = _safe_skill_path(name)
     if p is None:
         return JSONResponse({"error": "skill 名称非法"}, status_code=400)
-    if not p.exists():
-        return JSONResponse({"error": "skill 不存在"}, status_code=404)
-    shutil.rmtree(p)
-    logger.info(f"删除 skill: {name}")
-    return JSONResponse({"ok": True, "name": name})
+    skill_md = p / "SKILL.md"
+    if not skill_md.is_file():
+        return JSONResponse({"error": "该 skill 不存在 SKILL.md"}, status_code=404)
+    return JSONResponse({"ok": True, "content": skill_md.read_text(encoding="utf-8", errors="replace")})
+
+
+@app.post("/api/skills")
+async def api_global_upload_skill(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json body"}, status_code=400)
+    return _do_upload_skill(body)
+
+
+@app.put("/api/skills/{name}")
+async def api_global_update_skill(name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json body"}, status_code=400)
+    return _do_update_skill(name, body)
+
+
+@app.delete("/api/skills/{name}")
+async def api_global_delete_skill(name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    return _do_delete_skill(name)
 
 
 @app.post("/api/tasks/{task_id}/skills/{name}/notify")
index af10872e82b77ad2f7f2a88cbc8ee639b28588e0..5da59ca809d0468e38869e8e058c3e412fd4864a 100644 (file)
@@ -187,6 +187,7 @@ 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>
@@ -251,14 +252,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
       <input type="file" id="paste-file-input" accept="image/*" style="display:none;">
     </div>
   </div>
-  <!-- 技能管理 modal -->
+  <!-- 技能管理 modal(全局,首页与终端页共用) -->
   <div id="skills-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center;">
     <div style="background:#1a1a2e; border:1px solid #74c0fc; border-radius:12px; padding:18px; width:640px; max-width:92vw; max-height:86vh; overflow:auto;">
       <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
         <div style="color:#74c0fc; font-size:16px;">🧩 会话技能(全局 ~/.codebuddy/skills)</div>
         <button class="icon-btn" id="skills-close" title="关闭">✕</button>
       </div>
-      <div style="font-size:12px; color:#999; margin-bottom:10px; line-height:1.5;">ä¸\8aä¼ æ\95´ç\9b®å½\95 zipï¼\88须以å\8d\95个 skill ç\9b®å½\95为顶å±\82ï¼\8cå\86\85å\90« <code style="color:#4ecca3;">SKILL.md</code>ï¼\89ã\80\82æ\96°æ\8a\80è\83½å¯¹æ\89\80æ\9c\89ä¼\9aè¯\9då\8f¯è§\81ï¼\9bå½\93å\89\8dè¿\90è¡\8c中ç\9a\84ä¼\9aè¯\9då\8f¯ç\82¹ã\80\8cé\80\9aç\9f¥å\8a è½½ã\80\8d热加载,否则重启/重连任务后由 codebuddy 自动发现。</div>
+      <div style="font-size:12px; color:#999; margin-bottom:10px; line-height:1.5;">ä¸\8aä¼ æ\95´ç\9b®å½\95 zipï¼\88须以å\8d\95个 skill ç\9b®å½\95为顶å±\82ï¼\8cå\86\85å\90« <code style="color:#4ecca3;">SKILL.md</code>ï¼\89ã\80\82æ\96°æ\8a\80è\83½å¯¹æ\89\80æ\9c\89ä¼\9aè¯\9då\85¨å±\80å\8f¯è§\81ï¼\9bè¿\90è¡\8c中ç\9a\84ä¼\9aè¯\9då\8f¯ç\82¹ã\80\8cå\8a è½½ã\80\8dé\80\89æ\8b©ä¼\9aè¯\9d热加载,否则重启/重连任务后由 codebuddy 自动发现。</div>
       <div style="display:flex; gap:8px; margin-bottom:12px;">
         <input type="file" id="skills-file" accept=".zip" style="flex:1; color:#ccc; font-size:13px;">
         <button class="send-btn" id="skills-upload">上传</button>
@@ -272,6 +273,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
           <button class="send-btn" id="skills-edit-save">保存</button>
         </div>
       </div>
+      <div id="skills-load" style="display:none; margin-top:14px; border-top:1px solid rgba(255,255,255,0.1); padding-top:12px;">
+        <div style="color:#e0e0e0; font-size:13px; margin-bottom:6px;">选择要热加载的会话:<code id="skills-load-name" style="color:#4ecca3;"></code></div>
+        <div id="skills-load-list" style="display:flex; flex-direction:column; gap:6px; max-height:200px; overflow:auto;"></div>
+        <div style="display:flex; gap:8px; justify-content:flex-end; margin-top:10px;">
+          <button class="del-btn del-cancel" id="skills-load-cancel">取消</button>
+          <button class="send-btn" id="skills-load-confirm">确定加载</button>
+        </div>
+      </div>
     </div>
   </div>
 </div>
@@ -814,9 +823,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   const skillsEditArea = document.getElementById('skills-edit-area');
   const skillsEditName = document.getElementById('skills-edit-name');
   let skillsEditTarget = null;
+  const skillsLoad = document.getElementById('skills-load');
+  const skillsLoadList = document.getElementById('skills-load-list');
+  const skillsLoadName = document.getElementById('skills-load-name');
+  let skillsLoadTarget = null;
 
   function openSkillsModal() {
     skillsEdit.style.display = 'none';
+    skillsLoad.style.display = 'none';
     skillsModal.style.display = 'flex';
     loadSkills();
   }
@@ -825,6 +839,7 @@ 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) => {
@@ -835,7 +850,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
 
   async function loadSkills() {
     try {
-      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+      const res = await fetch(API + '/api/skills', { headers: authHeaders() });
       if (!res.ok) return;
       const data = await res.json();
       const skills = (data.skills || []);
@@ -850,7 +865,9 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
         + (s.description ? '<div style="color:#aaa; font-size:12px; margin:4px 0 8px; line-height:1.4;">' + escHtml(s.description) + '</div>' : '<div style="margin:4px 0 8px;"></div>')
         + '<div style="display:flex; gap:8px;">'
         + '<button class="del-btn del-cancel" data-act="edit" data-name="' + escHtml(s.dir_name) + '">编辑</button>'
-        + '<button class="del-btn del-cancel" data-act="notify" data-name="' + escHtml(s.dir_name) + '">通知加载</button>'
+        + (MODE === 'terminal'
+            ? '<button class="del-btn del-cancel" data-act="notify" data-name="' + escHtml(s.dir_name) + '">通知加载</button>'
+            : '<button class="del-btn del-cancel" data-act="load" data-name="' + escHtml(s.dir_name) + '">加载</button>')
         + '<button class="del-btn del-confirm" data-act="del" data-name="' + escHtml(s.dir_name) + '">删除</button>'
         + '</div></div>'
       ).join('');
@@ -860,6 +877,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
           if (act === 'edit') editSkill(n);
           else if (act === 'del') deleteSkill(n);
           else if (act === 'notify') notifySkill(n);
+          else if (act === 'load') loadSkillToSession(n);
         };
       });
     } catch (e) { console.warn('loadSkills failed', e); }
@@ -879,7 +897,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     if (!f) { alert('请先选择一个 .zip 文件'); return; }
     try {
       const b64 = await readFileAsBase64(f);
-      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', {
+      const res = await fetch(API + '/api/skills', {
         method: 'POST',
         headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
         body: JSON.stringify({ data: b64 })
@@ -894,11 +912,11 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
 
   async function editSkill(name) {
     try {
-      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills', { headers: authHeaders() });
+      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/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name) + '/content', { headers: authHeaders() });
+      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 || '读取失败');
       skillsEditName.textContent = s.name || name;
@@ -913,7 +931,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     if (!skillsEditTarget) return;
     const name = skillsEditTarget;
     try {
-      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), {
+      const res = await fetch(API + '/api/skills/' + encodeURIComponent(name), {
         method: 'PUT',
         headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
         body: JSON.stringify({ content: skillsEditArea.value })
@@ -929,7 +947,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   async function deleteSkill(name) {
     if (!confirm('确认删除 skill: ' + name + '?该目录将被永久移除。')) return;
     try {
-      const res = await fetch(API + '/api/tasks/' + TASK_ID + '/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+      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));
       term && term.writeln('\x1b[33m🗑 已删除 skill: ' + name + '\x1b[0m');
@@ -947,6 +965,51 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     } catch (e) { alert('热加载失败: ' + e.message); }
   }
 
+  // 首页“加载”:选择运行中的会话热加载
+  async function loadSkillToSession(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;
+      }
+      skillsLoadTarget = name;
+      skillsLoadName.textContent = name;
+      skillsLoadList.innerHTML = living.map(t =>
+        '<label style="display:flex; gap:8px; align-items:center; font-size:13px; color:#e0e0e0;">'
+        + '<input type="checkbox" class="skills-load-cb" value="' + escHtml(t.id) + '">'
+        + escHtml(t.name) + ' <span style="color:#888;">[' + escHtml(t.live_status) + ']</span></label>'
+      ).join('')
+      + '<label style="display:flex; gap:8px; align-items:center; font-size:13px; color:#4ecca3; margin-top:4px;"><input type="checkbox" id="skills-load-all"> 全部运行中的会话</label>';
+      skillsEdit.style.display = 'none';
+      skillsLoad.style.display = 'block';
+    } catch (e) { alert('加载失败: ' + e.message); }
+  }
+
+  document.getElementById('skills-load-cancel').onclick = () => { skillsLoad.style.display = 'none'; skillsLoadTarget = null; };
+  document.getElementById('skills-load-confirm').onclick = async () => {
+    if (!skillsLoadTarget) return;
+    const name = skillsLoadTarget;
+    const all = document.getElementById('skills-load-all').checked;
+    const cbs = Array.from(skillsLoadList.querySelectorAll('.skills-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 + ' 个失败') : '') + '。');
+    skillsLoad.style.display = 'none'; skillsLoadTarget = null;
+    loadSkills();
+  };
+
   // ✕ 清空
   pasteClear.onclick = () => {
     pasteInput.value = '';