用 DeepSeek Search API 做一个干净的单文件搜索页面(附完整源码)

想要搜索结果没有广告吗?想要一个不会被 SEO 垃圾淹没的界面吗?想要 AI 直接帮你把结果读一遍再总结吗?想......好吧我开个玩笑,这个页面不能帮你摆脱房贷,也不能替你上班。

它就是一个搜索页面。一个单 HTML 文件,打开就能用,填个 API Key 就能搜,结果干净、卡片清爽、还带一个可选的 AI 综合回答。功能不多,但每一个都够用。

下面正经介绍。


一、它到底做了什么

核心就两件事:

第一,调 DeepSeek 的 /v1/tools/search 接口 ,拿到结构化的搜索结果,渲染成卡片列表。每条结果包含站点图标、站点名、标题、摘要、日期,以及接口返回的 rerank_score 和 authority 两个字段衍生出来的「优质」「权威」标签。

第二,可选地调 /chat/completions 接口,把搜索结果作为上下文喂给模型,让它生成一段 Markdown 格式的综合回答。默认关闭,需要时在设置里打开。

除了这两个核心,剩下都是围绕「用得舒服」做的小事:

  • 四套界面风格:暗色、极简白、科技蓝、暖砂,设置里点一下即时切换,不用刷新。
  • 配置本地保存 :API Key、结果数量、风格偏好存在浏览器 localStorage,下次打开还在。
  • 快捷键 :⌘K / Ctrl+K 聚焦搜索框,Esc 关闭设置。
  • 骨架屏:搜索期间显示 shimmer 占位,不是干等一个转圈。
  • 可中断:等待时按钮变成「停止」,随时可以取消请求。
  • 流式输出 :AI 回答边生成边显示,带打字光标,引用角标 [1] [2] 自动高亮。

二、四套风格

风格 定位 主色 圆角
暗色 默认,克制 蓝紫 #6e8bff 16px
极简白 纯白 + 胶囊,参考 MiniMax 官网 近黑 #18181b + 品红渐变 9999px
科技蓝 深蓝 + 网格 + 青光,门户感 青蓝 #38bdf8 16px
暖砂 米白低对比,护眼 暖棕 #b45309 14px

所有组件的圆角、阴影、强调色都走 CSS 变量。想加第五套风格,只需要在 :root 下面再写一段 [data-style="xxx"]{ ... } 覆盖变量,组件样式一行都不用动。


三、完整源码

