]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 帮助页改版(规格风格+侧边目录+MD下载,移除FAQ);上传上限统一提升至20MB
authorCodebuddy <codebuddy@localhost>
Mon, 17 Aug 2026 02:10:52 +0000 (10:10 +0800)
committerCodebuddy <codebuddy@localhost>
Mon, 17 Aug 2026 02:10:52 +0000 (10:10 +0800)
backend/app.py
frontend/help.html
frontend/index.html

index b9bb272ed4d1fe4b31654d1f38c11cfcee38b5f1..1d88ed944dbface105eab62473f35bc1f7ecdb30 100644 (file)
@@ -734,7 +734,7 @@ async def api_kill(pid: int, request: Request):
 # 前端把粘贴的截图 POST 到这里,后端存盘后返回绝对路径
 # 前端再把 <image_local_path>路径</image_local_path> 当作输入发到 CLI
 PASTES_DIR = Path(__file__).resolve().parent.parent / "data" / "pastes"
-MAX_UPLOAD_SIZE = 5 * 1024 * 1024  # 5MB(粘贴栏上传上限:图片/视频/zip/任意格式)
+MAX_UPLOAD_SIZE = 20 * 1024 * 1024  # 20MB(粘贴栏上传上限:图片/视频/zip/任意格式,统一上限)
 _MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
              "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp"}
 
index dc41b66f4d5ac0114955c42155b61d015a40c7d9..3793b63916c6755ccc0af28e7c638d34298e62fe 100644 (file)
 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
 <title>Codebuddy Web Console · 设计说明</title>
 <style>
+  :root {
+    --ink: #1f2328;
+    --ink-soft: #444b52;
+    --muted: #6b7280;
+    --line: #d8dde3;
+    --line-soft: #eceff2;
+    --paper: #ffffff;
+    --bg: #f4f5f7;
+    --accent: #2f5d8a;
+    --accent-soft: #eef2f7;
+    --code-bg: #f3f4f6;
+  }
   * { margin: 0; padding: 0; box-sizing: border-box; }