整份代码就是下面这一个 index.html。保存,双击打开,填 Key,开始用。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark" data-style="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DeepSeek Search</title>
<style>
  /* ============================================================
     风格变量层
     每套风格只覆盖这些 token,组件 CSS 全部引用变量
     ============================================================ */

  /* 暗色(默认) */
  :root, [data-style="dark"]{
    --bg:#0a0a0c;
    --surface:#111114;
    --surface-2:#17171b;
    --surface-3:#1e1e24;
    --border:rgba(255,255,255,.065);
    --border-hover:rgba(255,255,255,.14);
    --text:#ededf0;
    --text-2:#a0a0ab;
    --text-3:#5c5c68;
    --accent:#6e8bff;
    --accent-soft:rgba(110,139,255,.12);
    --accent-border:rgba(110,139,255,.35);
    --accent-gradient:linear-gradient(180deg,#7b95ff,#5f7df0);
    --green:#3ecf8e;
    --amber:#f5a623;
    --radius:16px;
    --radius-sm:11px;
    --ease:cubic-bezier(.4,0,.2,1);
    --spring:cubic-bezier(.34,1.3,.64,1);
    --shadow-card:0 1px 2px rgba(0,0,0,.3),0 8px 32px -12px rgba(0,0,0,.5);
    --shadow-float:0 24px 80px -20px rgba(0,0,0,.8);
    --ambient:
      radial-gradient(720px 480px at 18% -6%, rgba(110,139,255,.09), transparent 64%),
      radial-gradient(600px 400px at 84% 4%, rgba(110,139,255,.05), transparent 64%),
      radial-gradient(800px 600px at 50% 110%, rgba(110,139,255,.04), transparent 64%);
  }

  /* 极简白 · MiniMax 路线 */
  [data-style="minimax"]{
    --bg:#ffffff;
    --surface:#ffffff;
    --surface-2:#f7f7f9;
    --surface-3:#efeff3;
    --border:rgba(0,0,0,.075);
    --border-hover:rgba(0,0,0,.16);
    --text:#18181b;
    --text-2:#52525b;
    --text-3:#a1a1aa;
    --accent:#18181b;
    --accent-soft:rgba(234,94,193,.08);
    --accent-border:rgba(234,94,193,.35);
    --accent-gradient:linear-gradient(180deg,#2a2a2e,#0f0f11);
    --green:#0d9488;
    --amber:#d97706;
    --radius:9999px;
    --radius-sm:12px;
    --shadow-card:0 1px 2px rgba(44,30,116,.04),0 8px 28px -12px rgba(44,30,116,.10);
    --shadow-float:0 24px 70px -18px rgba(44,30,116,.22);
    --ambient:
      radial-gradient(640px 420px at 78% -10%, rgba(234,94,193,.06), transparent 62%),
      radial-gradient(560px 380px at 12% 6%, rgba(20,86,240,.05), transparent 62%);
  }
  [data-style="minimax"] .search-btn,
  [data-style="minimax"] .primary-btn{
    border-radius:9999px;
    background:var(--accent-gradient);
    box-shadow:0 1px 2px rgba(0,0,0,.12),inset 0 1px 0 rgba(255,255,255,.14);
  }
  [data-style="minimax"] .search-box{
    border-radius:9999px;
    padding-left:20px;
  }
  [data-style="minimax"] .chip{
    border-radius:9999px;
  }
  [data-style="minimax"] .result-card{
    border-radius:20px;
  }
  [data-style="minimax"] .result-card:hover{
    border-color:rgba(234,94,193,.28);
    box-shadow:0 1px 2px rgba(44,30,116,.04),0 14px 36px -14px rgba(234,94,193,.18);
  }
  [data-style="minimax"] .result-card:hover .result-title{
    color:#ea5ec1;
  }
  [data-style="minimax"] .brand-mark{
    background:linear-gradient(135deg,#ff5530,#ea5ec1,#a855f7);
    box-shadow:0 0 20px rgba(234,94,193,.28);
  }
  [data-style="minimax"] .answer{
    border-radius:22px;
  }
  [data-style="minimax"] .badge.auth{
    background:rgba(234,94,193,.09);
    color:#ea5ec1;
  }
  [data-style="minimax"] .cite{
    background:rgba(234,94,193,.1);
    color:#ea5ec1;
  }
  [data-style="minimax"] .caret{
    background:#ea5ec1;
  }
  [data-style="minimax"] .answer-head::before{
    background:#ea5ec1;
    box-shadow:0 0 8px rgba(234,94,193,.5);
  }

  /* 科技蓝 · 参考站门户路线 */
  [data-style="tech"]{
    --bg:#081226;
    --surface:#0d1b33;
    --surface-2:#122442;
    --surface-3:#1a3054;
    --border:rgba(125,211,252,.10);
    --border-hover:rgba(125,211,252,.24);
    --text:#eef6ff;
    --text-2:#9ab3cf;
    --text-3:#5c7898;
    --accent:#38bdf8;
    --accent-soft:rgba(56,189,248,.10);
    --accent-border:rgba(56,189,248,.35);
    --accent-gradient:linear-gradient(180deg,#38bdf8,#0ea5e9);
    --green:#34d399;
    --amber:#fbbf24;
    --shadow-card:0 1px 2px rgba(0,0,0,.4),0 8px 32px -12px rgba(0,0,0,.6);
    --shadow-float:0 24px 80px -20px rgba(0,0,0,.8);
    --ambient:
      radial-gradient(900px 480px at 10% -12%, rgba(56,189,248,.18), transparent 60%),
      radial-gradient(760px 420px at 90% 0%, rgba(129,140,248,.14), transparent 60%),
      linear-gradient(180deg, rgba(11,30,60,.0), rgba(8,18,38,.6));
  }
  [data-style="tech"] .ambient::after{
    content:'';
    position:absolute;inset:0;
    opacity:.4;
    background-image:
      linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
      linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px);
    background-size:44px 44px;
    mask-image:radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 75%);
  }
  [data-style="tech"] .brand-mark{
    background:linear-gradient(135deg,#38bdf8,#818cf8);
    box-shadow:0 0 22px rgba(56,189,248,.45);
  }
  [data-style="tech"] .hero-title span{
    background:linear-gradient(90deg,#7dd3fc,#c4b5fd,#f0abfc);
    -webkit-background-clip:text;background-clip:text;color:transparent;
  }
  [data-style="tech"] .search-box{
    background:rgba(13,27,51,.72);
    backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
  }
  [data-style="tech"] .result-card:hover{
    border-color:rgba(56,189,248,.35);
    box-shadow:0 0 0 1px rgba(56,189,248,.12),0 12px 40px -14px rgba(56,189,248,.25);
  }
  [data-style="tech"] .badge.good{
    background:rgba(52,211,153,.1);color:#34d399;
  }

  /* 暖砂 · 中性低对比 */
  [data-style="warm"]{
    --bg:#faf8f5;
    --surface:#fffdfa;
    --surface-2:#f4f0e9;
    --surface-3:#ebe5da;
    --border:rgba(120,100,80,.10);
    --border-hover:rgba(120,100,80,.22);
    --text:#2c241b;
    --text-2:#6b5f52;
    --text-3:#a89a88;
    --accent:#b45309;
    --accent-soft:rgba(180,83,9,.07);
    --accent-border:rgba(180,83,9,.3);
    --accent-gradient:linear-gradient(180deg,#c2701a,#a34a06);
    --green:#4d7c0f;
    --amber:#b45309;
    --radius:14px;
    --radius-sm:10px;
    --shadow-card:0 1px 2px rgba(80,60,40,.04),0 8px 28px -12px rgba(80,60,40,.08);
    --shadow-float:0 24px 70px -18px rgba(80,60,40,.18);
    --ambient:
      radial-gradient(640px 420px at 80% -10%, rgba(180,83,9,.05), transparent 62%),
      radial-gradient(520px 360px at 15% 8%, rgba(77,124,15,.04), transparent 62%);
  }
  [data-style="warm"] .search-btn,
  [data-style="warm"] .primary-btn{
    color:#fff;
  }
  [data-style="warm"] .brand-mark{
    background:linear-gradient(135deg,#d97706,#92400e);
    box-shadow:0 0 18px rgba(180,83,9,.25);
  }

  /* ============================================================
     基础
     ============================================================ */
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
    background:var(--bg);
    color:var(--text);
    -webkit-font-smoothing:antialiased;
    -moz-osx-font-smoothing:grayscale;
    font-feature-settings:"cv02","cv03","cv04","ss01","tnum";
    overflow-x:hidden;
    transition:background .35s var(--ease),color .35s var(--ease);
  }
  .ambient{
    position:fixed;inset:0;pointer-events:none;z-index:0;
    background:var(--ambient);
    transition:background .45s var(--ease),opacity .4s var(--ease);
  }
  [data-style="tech"] .ambient{position:fixed}
  .shell{max-width:780px;margin:0 auto;padding:0 24px;position:relative;z-index:1}

  /* 顶栏 */
  .top{display:flex;align-items:center;justify-content:space-between;padding:18px 0 0}
  .brand{display:flex;align-items:center;gap:9px;font-size:13.5px;font-weight:600;letter-spacing:-.01em}
  .brand-mark{
    width:22px;height:22px;border-radius:6px;flex:none;position:relative;
    background:linear-gradient(135deg,#6e8bff,#9b8bff);
    box-shadow:0 0 18px rgba(110,139,255,.4),inset 0 1px 0 rgba(255,255,255,.3);
    transition:background .35s var(--ease),box-shadow .35s var(--ease);
  }
  .brand-mark::after{
    content:'';position:absolute;inset:0;border-radius:6px;
    background:linear-gradient(180deg,rgba(255,255,255,.25),transparent 55%);
  }
  .brand em{font-style:normal;color:var(--text-3);font-weight:500}
  .actions{display:flex;gap:4px}
  .icon-btn{
    width:32px;height:32px;border-radius:8px;border:none;background:transparent;
    color:var(--text-2);cursor:pointer;display:flex;align-items:center;justify-content:center;
    transition:all .16s var(--ease);
  }
  .icon-btn:hover{background:var(--surface-2);color:var(--text)}
  .icon-btn:active{transform:scale(.9)}
  .icon-btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}

  /* 主区 */
  .main{min-height:calc(100vh - 52px);display:flex;flex-direction:column}
  .hero{flex:1;display:flex;flex-direction:column;justify-content:center;padding-bottom:8vh;transition:flex .5s var(--ease),padding .5s var(--ease)}
  .main.active .hero{flex:0 0 auto;padding:16px 0 24px}

  .hero-title{
    font-size:clamp(26px,4.2vw,34px);
    font-weight:600;letter-spacing:-.03em;line-height:1.2;
    text-align:center;margin-bottom:26px;
    background:linear-gradient(180deg,var(--text) 30%,var(--text-3));
    -webkit-background-clip:text;background-clip:text;color:transparent;
    transition:opacity .25s var(--ease),transform .4s var(--ease),height .45s var(--ease),margin .45s var(--ease);
  }
  .main.active .hero-title{opacity:0;transform:translateY(-8px);height:0;margin:0;pointer-events:none;overflow:hidden}

  /* 搜索框 */
  .search-box{
    display:flex;align-items:center;gap:9px;
    background:var(--surface);
    border:1px solid var(--border);
    border-radius:var(--radius);
    padding:5px 5px 5px 16px;
    box-shadow:var(--shadow-card);
    transition:border-color .2s var(--ease),box-shadow .25s var(--ease),background .2s var(--ease),border-radius .3s var(--ease);
  }
  .search-box:focus-within{
    border-color:var(--accent-border);
    box-shadow:0 0 0 3px var(--accent-soft),var(--shadow-card);
  }
  .search-icon{width:17px;height:17px;flex:none;stroke:var(--text-3);fill:none;stroke-width:2;stroke-linecap:round;transition:stroke .2s}
  .search-box:focus-within .search-icon{stroke:var(--accent)}
  .search-input{flex:1;border:none;outline:none;background:transparent;font-size:15px;color:var(--text);padding:10px 0;font-family:inherit;letter-spacing:-.01em;min-width:0}
  .search-input::placeholder{color:var(--text-3)}
  .kbd{
    font-family:inherit;font-size:10.5px;font-weight:500;color:var(--text-3);
    border:1px solid var(--border);border-radius:5px;padding:2px 5px;
    background:var(--surface-2);flex:none;transition:opacity .15s;letter-spacing:.02em;
  }
  .search-box:focus-within .kbd{opacity:0}
  .search-btn{
    flex:none;border:none;cursor:pointer;font-family:inherit;
    font-size:13px;font-weight:600;letter-spacing:-.01em;
    color:#fff;padding:0 18px;height:36px;border-radius:10px;
    background:var(--accent-gradient);
    box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.15);
    transition:all .16s var(--ease);white-space:nowrap;
  }
  .search-btn:hover{filter:brightness(1.08)}
  .search-btn:active{transform:scale(.97)}
  .search-btn.stop{background:linear-gradient(180deg,#ef4444,#dc2626)}

  .chips{display:flex;gap:7px;flex-wrap:wrap;justify-content:center;margin-top:18px;transition:opacity .25s var(--ease),height .35s var(--ease),margin .35s var(--ease)}
  .main.active .chips{opacity:0;height:0;overflow:hidden;margin:0;pointer-events:none}
  .chip{
    font-family:inherit;font-size:12px;color:var(--text-2);cursor:pointer;
    background:transparent;border:1px solid var(--border);
    padding:6px 12px;border-radius:999px;
    transition:all .16s var(--ease);
  }
  .chip:hover{color:var(--text);border-color:var(--border-hover);background:var(--surface-2)}

  /* 内容 */
  .content{display:none;padding-bottom:70px;animation:rise .4s var(--ease)}
  .main.active .content{display:block}
  @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}

  .notice{display:none;font-size:12px;line-height:1.55;padding:9px 13px;border-radius:9px;margin-bottom:12px;background:rgba(245,166,35,.07);border:1px solid rgba(245,166,35,.18);color:var(--amber)}
  .notice.visible{display:block}
  .error-box{display:none;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;padding:9px 13px;border-radius:9px;margin-bottom:12px;background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.18);color:#f87171}
  .error-box.visible{display:block}

  /* 回答 */
  .answer{
    display:none;position:relative;
    background:var(--surface);border:1px solid var(--border);
    border-radius:var(--radius);padding:20px 22px;margin-bottom:18px;
    box-shadow:var(--shadow-card);
    transition:border-radius .3s var(--ease);
  }
  .answer.visible{display:block;animation:rise .4s var(--ease)}
  .answer-head{display:flex;align-items:center;gap:7px;margin-bottom:13px;font-size:10.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3)}
  .answer-head::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);animation:pulse 2s ease-in-out infinite}
  @keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
  .answer-body{font-size:14.5px;line-height:1.75;color:var(--text);word-break:break-word}
  .answer-body p{margin:0 0 11px}.answer-body p:last-child{margin-bottom:0}
  .answer-body h3{font-size:14.5px;font-weight:600;margin:16px 0 7px}
  .answer-body ul{margin:7px 0 11px;padding-left:18px}
  .answer-body li{margin:3px 0;color:var(--text-2)}
  .answer-body strong{font-weight:600;color:var(--text)}
  .answer-body code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;background:var(--accent-soft);color:var(--accent);padding:1.5px 4px;border-radius:4px}
  .answer-body pre{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:13px 15px;margin:11px 0;overflow-x:auto}
  .answer-body pre code{background:none;color:var(--text-2);padding:0;font-size:12px;line-height:1.6}
  .cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;margin:0 1px;border-radius:4px;font-size:10px;font-weight:700;background:var(--accent-soft);color:var(--accent);vertical-align:1.5px;font-variant-numeric:tabular-nums}
  .caret{display:inline-block;width:2px;height:1em;background:var(--accent);vertical-align:-2px;margin-left:1px;border-radius:1px;animation:blink 1.05s steps(2) infinite}
  @keyframes blink{0%,50%{opacity:1}50.1%,100%{opacity:0}}

  /* 结果头 */
  .results-head{display:none;align-items:center;justify-content:space-between;margin-bottom:10px;padding:0 1px}
  .results-head.visible{display:flex}
  .results-title{font-size:10.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3)}
  .results-count{font-size:11px;color:var(--text-3);font-variant-numeric:tabular-nums}

  /* 结果卡片 */
  .result-list{display:flex;flex-direction:column;gap:8px}
  .result-card{
    display:flex;gap:12px;
    background:var(--surface);border:1px solid var(--border);
    border-radius:13px;padding:15px 16px;
    text-decoration:none;color:inherit;
    transition:border-color .18s var(--ease),background .18s var(--ease),transform .18s var(--ease),box-shadow .18s var(--ease),border-radius .3s var(--ease);
    position:relative;overflow:hidden;
    animation:rise .35s var(--ease) backwards;
  }
  .result-card:hover{border-color:var(--border-hover);background:var(--surface-2);transform:translateY(-1px);box-shadow:var(--shadow-card)}
  .result-card.is-disabled{cursor:default}
  .result-card.is-disabled:hover{transform:none}
  .result-icon{
    flex:none;width:24px;height:24px;border-radius:6px;margin-top:1px;
    background:var(--surface-2);border:1px solid var(--border);
    display:flex;align-items:center;justify-content:center;
    overflow:hidden;font-size:10px;font-weight:600;color:var(--text-3);
  }
  .result-icon img{width:100%;height:100%;object-fit:contain}
  .result-main{flex:1;min-width:0}
  .result-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:11px;color:var(--text-3);margin-bottom:4px}
  .result-site{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}
  .dot{width:2.5px;height:2.5px;border-radius:50%;background:var(--text-3);opacity:.5;flex:none}
  .badge{font-size:9.5px;font-weight:600;padding:1px 5px;border-radius:4px;letter-spacing:.02em}
  .badge.good{background:rgba(62,207,142,.1);color:var(--green)}
  .badge.auth{background:var(--accent-soft);color:var(--accent)}
  .result-title{
    font-size:14.5px;font-weight:600;line-height:1.4;color:var(--text);
    margin-bottom:4px;letter-spacing:-.01em;
    display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;
    transition:color .16s;
  }
  .result-card:hover .result-title{color:var(--accent)}
  .result-card.is-disabled:hover .result-title{color:var(--text)}
  .result-snippet{font-size:12.5px;line-height:1.6;color:var(--text-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}

  /* 骨架 */
  .skeleton{display:flex;gap:12px;padding:15px 16px;background:var(--surface);border:1px solid var(--border);border-radius:13px}
  .sk{background:linear-gradient(90deg,var(--surface-2) 25%,var(--surface-3) 50%,var(--surface-2) 75%);background-size:200% 100%;animation:shimmer 1.4s infinite;border-radius:5px}
  @keyframes shimmer{to{background-position:-200% 0}}
  .sk-icon{width:24px;height:24px;flex:none;border-radius:6px}
  .sk-body{flex:1;display:flex;flex-direction:column;gap:7px}
  .sk-line{height:10px}.sk-line.short{width:30%}.sk-line.med{width:65%}

  /* 模态 */
  .modal-backdrop{
    position:fixed;inset:0;z-index:100;padding:20px;
    background:rgba(0,0,0,.55);
    backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
    display:flex;align-items:center;justify-content:center;
    opacity:0;pointer-events:none;transition:opacity .2s var(--ease);
  }
  .modal-backdrop.open{opacity:1;pointer-events:auto}
  .modal{
    width:100%;max-width:430px;max-height:86vh;overflow-y:auto;
    background:var(--surface);
    border:1px solid var(--border);border-radius:18px;padding:24px;
    box-shadow:var(--shadow-float);
    transform:scale(.97) translateY(6px);
    transition:transform .3s var(--spring),border-radius .3s var(--ease);
  }
  .modal-backdrop.open .modal{transform:scale(1) translateY(0)}
  .modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
  .modal-title{font-size:14px;font-weight:600;letter-spacing:-.01em}
  .modal-close{width:26px;height:26px;border-radius:6px;border:none;background:transparent;color:var(--text-2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s}
  .modal-close:hover{background:var(--surface-2);color:var(--text)}
  .modal-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;pointer-events:none}

  .field{margin-bottom:15px}
  .field label{display:block;font-size:11px;font-weight:500;color:var(--text-2);margin-bottom:6px;letter-spacing:.01em}
  .field input,.field select{
    width:100%;font-family:inherit;padding:9px 11px;font-size:13px;
    color:var(--text);background:var(--surface-2);
    border:1px solid var(--border);border-radius:8px;outline:none;
    transition:border-color .16s var(--ease),box-shadow .16s var(--ease);
  }
  .field input:focus,.field select:focus{border-color:var(--accent-border);box-shadow:0 0 0 3px var(--accent-soft)}
  .field .hint{font-size:10.5px;color:var(--text-3);margin-top:5px;line-height:1.5}

  /* 风格选择器 */
  .style-grid{
    display:grid;
    grid-template-columns:repeat(4,1fr);
    gap:7px;
    margin-top:2px;
  }
  .style-option{
    position:relative;
    display:flex;flex-direction:column;align-items:center;gap:6px;
    padding:10px 4px 8px;
    border:1px solid var(--border);
    border-radius:10px;
    background:var(--surface-2);
    cursor:pointer;
    transition:all .16s var(--ease);
    font-family:inherit;
  }
  .style-option:hover{border-color:var(--border-hover)}
  .style-option.active{
    border-color:var(--accent-border);
    box-shadow:0 0 0 2px var(--accent-soft);
  }
  .style-swatch{
    width:26px;height:26px;border-radius:7px;flex:none;
    box-shadow:inset 0 0 0 1px rgba(128,128,128,.15);
    position:relative;overflow:hidden;
  }
  .style-swatch.dark-swatch{background:linear-gradient(135deg,#18181b 50%,#3b3b42 50%)}
  .style-swatch.minimax-swatch{background:linear-gradient(135deg,#ffffff 45%,#ea5ec1 100%)}
  .style-swatch.tech-swatch{background:linear-gradient(135deg,#0b1e3c 45%,#38bdf8 100%)}
  .style-swatch.warm-swatch{background:linear-gradient(135deg,#f4f0e9 45%,#b45309 100%)}
  .style-name{font-size:10.5px;font-weight:500;color:var(--text-2);letter-spacing:.01em}
  .style-option.active .style-name{color:var(--accent)}
  .style-check{
    position:absolute;top:5px;right:5px;
    width:12px;height:12px;border-radius:50%;
    background:var(--accent);
    display:flex;align-items:center;justify-content:center;
    opacity:0;transform:scale(.5);
    transition:opacity .15s,transform .2s var(--spring);
  }
  .style-option.active .style-check{opacity:1;transform:scale(1)}
  .style-check svg{width:7px;height:7px;stroke:#fff;fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}

  /* 开关 */
  .switch-row{display:flex;align-items:center;justify-content:space-between;padding:11px 0;border-top:1px solid var(--border);margin-top:4px}
  .switch-label{font-size:12px;font-weight:500;color:var(--text)}
  .switch-desc{font-size:10.5px;color:var(--text-3);margin-top:2px;line-height:1.45}
  .switch{position:relative;width:36px;height:20px;flex:none;cursor:pointer}
  .switch input{opacity:0;width:0;height:0;position:absolute}
  .switch-track{position:absolute;inset:0;border-radius:999px;background:var(--surface-3);border:1px solid var(--border);transition:background .2s var(--ease),border-color .2s var(--ease)}
  .switch-track::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-3);transition:transform .2s var(--spring),background .2s}
  .switch input:checked + .switch-track{background:var(--accent);border-color:var(--accent)}
  .switch input:checked + .switch-track::after{transform:translateX(16px);background:#fff}

  .modal-foot{display:flex;align-items:center;gap:10px;margin-top:20px}
  .primary-btn{
    font-family:inherit;font-size:12.5px;font-weight:600;color:#fff;
    padding:9px 18px;border-radius:8px;border:none;cursor:pointer;
    background:var(--accent-gradient);
    box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.15);
    transition:all .16s var(--ease);
  }
  .primary-btn:hover{filter:brightness(1.08)}
  .primary-btn:active{transform:scale(.97)}
  .save-status{font-size:11px;color:var(--green);opacity:0;transition:opacity .2s}
  .save-status.show{opacity:1}

  .model-fields{max-height:0;overflow:hidden;transition:max-height .35s var(--ease),opacity .25s var(--ease),margin .25s var(--ease);opacity:0;margin:0}
  .model-fields.open{max-height:200px;opacity:1;margin-top:4px}

  @media(max-width:600px){
    .shell{padding:0 16px}
    .kbd{display:none}
    .search-btn{padding:0 15px;height:34px;font-size:12.5px}
    .search-box{padding:4px 4px 4px 14px;border-radius:13px}
    .answer{padding:17px 18px;border-radius:14px}
    .hero-title{margin-bottom:22px}
    .result-card{padding:13px 14px}
    .style-grid{grid-template-columns:repeat(2,1fr)}
  }
</style>
</head>
<body>
<div class="ambient"></div>

<div class="shell">
  <header class="top">
    <div class="brand"><div class="brand-mark"></div><span>DeepSeek <em>Search</em></span></div>
    <div class="actions">
      <button type="button" class="icon-btn" id="themeBtn" title="切换明暗">
        <svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
      </button>
      <button type="button" class="icon-btn" id="settingsBtn" title="设置">
        <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.36.44.65.8.78H21a2 2 0 0 1 0 4h-.09c-.36.13-.66.42-.8.78z"/></svg>
      </button>
    </div>
  </header>

  <main class="main" id="main">
    <div class="hero">
      <h1 class="hero-title">搜索,然后理解</h1>
      <div class="search-box">
        <svg class="search-icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
        <input class="search-input" id="searchInput" placeholder="搜索或输入问题..." autocomplete="off" spellcheck="false">
        <kbd class="kbd">⌘K</kbd>
        <button type="button" class="search-btn" id="searchBtn">搜索</button>
      </div>
      <div class="chips">
        <button type="button" class="chip" data-q="DeepSeek 最新模型发布">DeepSeek 新模型</button>
        <button type="button" class="chip" data-q="今天北京天气">今日天气</button>
        <button type="button" class="chip" data-q="2026 年 AI 行业重要进展">AI 行业进展</button>
        <button type="button" class="chip" data-q="Python 3.14 新特性">Python 3.14</button>
      </div>
    </div>

    <div class="content" id="content">
      <div class="notice" id="noticeBox"></div>
      <div class="error-box" id="errorBox"></div>

      <div class="answer" id="answerCard">
        <div class="answer-head">综合回答</div>
        <div class="answer-body" id="answerBody"></div>
      </div>

      <div class="results-head" id="resultsHead">
        <span class="results-title">搜索结果</span>
        <span class="results-count" id="resultsCount"></span>
      </div>
      <div class="result-list" id="resultList"></div>
    </div>
  </main>
</div>

<!-- 设置 -->
<div class="modal-backdrop" id="modal">
  <div class="modal">
    <div class="modal-head">
      <div class="modal-title">设置</div>
      <button type="button" class="modal-close" id="modalClose">
        <svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>
      </button>
    </div>

    <div class="field">
      <label>API Key</label>
      <input type="password" id="apiKeyInput" placeholder="sk-..." autocomplete="off">
      <div class="hint">仅保存在你的浏览器里,不会上传到任何服务器。</div>
    </div>

    <div class="field">
      <label>界面风格</label>
      <div class="style-grid" id="styleGrid">
        <button type="button" class="style-option" data-style="dark">
          <div class="style-swatch dark-swatch"></div>
          <span class="style-name">暗色</span>
          <span class="style-check"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></span>
        </button>
        <button type="button" class="style-option" data-style="minimax">
          <div class="style-swatch minimax-swatch"></div>
          <span class="style-name">极简白</span>
          <span class="style-check"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></span>
        </button>
        <button type="button" class="style-option" data-style="tech">
          <div class="style-swatch tech-swatch"></div>
          <span class="style-name">科技蓝</span>
          <span class="style-check"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></span>
        </button>
        <button type="button" class="style-option" data-style="warm">
          <div class="style-swatch warm-swatch"></div>
          <span class="style-name">暖砂</span>
          <span class="style-check"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></span>
        </button>
      </div>
      <div class="hint">切换即时生效,不用保存。</div>
    </div>

    <div class="field">
      <label>每次结果数量</label>
      <select id="countSelect">
        <option value="5">5 条</option>
        <option value="10" selected>10 条</option>
        <option value="20">20 条</option>
        <option value="30">30 条</option>
        <option value="50">50 条</option>
      </select>
    </div>

    <div class="switch-row">
      <div>
        <div class="switch-label">AI 综合回答</div>
        <div class="switch-desc">开启后,基于搜索结果生成一段综合回答。关闭则只显示结果列表。</div>
      </div>
      <label class="switch">
        <input type="checkbox" id="modelToggle">
        <span class="switch-track"></span>
      </label>
    </div>

    <div class="model-fields" id="modelFields">
      <div class="field">
        <label>模型</label>
        <input type="text" id="modelInput" list="modelList" value="deepseek-chat">
        <datalist id="modelList">
          <option value="deepseek-chat">
          <option value="deepseek-reasoner">
        </datalist>
      </div>
    </div>

    <div class="modal-foot">
      <button type="button" class="primary-btn" id="saveBtn">保存</button>
      <span class="save-status" id="saveStatus">已保存</span>
    </div>
  </div>
</div>

<script>
(function(){
  "use strict";

  function log(){ try{ console.log.apply(console, ["[DS]"].concat([].slice.call(arguments))); }catch(_){} }

  function showFatal(msg){
    log("FATAL:", msg);
    try{
      var box = document.getElementById("errorBox");
      if(box){
        box.textContent = "脚本错误:" + msg + "\n(F12 查看控制台)";
        box.classList.add("visible");
        box.style.display = "block";
      }
    }catch(_){}
  }

  window.addEventListener("error", function(e){
    showFatal((e && (e.message || String(e.error))) || "未知错误");
  });

  var store = {
    get: function(k){ try{ return localStorage.getItem(k); }catch(e){ log("storage.get 失败:", e.message); return null; } },
    set: function(k,v){ try{ localStorage.setItem(k,v); }catch(e){ log("storage.set 失败:", e.message); } }
  };

  function onClick(el, fn){
    if(!el) return;
    el.addEventListener("click", function(e){
      try{ fn(e); }catch(err){ showFatal("点击处理出错: " + err.message); }
    });
  }

  function ready(fn){
    if(document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn);
    else fn();
  }

  ready(function(){
    try{ init(); }catch(e){ showFatal(e.message || String(e)); }
  });

  function init(){
    var $ = function(s){ return document.querySelector(s); };
    var $$ = function(s){ return Array.prototype.slice.call(document.querySelectorAll(s)); };

    var els = {
      main: $("#main"),
      searchInput: $("#searchInput"),
      searchBtn: $("#searchBtn"),
      answerCard: $("#answerCard"),
      answerBody: $("#answerBody"),
      resultsHead: $("#resultsHead"),
      resultsCount: $("#resultsCount"),
      resultList: $("#resultList"),
      noticeBox: $("#noticeBox"),
      errorBox: $("#errorBox"),
      modal: $("#modal"),
      themeBtn: $("#themeBtn"),
      settingsBtn: $("#settingsBtn"),
      modalClose: $("#modalClose"),
      saveBtn: $("#saveBtn"),
      saveStatus: $("#saveStatus"),
      apiKeyInput: $("#apiKeyInput"),
      countSelect: $("#countSelect"),
      modelToggle: $("#modelToggle"),
      modelFields: $("#modelFields"),
      modelInput: $("#modelInput"),
      styleGrid: $("#styleGrid"),
      styleOptions: $$(".style-option")
    };

    var missing = [];
    for(var k in els){ if(!els[k]) missing.push(k); }
    if(missing.length){ showFatal("关键元素缺失: " + missing.join(", ")); return; }

    var STORE_KEY = "ds_search_v6";
    var controller = null;
    var busy = false;
    var currentStyle = "dark";

    /* ============================================================
       安全:URL 白名单校验
       只放行 http / https。javascript:、data:、vbscript:、
       blob:、file: 等一律返回空串,由调用方做降级处理。
       ============================================================ */
    function safeUrl(u){
      if(!u || typeof u !== "string") return "";
      try{
        var p = new URL(u, location.href);
        if(p.protocol === "http:" || p.protocol === "https:"){
          return p.href;
        }
      }catch(_){}
      return "";
    }

    /* ============================================================
       风格切换
       ============================================================ */
    function applyStyle(name){
      document.documentElement.setAttribute("data-style", name);
      currentStyle = name;
      els.styleOptions.forEach(function(btn){
        btn.classList.toggle("active", btn.getAttribute("data-style") === name);
      });
      var isDark = (name === "dark" || name === "tech");
      document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light");
      store.set(STORE_KEY + "_style", name);
      log("风格切换为:", name);
    }

    (function initStyle(){
      try{
        var saved = store.get(STORE_KEY + "_style") || "dark";
        applyStyle(saved);

        els.styleOptions.forEach(function(btn){
          onClick(btn, function(){
            applyStyle(btn.getAttribute("data-style"));
          });
        });
      }catch(e){ showFatal("风格初始化失败: " + e.message); }
    })();

    /* ============================================================
       明暗快捷切换
       ============================================================ */
    (function initTheme(){
      try{
        onClick(els.themeBtn, function(){
          var next;
          if(currentStyle === "dark") next = "minimax";
          else if(currentStyle === "minimax") next = "dark";
          else if(currentStyle === "tech") next = "warm";
          else next = "tech";
          applyStyle(next);
        });
      }catch(e){ showFatal("主题初始化失败: " + e.message); }
    })();

    /* ============================================================
       设置
       ============================================================ */
    (function initSettings(){
      try{
        var raw = store.get(STORE_KEY);
        if(raw){
          try{
            var p = JSON.parse(raw);
            if(p.apiKey) els.apiKeyInput.value = p.apiKey;
            if(p.count) els.countSelect.value = String(p.count);
            if(p.model) els.modelInput.value = p.model;
            if(p.useModel === true){
              els.modelToggle.checked = true;
              els.modelFields.classList.add("open");
            }
          }catch(_){}
        }

        els.modelToggle.addEventListener("change", function(){
          els.modelFields.classList.toggle("open", els.modelToggle.checked);
        });

        onClick(els.settingsBtn, function(){ els.modal.classList.add("open"); });
        onClick(els.modalClose, function(){ els.modal.classList.remove("open"); });
        onClick(els.modal, function(e){ if(e.target === els.modal) els.modal.classList.remove("open"); });

        onClick(els.saveBtn, function(){
          store.set(STORE_KEY, JSON.stringify({
            apiKey: els.apiKeyInput.value.trim(),
            count: parseInt(els.countSelect.value, 10) || 10,
            useModel: els.modelToggle.checked,
            model: els.modelInput.value.trim() || "deepseek-chat"
          }));
          els.saveStatus.classList.add("show");
          setTimeout(function(){ els.saveStatus.classList.remove("show"); }, 1600);
        });
      }catch(e){ showFatal("设置初始化失败: " + e.message); }
    })();

    /* ============================================================
       配置 / 工具
       ============================================================ */
    function getConfig(){
      var key = els.apiKeyInput.value.trim();
      if(!key){
        showErr("请先在设置中填入 API Key。");
        els.modal.classList.add("open");
        return null;
      }
      return {
        key: key,
        count: parseInt(els.countSelect.value, 10) || 10,
        useModel: els.modelToggle.checked,
        model: els.modelInput.value.trim() || "deepseek-chat"
      };
    }

    function showErr(msg){ els.errorBox.textContent = msg; els.errorBox.classList.add("visible"); }
    function clearErr(){ els.errorBox.classList.remove("visible"); els.errorBox.textContent = ""; }
    function showNotice(msg){ els.noticeBox.textContent = msg; els.noticeBox.classList.add("visible"); }
    function clearNotice(){ els.noticeBox.classList.remove("visible"); els.noticeBox.textContent = ""; }

    function setBusy(v){
      busy = v;
      els.searchBtn.textContent = v ? "停止" : "搜索";
      els.searchBtn.classList.toggle("stop", v);
    }

    function escapeHtml(s){
      return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
    }

    function safeHost(u){
      try{ return new URL(u, location.href).hostname.replace(/^www\./,""); }catch(_){ return ""; }
    }

    function formatDate(d){
      if(!d) return "";
      try{
        var dt = new Date(d);
        if(isNaN(dt.getTime())) return "";
        return dt.getFullYear() + "-" + String(dt.getMonth()+1).padStart(2,"0") + "-" + String(dt.getDate()).padStart(2,"0");
      }catch(_){ return ""; }
    }

    function escapeRegex(s){
      return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }

    /* ============================================================
       Markdown
       注意:占位符每次随机生成,避免 AI 输出中偶然包含私有区
       字符导致误替换。先 escapeHtml 再生成标签,顺序不能反。
       ============================================================ */
    function mdToHtml(text){
      var s = escapeHtml(text);

      var nonce = "\uE000" + Math.random().toString(36).slice(2, 10) + "\uE000";
      var nonceRe = escapeRegex(nonce);

      var blocks = [];
      s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(_, lang, code){
        var i = blocks.length;
        blocks.push("<pre><code>" + code.replace(/\n$/,"") + "</code></pre>");
        return nonce + "B" + i + nonce;
      });

      var inlines = [];
      s = s.replace(/`([^`\n]+)`/g, function(_, code){
        var i = inlines.length;
        inlines.push("<code>" + code + "</code>");
        return nonce + "I" + i + nonce;
      });

      s = s.replace(/^#{1,3} (.+)$/gm, "<h3>$1</h3>");
      s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
      s = s.replace(/\[(\d{1,2})\]/g, '<span class="cite">$1</span>');
      s = s.replace(/^[-*] (.+)$/gm, "<li>$1</li>");
      s = s.replace(/(?:<li>.*<\/li>\n?)+/g, function(m){ return "<ul>" + m + "</ul>"; });

      s = s.split(/\n{2,}/).map(function(par){
        var t = par.trim();
        if(!t) return "";
        if(/^<(h3|ul|pre)/.test(t)) return t;
        return "<p>" + t.replace(/\n/g, "<br>") + "</p>";
      }).join("");

      s = s.replace(new RegExp(nonceRe + "B(\\d+)" + nonceRe, "g"), function(_, i){ return blocks[+i]; });
      s = s.replace(new RegExp(nonceRe + "I(\\d+)" + nonceRe, "g"), function(_, i){ return inlines[+i]; });
      return s;
    }

    function renderStream(text, isStreaming){
      if(isStreaming){
        var fences = (text.match(/```/g) || []).length;
        if(fences % 2 === 1) text += "\n```";
      }
      var html = mdToHtml(text);
      if(isStreaming){
        var idx = html.lastIndexOf("</p>");
        if(idx > -1) html = html.slice(0, idx) + '<span class="caret"></span>' + html.slice(idx);
        else html += '<span class="caret"></span>';
      }
      return html;
    }

    /* ============================================================
       结果渲染
       所有外部文本走 textContent,URL 走 safeUrl 白名单。
       ============================================================ */
    function renderSkeleton(){
      els.resultList.innerHTML = "";
      els.resultsHead.classList.add("visible");
      els.resultsCount.textContent = "";
      for(var i = 0; i < 4; i++){
        var el = document.createElement("div");
        el.className = "skeleton";
        el.innerHTML = '<div class="sk sk-icon"></div><div class="sk-body"><div class="sk sk-line short"></div><div class="sk sk-line med"></div><div class="sk sk-line"></div></div>';
        els.resultList.appendChild(el);
      }
    }

    function renderResults(results){
      els.resultList.innerHTML = "";
      if(!results || results.length === 0){
        els.resultsHead.classList.remove("visible");
        return;
      }
      els.resultsHead.classList.add("visible");
      els.resultsCount.textContent = results.length + " 条结果";

      var frag = document.createDocumentFragment();

      results.forEach(function(r, i){
        var href = safeUrl(r.url);

        var a = document.createElement("a");
        a.className = "result-card";
        a.style.animationDelay = (i * 25) + "ms";

        if(href){
          a.href = href;
          a.target = "_blank";
          a.rel = "noopener noreferrer";
        }else{
          a.href = "#";
          a.classList.add("is-disabled");
          a.addEventListener("click", function(e){ e.preventDefault(); });
        }

        var icon = document.createElement("div");
        icon.className = "result-icon";
        var iconSrc = safeUrl(r.site_icon);
        if(iconSrc){
          var img = document.createElement("img");
          img.src = iconSrc;
          img.alt = "";
          img.loading = "lazy";
          img.referrerPolicy = "no-referrer";
          img.onerror = function(){
            img.style.display = "none";
            icon.textContent = (r.site_name || safeHost(r.url) || "?").charAt(0).toUpperCase();
          };
          icon.appendChild(img);
        }else{
          icon.textContent = (r.site_name || safeHost(r.url) || "?").charAt(0).toUpperCase();
        }

        var main = document.createElement("div");
        main.className = "result-main";

        var meta = document.createElement("div");
        meta.className = "result-meta";

        var site = document.createElement("span");
        site.className = "result-site";
        site.textContent = r.site_name || safeHost(r.url) || "网页";
        meta.appendChild(site);

        if(r.rerank_score >= 1.5){
          var d1 = document.createElement("span"); d1.className = "dot"; meta.appendChild(d1);
          var b1 = document.createElement("span"); b1.className = "badge good"; b1.textContent = "优质";
          meta.appendChild(b1);
        }
        if(r.authority >= 3){
          var d2 = document.createElement("span"); d2.className = "dot"; meta.appendChild(d2);
          var b2 = document.createElement("span"); b2.className = "badge auth"; b2.textContent = "权威";
          meta.appendChild(b2);
        }
        var fd = formatDate(r.date);
        if(fd){
          var d3 = document.createElement("span"); d3.className = "dot"; meta.appendChild(d3);
          var ds = document.createElement("span"); ds.textContent = fd;
          meta.appendChild(ds);
        }

        var title = document.createElement("div");
        title.className = "result-title";
        title.textContent = r.title || "(无标题)";

        main.appendChild(meta);
        main.appendChild(title);

        if(r.snippet){
          var snip = document.createElement("div");
          snip.className = "result-snippet";
          snip.textContent = r.snippet;
          main.appendChild(snip);
        }

        a.appendChild(icon);
        a.appendChild(main);
        frag.appendChild(a);
      });

      els.resultList.appendChild(frag);
    }

    /* ============================================================
       搜索 / 模型
       ============================================================ */
    function searchAPI(cfg, query){
      return fetch("https://api.deepseek.com/v1/tools/search", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Authorization": "Bearer " + cfg.key },
        body: JSON.stringify({ query: query, count: cfg.count }),
        signal: controller ? controller.signal : undefined
      }).then(function(res){
        if(!res.ok){
          return res.text().then(function(t){
            throw new Error("搜索接口 " + res.status + (t ? ":" + t.slice(0, 200) : ""));
          });
        }
        return res.json();
      }).then(function(data){
        return (data && data.results) ? data.results : [];
      });
    }

    function chatStream(cfg, query, results){
      var ctx = results.slice(0, 8).map(function(r, i){
        return "[" + (i+1) + "] " + r.title + "\n" + (r.summary || r.snippet || "") + "\n来源:" + r.url;
      }).join("\n\n");

      var messages = [
        { role: "system", content: "你是一个专业的搜索助手。请基于提供的搜索结果,用中文给出准确、结构清晰的综合回答。可以使用 Markdown。引用来源时用 [1] [2] 这样的角标对应结果序号。若结果不足以回答,如实说明。" },
        { role: "user", content: "用户问题:" + query + "\n\n搜索结果:\n" + (ctx || "(本次没有返回搜索结果,请基于你的知识回答)") }
      ];

      return fetch("https://api.deepseek.com/chat/completions", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Authorization": "Bearer " + cfg.key },
        body: JSON.stringify({ model: cfg.model, messages: messages, stream: true, temperature: 0.3 }),
        signal: controller.signal
      }).then(function(res){
        if(!res.ok){
          return res.text().then(function(t){
            throw new Error("对话接口 " + res.status + (t ? ":" + t.slice(0, 300) : ""));
          });
        }
        if(!res.body) throw new Error("浏览器不支持流式响应。");
        return res.body;
      });
    }

    function readStream(body, onDelta){
      var reader = body.getReader();
      var decoder = new TextDecoder();
      var buffer = "";
      var full = "";
      var raf = 0;

      function flush(){ raf = 0; onDelta(full, true); }

      function pump(){
        return reader.read().then(function(chunk){
          if(chunk.done){
            if(raf) cancelAnimationFrame(raf);
            onDelta(full, false);
            return;
          }
          buffer += decoder.decode(chunk.value, { stream: true });
          var lines = buffer.split("\n");
          buffer = lines.pop() || "";
          for(var i = 0; i < lines.length; i++){
            var t = lines[i].trim();
            if(t.indexOf("data:") !== 0) continue;
            var payload = t.slice(5).trim();
            if(payload === "[DONE]") continue;
            try{
              var json = JSON.parse(payload);
              if(json.error) throw new Error(json.error.message || JSON.stringify(json.error));
              var delta = json.choices && json.choices[0] && json.choices[0].delta;
              var piece = delta && delta.content;
              if(piece){ full += piece; if(!raf) raf = requestAnimationFrame(flush); }
            }catch(e){
              if(e.message && e.message.indexOf("JSON") === -1) throw e;
            }
          }
          return pump();
        });
      }
      return pump();
    }

    /* ============================================================
       主流程
       ============================================================ */
    function doSearch(query){
      if(busy){
        if(controller) try{ controller.abort(); }catch(_){}
        return;
      }
      var cfg = getConfig();
      if(!cfg) return;

      clearErr();
      clearNotice();

      els.main.classList.add("active");
      els.answerCard.classList.remove("visible");
      els.answerBody.innerHTML = "";
      els.resultList.innerHTML = "";
      els.resultsHead.classList.remove("visible");

      setBusy(true);
      controller = new AbortController();
      renderSkeleton();

      var results = [];

      searchAPI(cfg, query).then(function(r){
        results = r;
        renderResults(results);
      }, function(e){
        if(e.name === "AbortError") throw e;
        renderResults([]);
        showNotice("搜索失败(" + e.message + "),请检查 API Key 或网络。");
      }).then(function(){
        if(!cfg.useModel) return null;

        els.answerCard.classList.add("visible");
        els.answerBody.innerHTML = renderStream("", true);

        return chatStream(cfg, query, results).then(function(body){
          return readStream(body, function(text, streaming){
            els.answerBody.innerHTML = renderStream(text, streaming);
          });
        });
      }).catch(function(err){
        if(err.name === "AbortError"){
          var cur = els.answerBody.textContent || "";
          if(cur) els.answerBody.innerHTML = renderStream(cur + "\n\n(已停止)", false);
        }else{
          showErr(err.message || "请求失败。");
        }
      }).then(function(){
        controller = null;
        setBusy(false);
      });
    }

    /* ============================================================
       事件
       ============================================================ */
    onClick(els.searchBtn, function(){
      if(busy){
        if(controller) try{ controller.abort(); }catch(_){}
        return;
      }
      var q = els.searchInput.value.trim();
      if(!q){ els.searchInput.focus(); return; }
      doSearch(q);
    });

    els.searchInput.addEventListener("keydown", function(e){
      if(e.key === "Enter" && !e.isComposing){
        e.preventDefault();
        els.searchBtn.click();
      }
    });

    var chips = document.querySelectorAll(".chip");
    for(var i = 0; i < chips.length; i++){
      (function(chip){
        onClick(chip, function(){
          var q = chip.getAttribute("data-q");
          els.searchInput.value = q;
          doSearch(q);
        });
      })(chips[i]);
    }

    document.addEventListener("keydown", function(e){
      if((e.metaKey || e.ctrlKey) && e.key && e.key.toLowerCase() === "k"){
        e.preventDefault();
        els.searchInput.focus();
        els.searchInput.select();
      }
      if(e.key === "Escape"){
        if(els.modal.classList.contains("open")) els.modal.classList.remove("open");
        else els.searchInput.blur();
      }
    });

    try{ els.searchInput.focus(); }catch(_){}
    log("初始化完成,当前风格:", currentStyle);
  }
})();
</script>
</body>
</html>

四、使用步骤

  1. 新建文件 index.html,把上面的代码完整保存进去。
  2. 用浏览器打开(双击即可)。
  3. 点右上角齿轮,填入 DeepSeek API Key,保存。
  4. 在搜索框输入内容,回车。

API Key 在 platform.deepseek.com → API Keys 里创建,格式是 sk- 开头。


五、先说缺陷,再谈部署

缺陷一:要钱。

这是最重要的一条,放在最前面。DeepSeek 的 Search API 和 Chat Completions 都是计费接口,按调用量或 token 计费,不是免费额度无限薅的那种。

  • 只开搜索:每次搜索消耗一次 Search API 调用。
  • 同时开 AI 回答:除了 Search,还会再消耗一次 Chat 的 token(输入 + 输出)。

所以如果你一天搜几百次、还开着 AI 综合回答,账单是真的会来的。这不是一个「免费替代搜索引擎」的项目,它更像「用 API 额度换一个干净的搜索界面」。日常偶尔用问题不大,高频使用请先看一下 DeepSeek 的定价页面,心里有个数。

缺陷二:纯前端直连,Key 在浏览器里。

这个项目没有后端,API Key 存在 localStorage,请求直接从浏览器发到 api.deepseek.com。这意味着:

  • localStorage 按源隔离,你存在自己浏览器里的 Key,别人看不到。部署到公网也不会把你的 Key 暴露给访客。
  • 但如果页面存在 XSS 漏洞、或者装了恶意浏览器扩展,当前浏览器里的 Key 就有被读走的风险。这份代码已经做了 safeUrl 白名单、textContent 渲染、Markdown 先转义等防护,堵掉了主路径,但 localStorage 对同源脚本可读这一点是架构决定的,前端代码再怎么改也改不掉。
  • 如果你想用自己的 Key 给所有访客用,那必须加后端代理,Key 放服务端。这种情况纯前端方案不成立。

缺陷三:Search API 的字段和端点可能变。

代码里写死了 https://api.deepseek.com/v1/tools/search,读的是 data.results。DeepSeek 如果调整端点路径、请求体字段或者返回结构,这份代码需要跟着改。目前它对 results、data、documents 几种常见返回结构做了兼容,字段也做了 title/name、url/link、snippet/summary 多路读取,但没法覆盖所有未来的变化。

缺陷四:没有历史记录、没有分页、没有高级语法。

它是一个「一次搜索一次展示」的页面。不存搜索历史,不支持 site: 之类的搜索语法(取决于 API 本身),结果数量在设置里固定,没有「加载更多」。这些不是 bug,是刻意没做------加了就不是「单文件打开即用」了。


六、关于部署和密钥安全

把缺陷说清楚之后,部署这件事就简单了:

  • 个人本地用:没问题。Key 存自己浏览器,页面本身有 XSS 防护,风险可控。
  • 部署到公网、每个用户填自己的 Key :架构成立。每个人各填各的、各存各的 localStorage,互相不可见。你的 Key 不会因为部署而泄露。
  • 部署到公网、用自己的 Key 给所有人用:不成立。这种情况必须上后端代理,Key 放服务端环境变量,前端只和你的代理通信。
  • 无论哪种情况:不要把 Key 硬编码在源码里发布。

代理本身很轻,Node.js + Express 三四十行就够,只做转发和 Authorization 头注入。需要的话可以后面单独写一篇。


七、实现细节

为什么用 Search API,而不是让 chat 模型直接联网? Search API 返回的是结构化结果,有 title、url、snippet、site_name、site_icon、rerank_score、authority、date,可以直接渲染成卡片。让 chat 模型联网的话,搜索结果是个黑盒,客户端拿不到 URL 和标题,做不成结果列表。

AI 回答为什么默认关闭? 搜一个具体名词、查一个网页,看列表就够了。默认只做搜索,更快、更省钱;需要综合回答的时候在设置里打开。

引用角标怎么做的? 在 system prompt 里要求模型用 [1] [2] 对应结果序号,前端用正则 \[(\d{1,2})\] 替换成 <span class="cite">1</span>,加个底色和字号就成了角标。

流式输出为什么用 requestAnimationFrame? 模型吐字可能比浏览器帧率还快,每个 chunk 都触发 DOM 更新会卡。用 rAF 做节流,一帧内多次 chunk 只渲染一次。

Markdown 为什么不用第三方库? 只做了代码块、行内代码、标题、粗体、列表、引用角标、段落这几个最常用的。够用就行,引一个 marked.js 进来反而是杀鸡用牛刀,还多一个外部依赖。


结语

这个项目的定位很明确:一个不需要部署、打开就能用的搜索界面,用 API 额度换干净的体验。它不免费,不能替代搜索引擎,也没有历史记录和账号体系。但如果你本来就在用 DeepSeek 的 API,想要一个比官方控制台舒服得多的搜索界面,这份代码拿去改改就能用。

CSS 变量和 JS 结构都往好改的方向写了。想加第五套风格、想换默认模型、想调整结果卡片的信息密度,都在对应位置改就行。


本文源码基于 DeepSeek Search API 编写,使用时请遵守 DeepSeek 的服务条款。API Key 请妥善保管,不要泄露;Search 与 Chat 接口均为计费服务,请留意用量。

相关推荐
子兮曰1 小时前
Bun v1.4.4 实测:2,025 个文件,bun fmt 2.6 秒跑完
前端·后端·bun
Rocky Ding*1 小时前
【三年面试五年模拟】2026-10-21_零一万物知识图谱算法实习生一面面经全解析
论文阅读·人工智能·深度学习·机器学习·aigc·知识图谱·ai-native
茜茜数模1 小时前
2026第七届MathorCup大数据竞赛B题 一等奖级完整解题方案
人工智能·数学建模
小豆庄1 小时前
本地视频脱敏的追踪系统踩坑——车牌过滤、转场误判与ID冲突排查
人工智能·算法·目标检测·音视频·视频·敏捷流程·纹理贴图
忆挽篱笙歌1 小时前
进程替换函数与shenll命令行解释器
前端·javascript·算法
计算机魔术师1 小时前
砸了2200亿美元还是不够用,AWS说2027年也卖完了
前端
2601_965305391 小时前
智慧农用的农业四情分别是什么
服务器·前端·人工智能
LMT流氓兔1 小时前
第五单元——第11课:自动测试“拒绝后绝不能写入”
数据库·人工智能·python·langchain
老徐聊GEO1 小时前
把“安徽“写进题面之后:多平台AI推荐名单的区域扰动测量(2026)
前端·人工智能·算法·geo优化