-  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
-         color: #222; line-height: 1.7; background: #f5f6fa; padding: 32px 16px; }
-  .wrap { max-width: 920px; margin: 0 auto; background: #fff; border-radius: 14px; padding: 36px 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
-  header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; border-bottom: 2px solid #4ecca3; padding-bottom: 16px; margin-bottom: 8px; }
-  h1 { font-size: 24px; color: #1a1a2e; }
-  .sub { color: #888; font-size: 13px; margin-top: 4px; }
-  .btn-pdf { flex-shrink: 0; background: #4ecca3; color: #06281f; border: none; border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 13px; font-weight: 600; }
-  .btn-pdf:hover { background: #6fd9b4; }
-  h2 { font-size: 19px; color: #1a1a2e; margin: 28px 0 10px; padding-left: 10px; border-left: 4px solid #4ecca3; }
-  h3 { font-size: 16px; color: #157a5e; margin: 18px 0 6px; }
-  p { margin: 6px 0; color: #333; }
-  ul, ol { margin: 6px 0 6px 22px; }
-  li { margin: 4px 0; }
-  code { background: #eef6f2; color: #157a5e; padding: 1px 6px; border-radius: 5px; font-size: 13px; font-family: "Cascadia Code", Menlo, Consolas, monospace; }
-  .tag { display: inline-block; background: #1a1a2e; color: #4ecca3; border-radius: 6px; padding: 2px 8px; font-size: 12px; margin: 2px 4px 2px 0; }
-  table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 14px; }
-  th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #e3e6ee; vertical-align: top; }
-  th { background: #f0f3f8; color: #1a1a2e; }
-  .note { background: #fff8e6; border: 1px solid #ffe08a; border-radius: 8px; padding: 10px 14px; margin: 10px 0; font-size: 13px; color: #6b5a1f; }
-  .page { background: #f0f3ff; border: 1px solid #c9d4ff; border-radius: 8px; padding: 12px 16px; margin: 10px 0; }
-  footer { margin-top: 32px; padding-top: 14px; border-top: 1px solid #eee; color: #aaa; font-size: 12px; text-align: center; }
-  a { color: #157a5e; }
+  html { scroll-behavior: smooth; }
+  body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
+    color: var(--ink); background: var(--bg); line-height: 1.75; font-size: 15px;
+  }
+
+  /* ---------- 布局:左侧目录 + 右侧正文 ---------- */
+  .layout { display: flex; align-items: flex-start; max-width: 1180px; margin: 0 auto; }
+
+  .toc {
+    position: sticky; top: 0; align-self: flex-start;
+    width: 260px; flex-shrink: 0; height: 100vh; overflow-y: auto;
+    padding: 28px 18px 40px; border-right: 1px solid var(--line);
+    background: #fbfcfd;
+  }
+  .toc-title {
+    font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
+    color: var(--muted); margin-bottom: 12px; font-weight: 600;
+  }
+  .toc ol { list-style: none; counter-reset: toc; }
+  .toc > ol > li { margin: 2px 0; }
+  .toc a {
+    display: block; padding: 6px 10px; border-radius: 6px;
+    color: var(--ink-soft); text-decoration: none; font-size: 13.5px;
+    border-left: 2px solid transparent;
+  }
+  .toc a:hover { background: var(--accent-soft); color: var(--accent); }
+  .toc a.active { background: var(--accent-soft); color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
+  .toc .sub { padding-left: 14px; }
+  .toc .sub a { font-size: 12.5px; color: var(--muted); }
+
+  .content { flex: 1; min-width: 0; background: var(--paper); border: 1px solid var(--line); border-top: none; border-bottom: none; }
+  .doc { padding: 40px 52px 64px; }
+
+  /* ---------- 顶部条 ---------- */
+  .topbar {
+    display: flex; justify-content: space-between; align-items: flex-start; gap: 16px;
+    padding: 22px 52px; border-bottom: 1px solid var(--line); background: var(--paper);
+  }
+  .topbar h1 { font-size: 22px; font-weight: 700; letter-spacing: .01em; }
+  .topbar .sub { color: var(--muted); font-size: 13px; margin-top: 6px; }
+  .btn-pdf, .btn-md {
+    flex-shrink: 0; background: #fff; color: var(--accent); border: 1px solid var(--accent);
+    border-radius: 6px; padding: 7px 14px; cursor: pointer; font-size: 13px; font-weight: 600;
+  }
+  .btn-pdf:hover, .btn-md:hover { background: var(--accent-soft); }
+  .btn-md { margin-right: 8px; }
+
+  /* ---------- 排版 ---------- */
+  h2 {
+    font-size: 19px; font-weight: 700; color: var(--ink);
+    margin: 38px 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--line);
+    counter-increment: sec;
+  }
+  h2::before { content: counter(sec) ". "; color: var(--accent); }
+  h3 { font-size: 15.5px; font-weight: 700; color: var(--ink); margin: 22px 0 8px; }
+  h3::before { content: "§ "; color: var(--muted); font-weight: 400; }
+  p { margin: 8px 0; color: var(--ink-soft); }
+  ul, ol { margin: 8px 0 8px 24px; }
+  li { margin: 5px 0; color: var(--ink-soft); }
+  a { color: var(--accent); }
+  code {
+    background: var(--code-bg); color: #9a3412; padding: 1px 6px; border-radius: 4px;
+    font-size: 13px; font-family: "Cascadia Code", Menlo, Consolas, monospace;
+  }
+  kbd {
+    background: #fff; border: 1px solid var(--line); border-bottom-width: 2px;
+    border-radius: 4px; padding: 1px 6px; font-size: 12px; font-family: inherit;
+  }
+
+  .chip {
+    display: inline-block; background: #fff; color: var(--ink); border: 1px solid var(--line);
+    border-radius: 5px; padding: 1px 8px; font-size: 12.5px; margin: 2px 3px 2px 0;
+    font-family: "Cascadia Code", Menlo, Consolas, monospace;
+  }
+  table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 13.5px; }
+  th, td { text-align: left; padding: 9px 12px; border: 1px solid var(--line); vertical-align: top; }
+  th { background: #f8fafc; color: var(--ink); font-weight: 600; }
+  tbody tr:nth-child(even) { background: #fcfcfd; }
+
+  .note {
+    background: #fbfaf5; border: 1px solid #e8e2cf; border-left: 3px solid #c9a227;
+    border-radius: 4px; padding: 12px 16px; margin: 14px 0; font-size: 13.5px; color: #5c4d1f;
+  }
+  .note b { color: #4a3d12; }
+
+  footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12.5px; }
+
+  /* ---------- 移动端目录开关 ---------- */
+  .toc-toggle {
+    display: none; position: sticky; top: 0; z-index: 50;
+    width: 100%; text-align: left; background: #fbfcfd; color: var(--ink);
+    border: none; border-bottom: 1px solid var(--line); padding: 12px 16px; font-size: 14px; font-weight: 600; cursor: pointer;
+  }
+
+  @media (max-width: 900px) {
+    .layout { display: block; }
+    .toc-toggle { display: block; }
+    .toc {
+      position: static; width: 100%; height: auto; border-right: none;
+      border-bottom: 1px solid var(--line); display: none;
+    }
+    .toc.open { display: block; }
+    .content { border: none; }
+    .topbar, .doc { padding-left: 20px; padding-right: 20px; }
+  }
+
+  /* ---------- 打印 ---------- */
   @media print {
-    body { background: #fff; padding: 0; }
-    .wrap { box-shadow: none; max-width: 100%; padding: 0 8px; }
-    .btn-pdf { display: none; }
+    body { background: #fff; }
+    .layout { display: block; max-width: 100%; }
+    .toc-toggle, .toc, .btn-pdf { display: none !important; }
+    .content { border: none; }
+    .doc { padding: 0; }
     h2 { page-break-after: avoid; }
-    .page, .note { break-inside: avoid; }
+    .note, table, tr { break-inside: avoid; }
+    a { color: var(--ink); text-decoration: none; }
   }
   @page { margin: 16mm; }
 </style>
 </head>
 <body>
-<div class="wrap">
-  <header>
-    <div>
-      <h1>Codebuddy Web Console · 设计说明</h1>
-      <div class="sub">通过浏览器远程使用 Codebuddy CLI 的 Web 控制台 · 多会话 / 全局技能 / 文件上传 / 移动端适配</div>
-    </div>
-    <button class="btn-pdf" onclick="window.print()">⬇ 下载 PDF</button>
-  </header>
-
-  <h2>一、整体架构</h2>
-  <ul>
-    <li><b>后端</b>:FastAPI + WebSocket PTY。每个“任务”对应一个独立运行的 <code>codebuddy</code> CLI 进程。</li>
-    <li><b>前端</b>:单文件原生 HTML(无前端框架)。通过 URL 参数区分页面:<code>/</code> 为任务列表页,<code>/?task=&lt;任务ID&gt;</code> 为对应任务的终端页。</li>
-    <li><b>技能</b>:存储于全局目录 <code>~/.codebuddy/skills</code>。上传后对所有会话可见;运行中的会话可“热加载”即时生效,否则下次打开会话时由 codebuddy 自动发现。</li>
-    <li><b>上传文件</b>:统一存于服务端的 <code>data/pastes/</code> 目录,并可在“已上传文件”面板中集中管理。</li>
-  </ul>
-
-  <h2>二、页面与功能</h2>
-
-  <div class="page">
-    <h3>1. 登录页</h3>
-    <ul>
-      <li>输入访问密码进入控制台(密码保存在浏览器 <code>localStorage</code>)。</li>
-    </ul>
-  </div>
 
-  <div class="page">
-    <h3>2. 任务列表页(首页)</h3>
-    <p>默认展示“启用中”的任务,可勾选“显示全部任务”查看包括已暂停的全部任务。</p>
-    <h3>左侧 · 技能侧边栏(🧩 技能 全局)</h3>
-    <ul>
-      <li><b>上传</b>:选择 skill 的 zip(须以单个 skill 目录为顶层,内含 <code>SKILL.md</code>),上传后对所有会话全局可见。</li>
-      <li><b>技能列表卡片</b>:每张卡片提供 <span class="tag">更新</span><span class="tag">编辑</span><span class="tag">加载</span><span class="tag">删除</span>。
-        <ul>
-          <li><b>更新</b>:用新 zip 覆盖同名 skill(按 zip 内声明名匹配,避免产生重复目录)。</li>
-          <li><b>编辑</b>:直接修改 <code>SKILL.md</code> 内容。</li>
-          <li><b>加载</b>:选择“运行中的会话”注入热加载通知(可多选,或一键“全部运行中的会话”);若无运行会话则技能已全局就位,下次打开自动发现。</li>
-          <li><b>删除</b>:移除该全局 skill。</li>
-        </ul>
+<button class="toc-toggle" id="toc-toggle" onclick="document.getElementById('toc').classList.toggle('open')">☰ 目录</button>
+
+<div class="layout">
+  <nav class="toc" id="toc">
+    <div class="toc-title">目录</div>
+    <ol>
+      <li><a href="#sec-arch" class="nav-link">整体架构</a></li>
+      <li><a href="#sec-login" class="nav-link">登录页</a></li>
+      <li><a href="#sec-list" class="nav-link">任务列表页</a>
+        <ol class="sub">
+          <li><a href="#sec-list-head" class="nav-link">页面头部</a></li>
+          <li><a href="#sec-list-skills" class="nav-link">技能侧边栏</a></li>
+          <li><a href="#sec-list-tasks" class="nav-link">任务卡片区</a></li>
+          <li><a href="#sec-list-del" class="nav-link">删除确认弹窗</a></li>
+        </ol>
       </li>
-      <li><b>📂 已上传文件</b>:打开“已上传文件”管理弹窗(详见第 4 节)。</li>
-    </ul>
-    <h3>主区 · 任务卡片</h3>
-    <ul>
-      <li>展示:任务名、工作目录、状态、最近使用时间。</li>
-      <li>状态指示:<span class="tag">● 前台已登录</span><span class="tag">⚙ 后台运行中</span><span class="tag">⏸ 后台等待中</span><span class="tag">⏳ 后台任务待确认</span><span class="tag">⏸ 已暂停</span><span class="tag">○ 空闲</span>。</li>
-      <li>操作:打开终端、重启会话、暂停/恢复、删除(可同时删除历史会话记录与工作目录)。</li>
-    </ul>
-    <p class="note">列表页每 5 秒自动刷新一次,实时反映各会话状态。</p>
-  </div>
+      <li><a href="#sec-terminal" class="nav-link">终端页</a>
+        <ol class="sub">
+          <li><a href="#sec-term-top" class="nav-link">顶部操作栏</a></li>
+          <li><a href="#sec-term-out" class="nav-link">终端输出区</a></li>
+          <li><a href="#sec-term-paste" class="nav-link">粘贴弹窗</a></li>
+          <li><a href="#sec-term-skills" class="nav-link">技能弹窗</a></li>
+        </ol>
+      </li>
+      <li><a href="#sec-uploads" class="nav-link">已上传文件弹窗</a></li>
+      <li><a href="#sec-mobile" class="nav-link">移动端适配</a></li>
+      <li><a href="#sec-shortcut" class="nav-link">快捷操作</a></li>
+    </ol>
+  </nav>
 
-  <div class="page">
-    <h3>3. 终端页(<code>/?task=&lt;ID&gt;</code>)</h3>
-    <h3>顶部栏</h3>
-    <ul>
-      <li>连接状态指示灯 + 文本、任务名、工作目录。</li>
-      <li>模型切换下拉(切换后重启会话以应用)。</li>
-      <li>按钮:<span class="tag">↻ 重启会话</span><span class="tag">≡ 列表</span><span class="tag">📋 粘贴</span><span class="tag">🧩 技能</span><span class="tag">❓ 帮助</span><span class="tag">✕ 关闭</span>。</li>
-    </ul>
-    <h3>终端输出区(xterm)</h3>
-    <ul>
-      <li>实时显示 CLI 输出;新输出自动滚动贴底。</li>
-      <li>手动上滑查看历史时,右下角出现 <span class="tag">↓ 回到底部</span> 按钮,点击即回到最新。</li>
-    </ul>
-    <h3>粘贴框(📋)</h3>
-    <ul>
-      <li>文本直接输入;<code>Ctrl+Enter</code> 发送到终端输入行(不自动提交,需自行回车)。</li>
-      <li><b>📎 附件</b>:支持上传 <b>图片 / 视频 / zip / 任意格式</b> 文件,单文件上限 <b>5MB</b>。</li>
-      <li>待发送文件以 chip 展示(图标区分 🖼 图片 / 🎬 视频 / 📦 压缩包 / 📄 其他)。</li>
-      <li>发送规则:图片包装为 <code>&lt;image_local_path&gt;路径&lt;/image_local_path&gt;</code>;其它文件发送其本地路径文本,供命令行引用。</li>
-      <li>粘贴框内“已上传文件”区:列出本次/历史上传文件,可单独删除。</li>
-    </ul>
-    <h3>技能弹窗(🧩,终端页内)</h3>
-    <ul>
-      <li>功能与列表页左侧技能侧边栏一致(上传/更新/编辑/加载/删除)。</li>
-    </ul>
-  </div>
+  <div class="content">
+    <div class="topbar">
+      <div>
+        <h1>Codebuddy Web Console · 设计说明</h1>
+        <div class="sub">通过浏览器远程使用 Codebuddy CLI 的 Web 控制台 — 多会话 / 全局技能 / 文件上传 / 移动端适配</div>
+      </div>
+      <button class="btn-md" onclick="downloadMd()">下载 MD</button>
+      <button class="btn-pdf" onclick="window.print()">下载 PDF</button>
+    </div>
 
-  <div class="page">
-    <h3>4. 已上传文件弹窗</h3>
-    <p>从列表页「📂 已上传文件」按钮打开,以表格管理所有上传文件:</p>
-    <table>
-      <thead><tr><th>列</th><th>说明</th></tr></thead>
-      <tbody>
-        <tr><td>选择框</td><td>每行可勾选;表头“全选”一键勾选全部。</td></tr>
-        <tr><td>文件名</td><td>服务端存储的文件名。</td></tr>
-        <tr><td>上传时间</td><td>文件写入时间(本地时间)。</td></tr>
-        <tr><td>所属会话任务</td><td>上传时所在的终端会话名;历史文件(升级前上传)显示 <code>—</code>。</td></tr>
-        <tr><td>文件类型</td><td>按扩展名/格式显示,如 <code>.png</code> / <code>.zip</code> / <code>.mp4</code>。</td></tr>
-        <tr><td>操作</td><td>单条“删除”。</td></tr>
-      </tbody>
-    </table>
-    <ul>
-      <li><b>批量删除</b>:勾选多个后点“删除所选”(带二次确认)。</li>
-      <li>删除同时从服务端磁盘移除文件,并清理其会话归属索引。</li>
-    </ul>
-  </div>
+    <div class="doc" style="counter-reset: sec;">
 
-  <h2>三、移动端(iPad / 手机)适配</h2>
-  <ul>
-    <li><b>顶栏固定</b>:顶部菜单栏锁定在视口顶部,不随页面滚动;整页只有终端区内部滚动。</li>
-    <li><b>键盘适配</b>:软键盘弹起时,终端区自动缩小到键盘上方,输入内容始终可见。</li>
-    <li><b>自动滚动</b>:CLI 新输出自动贴底,无需手动下拉。</li>
-    <li><b>标签页标题</b>:浏览器标签页显示为 <code>会话名 - CLI</code>(如 <code>Words - CLI</code>),便于多标签区分。</li>
-    <li><b>安全区域</b>:适配刘海与底部指示条(<code>env(safe-area-inset)</code>)。</li>
-  </ul>
-
-  <h2>四、快捷操作</h2>
-  <ul>
-    <li><code>Ctrl+Enter</code>:在粘贴框发送内容。</li>
-    <li><code>Esc</code>:关闭弹窗(粘贴框 / 技能框 / 已上传文件框)。</li>
-    <li>多会话选择 + “全部运行中的会话”:一键把 skill 热加载到多个会话。</li>
-  </ul>
-
-  <h2>五、常见问题</h2>
-  <div class="note">
-    <b>Q:上传视频/zip 提示不支持或过大?</b><br>
-    服务端已内置“任意格式 + 5MB”支持;若仍报错,多为服务端尚未重启生效,重启 <code>codebuddy-web.service</code> 即可。
-  </div>
-  <div class="note">
-    <b>Q:已上传文件的“所属会话任务”是空/—?</b><br>
-    仅“升级后、从终端页新上传”的文件会记录所属会话;升级前上传的历史文件因当时未记录归属,故显示 <code>—</code>,无法反查。
-  </div>
+      <h2 id="sec-arch">整体架构</h2>
+      <ul>
+        <li><b>后端</b>:FastAPI + WebSocket PTY。每个「任务」对应一个独立运行的 <code>codebuddy</code> CLI 进程,经由 PTY 与前端双向通信。</li>
+        <li><b>前端</b>:单文件原生 HTML(无前端框架)。通过 URL 参数区分页面:<code>/</code> 为任务列表页,<code>/?task=&lt;任务ID&gt;</code> 为对应任务的终端页。</li>
+        <li><b>技能</b>:存储于全局目录 <code>~/.codebuddy/skills</code>。上传后对所有会话可见;运行中的会话可「热加载」即时生效,否则下次打开会话时由 codebuddy 自动发现。</li>
+        <li><b>上传文件</b>:统一存于服务端的 <code>data/pastes/</code> 目录,并在「已上传文件」面板中集中管理(含所属会话关联)。</li>
+        <li><b>鉴权</b>:通过访问密码进入,令牌保存于浏览器 <code>localStorage</code>,后续请求经请求头携带。</li>
+      </ul>
+
+      <h2 id="sec-login">登录页</h2>
+      <ul>
+        <li><b>密码输入框</b>:聚焦态自动获得焦点;输入访问密码。</li>
+        <li><b>进入控制台</b>:提交密码进行鉴权;成功后进入任务列表页,并将令牌写入 <code>localStorage</code> 以便后续自动携带。</li>
+        <li><b>错误提示</b>:鉴权失败时显示错误文案,不写入令牌。</li>
+      </ul>
+
+      <h2 id="sec-list">任务列表页</h2>
+
+      <h3 id="sec-list-head">页面头部</h3>
+      <ul>
+        <li><b>标题与计数</b>:显示「任务清单」及当前筛选下的任务数量、暂停数量统计。</li>
+        <li><b>显示全部任务</b>(复选框):默认仅列出「启用中」任务;勾选后展示包含已暂停的全部任务。</li>
+        <li><b>❓ 帮助</b>:新标签页打开本设计说明(<code>/help</code>)。</li>
+        <li><b>退出登录</b>:清除本地令牌并返回登录页。</li>
+      </ul>
+
+      <h3 id="sec-list-skills">技能侧边栏(🧩 技能 · 全局)</h3>
+      <ul>
+        <li><b>上传</b>:选择 skill 的 zip(须以单个 skill 目录为顶层,内含 <code>SKILL.md</code>),上传后对所有会话全局可见。</li>
+        <li><b>技能列表卡片</b>:每张卡片提供 <span class="chip">更新</span><span class="chip">编辑</span><span class="chip">加载</span><span class="chip">删除</span>。
+          <ul>
+            <li><b>更新</b>:用新 zip 覆盖同名 skill(按 zip 内声明名匹配,避免产生重复目录)。</li>
+            <li><b>编辑</b>:直接修改 <code>SKILL.md</code> 内容并保存。</li>
+            <li><b>加载</b>:选择「运行中的会话」注入热加载通知(可多选,或勾选「全部运行中的会话」);若无运行会话则技能已全局就位,下次打开自动发现。</li>
+            <li><b>删除</b>:移除该全局 skill。</li>
+          </ul>
+        </li>
+        <li><b>📂 已上传文件</b>:打开「已上传文件」管理弹窗(见第 5 节)。</li>
+      </ul>
+
+      <h3 id="sec-list-tasks">任务卡片区</h3>
+      <ul>
+        <li><b>卡片字段</b>:任务名、工作目录、最近使用时间、实时状态。</li>
+        <li><b>状态指示</b>:<span class="chip">● 前台已登录</span><span class="chip">⚙ 后台运行中</span><span class="chip">⏸ 后台等待中</span><span class="chip">⏳ 后台任务待确认</span><span class="chip">⏸ 已暂停</span><span class="chip">○ 空闲</span>。</li>
+        <li><b>进入</b>:打开该任务的终端页(<code>/?task=&lt;ID&gt;</code>)。</li>
+        <li><b>暂停 / 恢复</b>:运行中显示「暂停」(⏸),已暂停显示「恢复」(▶);暂停后需满 24 小时才允许删除。</li>
+        <li><b>删除</b>(✕):仅对暂停满 24 小时的任务出现,触发删除确认弹窗。</li>
+        <li><b>+ 新建任务</b>:输入新任务名称(将作为 <code>/home/&lt;名称&gt;</code> 工作目录)以创建会话。</li>
+      </ul>
+      <p class="note">列表页每 5 秒自动刷新一次,实时反映各会话状态。</p>
+
+      <h3 id="sec-list-del">删除确认弹窗</h3>
+      <ul>
+        <li>二次确认后方可删除;「确认删除」按钮初始禁用,需勾选至少一项附加操作后才可点击。</li>
+        <li><b>同时删除历史会话</b>:勾选后清理对话记录。</li>
+        <li><b>同时删除工作目录</b>:勾选后移除 <code>/home/&lt;名称&gt;</code> 目录(显示其路径供核对)。</li>
+      </ul>
+
+      <h2 id="sec-terminal">终端页</h2>
+
+      <h3 id="sec-term-top">顶部操作栏</h3>
+      <ul>
+        <li><b>连接状态</b>:状态指示灯 + 文本,反映当前 PTY 连接状态。</li>
+        <li><b>任务名 / 工作目录</b>:显示当前会话标识。</li>
+        <li><b>模型切换下拉</b>:切换后重启会话以应用新模型。</li>
+        <li><span class="chip">↻ 重启会话</span>:重启当前 PTY 并续接上次历史。</li>
+        <li><span class="chip">≡ 列表</span>:返回任务列表页。</li>
+        <li><span class="chip">📋 粘贴</span>:打开粘贴弹窗。</li>
+        <li><span class="chip">🧩 技能</span>:打开技能弹窗(同列表页技能侧栏功能)。</li>
+        <li><span class="chip">❓ 帮助</span>:新标签页打开本设计说明。</li>
+        <li><span class="chip">✕ 关闭</span>:关闭窗口。</li>
+      </ul>
+
+      <h3 id="sec-term-out">终端输出区(xterm)</h3>
+      <ul>
+        <li>实时显示 CLI 输出;新输出默认自动滚动贴底。</li>
+        <li>手动上滑查看历史时,右下角出现 <span class="chip">↓ 回到底部</span> 按钮,点击即回到最新输出。</li>
+      </ul>
+
+      <h3 id="sec-term-paste">粘贴弹窗(📋)</h3>
+      <ul>
+        <li><b>文本输入</b>:直接输入;<kbd>Ctrl</kbd>+<kbd>Enter</kbd> 发送到终端输入行(不自动提交,需自行回车)。</li>
+        <li><b>📎 附件</b>:支持上传 图片 / 视频 / zip / 任意格式 文件,单文件上限 <b>20MB</b>。</li>
+        <li><b>✕ 清空</b>:清空当前输入与待发送文件。</li>
+        <li><b>发送</b>:将文本与待发送文件一并提交。</li>
+        <li><b>待发送文件 chip</b>:图标区分 🖼 图片 / 🎬 视频 / 📦 压缩包 / 📄 其他。</li>
+        <li><b>发送规则</b>:图片包装为 <code>&lt;image_local_path&gt;路径&lt;/image_local_path&gt;</code>;其它文件发送其本地路径文本,供命令行引用。</li>
+        <li><b>已上传文件区</b>:列出历史/本次上传文件,可单独删除;⟳ 按钮刷新列表。</li>
+      </ul>
 
-  <footer>
-    Codebuddy Web Console · 设计说明(随仓库 <code>frontend/help.html</code> 提供) · 在应用内点「❓ 帮助」可随时打开
-  </footer>
+      <h3 id="sec-term-skills">技能弹窗(🧩,终端页内)</h3>
+      <ul>
+        <li>功能与列表页左侧技能侧边栏一致:上传 / 更新 / 编辑 / 加载 / 删除。</li>
+      </ul>
+
+      <h2 id="sec-uploads">已上传文件弹窗</h2>
+      <p>从列表页「📂 已上传文件」按钮打开,以表格管理所有上传文件:</p>
+      <table>
+        <thead><tr><th>列</th><th>说明</th></tr></thead>
+        <tbody>
+          <tr><td>选择框</td><td>每行可勾选;表头「全选」一键勾选全部。</td></tr>
+          <tr><td>文件名</td><td>服务端存储的文件名。</td></tr>
+          <tr><td>上传时间</td><td>文件写入时间(本地时间)。</td></tr>
+          <tr><td>所属会话任务</td><td>上传时所在的终端会话名;历史文件(升级前上传)显示 <code>—</code>。</td></tr>
+          <tr><td>文件类型</td><td>按扩展名/格式显示,如 <code>.png</code> / <code>.zip</code> / <code>.mp4</code>。</td></tr>
+          <tr><td>操作</td><td>单条「删除」。</td></tr>
+        </tbody>
+      </table>
+      <ul>
+        <li><b>删除所选</b>:勾选多个后点击(带二次确认);初始禁用,至少勾选一项后启用。</li>
+        <li>删除同时从服务端磁盘移除文件,并清理其会话归属索引。</li>
+      </ul>
+
+      <h2 id="sec-mobile">移动端(iPad / 手机)适配</h2>
+      <ul>
+        <li><b>顶栏固定</b>:顶部菜单栏锁定在视口顶部,不随页面滚动;整页只有终端区内部滚动。</li>
+        <li><b>键盘适配</b>:软键盘弹起时,终端区自动缩小到键盘上方,输入内容始终可见。</li>
+        <li><b>自动滚动</b>:CLI 新输出自动贴底,无需手动下拉。</li>
+        <li><b>标签页标题</b>:浏览器标签页显示为 <code>会话名 - CLI</code>(如 <code>Words - CLI</code>),便于多标签区分。</li>
+        <li><b>安全区域</b>:适配刘海与底部指示条(<code>env(safe-area-inset)</code>)。</li>
+      </ul>
+
+      <h2 id="sec-shortcut">快捷操作</h2>
+      <ul>
+        <li><kbd>Ctrl</kbd>+<kbd>Enter</kbd>:在粘贴框发送内容。</li>
+        <li><kbd>Esc</kbd>:关闭弹窗(粘贴框 / 技能框 / 已上传文件框)。</li>
+        <li>多会话选择 + 「全部运行中的会话」:一键把 skill 热加载到多个会话。</li>
+      </ul>
+
+      <footer>
+        Codebuddy Web Console · 设计说明(随仓库 <code>frontend/help.html</code> 提供) · 在应用内点「❓ 帮助」可随时打开
+      </footer>
+    </div>
+  </div>
 </div>
+
+<script>
+  // ---------- DOM → Markdown 转换(单一数据源,避免与正文脱节) ----------
+  function mdInline(node) {
+    var s = '';
+    node.childNodes.forEach(function (n) {
+      if (n.nodeType === 3) { s += n.textContent; return; }
+      if (n.nodeType !== 1) return;
+      var t = n.tagName.toLowerCase();
+      var txt = mdInline(n);
+      if (t === 'code' || t === 'kbd') s += '`' + txt + '`';
+      else if (t === 'span' && n.classList.contains('chip')) s += '`' + txt + '`';
+      else if (t === 'b' || t === 'strong') s += '**' + txt + '**';
+      else if (t === 'a') s += '[' + txt + '](' + (n.getAttribute('href') || '') + ')';
+      else if (t === 'br') s += '\n';
+      else s += txt;
+    });
+    return s;
+  }
+
+  function mdTable(table) {
+    var rows = Array.prototype.slice.call(table.querySelectorAll('tr'));
+    if (!rows.length) return '';
+    var head = Array.prototype.slice.call(rows[0].querySelectorAll('th')).map(function (c) { return mdInline(c).trim(); });
+    var out = '| ' + head.join(' | ') + ' |\n';
+    out += '| ' + head.map(function () { return '---'; }).join(' | ') + ' |\n';
+    for (var i = 1; i < rows.length; i++) {
+      var cells = Array.prototype.slice.call(rows[i].querySelectorAll('td')).map(function (c) {
+        return mdInline(c).trim().replace(/\n/g, ' ');
+      });
+      out += '| ' + cells.join(' | ') + ' |\n';
+    }
+    return out;
+  }
+
+  function docToMd() {
+    var root = document.querySelector('.doc');
+    var lines = [];
+    var h2n = 0;
+    Array.prototype.slice.call(root.children).forEach(function (node) {
+      var tag = node.tagName.toLowerCase();
+      if (tag === 'h2') {
+        h2n++;
+        var title = mdInline(node).replace(/^\s*[\d.]+\.?\s*/, '').trim();
+        lines.push('## ' + h2n + '. ' + title, '');
+      } else if (tag === 'h3') {
+        lines.push('### ' + mdInline(node).replace(/^§\s*/, '').trim(), '');
+      } else if (tag === 'p') {
+        if (node.classList.contains('note')) lines.push('> ' + mdInline(node).replace(/\n+/g, ' ').trim(), '');
+        else lines.push(mdInline(node).trim(), '');
+      } else if (tag === 'div' && node.classList.contains('note')) {
+        mdInline(node).split('\n').forEach(function (l) { lines.push('> ' + l.trim()); });
+        lines.push('');
+      } else if (tag === 'ul') {
+        Array.prototype.slice.call(node.children).forEach(function (li) { lines.push('- ' + mdInline(li).replace(/\n+/g, ' ').trim()); });
+        lines.push('');
+      } else if (tag === 'ol') {
+        var k = 1;
+        Array.prototype.slice.call(node.children).forEach(function (li) { lines.push(k + '. ' + mdInline(li).replace(/\n+/g, ' ').trim()); k++; });
+        lines.push('');
+      } else if (tag === 'table') {
+        lines.push(mdTable(node), '');
+      } else if (tag === 'footer') {
+        lines.push('---', mdInline(node).trim(), '');
+      }
+    });
+    return '# Codebuddy Web Console · 设计说明\n\n' + lines.join('\n');
+  }
+
+  function downloadMd() {
+    var md = docToMd();
+    var blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
+    var url = URL.createObjectURL(blob);
+    var a = document.createElement('a');
+    a.href = url;
+    a.download = 'Codebuddy-Web-Console-设计说明.md';
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    URL.revokeObjectURL(url);
+  }
+
+  // 滚动高亮当前章节
+  (function () {
+    var links = Array.prototype.slice.call(document.querySelectorAll('.nav-link'));
+    var map = {};
+    links.forEach(function (a) {
+      var id = a.getAttribute('href').slice(1);
+      var el = document.getElementById(id);
+      if (el) map[id] = a;
+    });
+    var targets = Object.keys(map).map(function (id) { return document.getElementById(id); });
+    if (!('IntersectionObserver' in window) || !targets.length) return;
+    var observer = new IntersectionObserver(function (entries) {
+      entries.forEach(function (e) {
+        if (e.isIntersecting) {
+          links.forEach(function (l) { l.classList.remove('active'); });
+          var a = map[e.target.id];
+          if (a) a.classList.add('active');
+        }
+      });
+    }, { rootMargin: '0px 0px -70% 0px', threshold: 0 });
+    targets.forEach(function (t) { observer.observe(t); });
+  })();
+</script>
 </body>
 </html>
index ab739c6aec91647944e5be3d84f26191e40406f6..eeddea976311007511b38e6eeb962d38223f0aad 100644 (file)
@@ -845,12 +845,12 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   // 待发送的图片列表:[{path, name}]
   let pendingFiles = [];
 
-  // 上传任意文件到后端,返回 {path, name} 或抛错(上限 5MB)
+  // 上传任意文件到后端,返回 {path, name} 或抛错(上限 20MB)
   async function uploadFile(blob) {
-    const MAX = 5 * 1024 * 1024;
+    const MAX = 20 * 1024 * 1024;
     if (blob.size > MAX) {
       const mb = Math.round(blob.size / 1024 / 1024 * 10) / 10;
-      throw new Error('文件过大(' + mb + 'MB),上限 5MB');
+      throw new Error('文件过大(' + mb + 'MB),上限 20MB');
     }
     const sizeKB = Math.max(1, Math.round(blob.size / 1024));
     const name = blob.name || ('文件_' + Date.now() + '.' + ((blob.type.split('/')[1]) || 'bin'));