css / index.html
kklljj's picture
Update index.html
efab6ee verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat Pro</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
:root{
--bg:#0f0f0f;--bg2:#161616;--bg3:#1f1f1f;--bg4:#2a2a2a;
--bd:rgba(255,255,255,.06);--bd2:rgba(255,255,255,.1);--bd3:rgba(255,255,255,.15);
--tx:#e8e8e8;--tx2:#999;--tx3:#666;--tx4:#444;
--ac:#4f46e5;--ach:#6366f1;--acs:rgba(99,102,241,.12);--acb:rgba(99,102,241,.22);
--gr:#22c55e;--grs:rgba(34,197,94,.1);
--rd:#ef4444;--rds:rgba(239,68,68,.1);
--yw:#f59e0b;--yws:rgba(245,158,11,.1);
--pu:#a855f7;--sw:280px;--tr:.18s cubic-bezier(.4,0,.2,1);
}
body.light{
--bg:#fafafa;--bg2:#f4f4f5;--bg3:#e4e4e7;--bg4:#d4d4d8;
--bd:rgba(0,0,0,.06);--bd2:rgba(0,0,0,.1);--bd3:rgba(0,0,0,.15);
--tx:#09090b;--tx2:#71717a;--tx3:#a1a1aa;--tx4:#d4d4d8;
}
html,body{height:100%;overflow:hidden;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif;background:var(--bg);color:var(--tx);font-size:14px;-webkit-font-smoothing:antialiased;}
::-webkit-scrollbar{width:4px;height:4px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--bd3);border-radius:4px;}
#loginScreen{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:9999;}
.lc{width:380px;display:flex;flex-direction:column;gap:20px;align-items:center;}
.lc-icon{width:56px;height:56px;border-radius:18px;background:linear-gradient(135deg,var(--ac),var(--pu));display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 8px 28px rgba(99,102,241,.4);}
.lc-title{font-size:22px;font-weight:700;}
.lc-sub{font-size:13px;color:var(--tx2);}
.lc-box{width:100%;background:var(--bg2);border:1px solid var(--bd2);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:14px;}
.lc-inp{width:100%;padding:11px 14px;border:1px solid var(--bd2);border-radius:8px;background:var(--bg3);color:var(--tx);font-size:14px;outline:none;transition:border-color var(--tr),box-shadow var(--tr);font-family:inherit;}
.lc-inp:focus{border-color:var(--ach);box-shadow:0 0 0 3px var(--acb);}
.lc-btn{padding:12px;border:none;border-radius:8px;background:linear-gradient(135deg,var(--ac),var(--ach));color:#fff;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;}
.lc-btn:hover{opacity:.85;}
.lc-err{display:none;font-size:12px;color:var(--rd);background:var(--rds);border:1px solid rgba(239,68,68,.2);border-radius:6px;padding:8px 12px;text-align:center;}
.lc-hint{font-size:11px;color:var(--tx3);text-align:center;}
#app{display:none;height:100vh;}
#app.show{display:flex;}
.sidebar{width:var(--sw);background:var(--bg2);border-right:1px solid var(--bd);display:flex;flex-direction:column;height:100vh;flex-shrink:0;z-index:40;transition:transform var(--tr);}
.sb-top{padding:10px 10px 6px;display:flex;flex-direction:column;gap:6px;}
.sb-brand{display:flex;align-items:center;gap:9px;padding:8px 10px;}
.sb-brand-ico{width:28px;height:28px;border-radius:8px;flex-shrink:0;background:linear-gradient(135deg,var(--ac),var(--pu));display:flex;align-items:center;justify-content:center;font-size:14px;}
.sb-brand-name{font-size:14px;font-weight:700;}
.sb-sync{margin-left:auto;font-size:10px;padding:2px 7px;border-radius:20px;font-weight:500;}
.sb-sync.ok{background:var(--grs);color:var(--gr);}
.sb-sync.syncing{background:var(--yws);color:var(--yw);}
.sb-sync.err{background:var(--rds);color:var(--rd);}
.sb-sync.idle{background:var(--bg3);color:var(--tx3);}
.sb-new{display:flex;align-items:center;gap:8px;width:100%;padding:10px 12px;border:1px dashed var(--bd2);border-radius:8px;background:none;color:var(--tx2);cursor:pointer;font-size:13px;transition:all var(--tr);font-family:inherit;}
.sb-new:hover{border-color:var(--acb);background:var(--acs);color:var(--ach);}
.sb-search{position:relative;padding:0 2px;}
.sb-search-ico{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--tx3);font-size:12px;pointer-events:none;}
.sb-sinp{width:100%;padding:8px 10px 8px 30px;border:1px solid var(--bd);border-radius:8px;background:var(--bg3);color:var(--tx);font-size:12px;outline:none;transition:border-color var(--tr);font-family:inherit;}
.sb-sinp:focus{border-color:var(--ach);}
.sb-sinp::placeholder{color:var(--tx3);}
.conv-list{flex:1;overflow-y:auto;padding:4px 6px;}
.conv-grp-lbl{font-size:11px;color:var(--tx3);padding:10px 8px 4px;font-weight:500;}
.conv-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:all var(--tr);border:1px solid transparent;margin-bottom:1px;}
.conv-item:hover{background:var(--bg3);}
.conv-item.active{background:var(--acs);border-color:var(--acb);}
.conv-item-ico{font-size:13px;flex-shrink:0;opacity:.6;}
.conv-item-body{flex:1;min-width:0;}
.conv-item-title{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tx);}
.conv-item.active .conv-item-title{color:var(--ach);}
.conv-item-time{font-size:11px;color:var(--tx3);margin-top:1px;}
.conv-item-del{opacity:0;background:none;border:none;color:var(--tx3);cursor:pointer;padding:3px 5px;border-radius:5px;font-size:12px;flex-shrink:0;transition:all .15s;}
.conv-item:hover .conv-item-del{opacity:1;}
.conv-item-del:hover{color:var(--rd);background:var(--rds);}
.sb-foot{padding:8px 10px 12px;border-top:1px solid var(--bd);display:flex;flex-direction:column;gap:5px;}
.sb-foot-row{display:flex;gap:5px;}
.sb-user{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:background var(--tr);}
.sb-user:hover{background:var(--bg3);}
.sb-user-av{width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,var(--ac),var(--pu));display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
.sb-user-info{display:flex;flex-direction:column;gap:1px;}
.sb-user-name{font-size:13px;font-weight:500;}
.sb-user-role{font-size:11px;color:var(--tx3);}
.main{flex:1;display:flex;flex-direction:column;height:100vh;overflow:hidden;min-width:0;}
.topbar{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--bd);flex-shrink:0;background:var(--bg);gap:10px;}
.tb-l{display:flex;align-items:center;gap:8px;min-width:0;}
.tb-r{display:flex;align-items:center;gap:6px;flex-shrink:0;}
.tb-menu{width:30px;height:30px;border-radius:6px;border:none;background:none;color:var(--tx2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:17px;transition:all var(--tr);flex-shrink:0;}
.tb-menu:hover{background:var(--bg3);color:var(--tx);}
.tb-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px;}
.tb-model{display:flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;background:var(--bg3);border:1px solid var(--bd);font-size:11px;color:var(--tx2);white-space:nowrap;flex-shrink:0;}
.tb-model-dot{width:5px;height:5px;border-radius:50%;background:var(--gr);}
.msgs-wrap{flex:1;overflow-y:auto;background:var(--bg);}
.msgs-inner{max-width:740px;margin:0 auto;padding:28px 20px 16px;}
.msgs-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;gap:16px;}
.empty-ico{width:68px;height:68px;border-radius:22px;background:linear-gradient(135deg,var(--acs),rgba(168,85,247,.1));border:1px solid var(--acb);display:flex;align-items:center;justify-content:center;font-size:28px;}
.empty-title{font-size:19px;font-weight:700;}
.empty-sub{font-size:13px;color:var(--tx2);text-align:center;max-width:320px;line-height:1.7;}
.chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;max-width:480px;}
.chip{padding:8px 15px;border:1px solid var(--bd2);border-radius:20px;font-size:12px;color:var(--tx2);cursor:pointer;background:var(--bg2);transition:all var(--tr);}
.chip:hover{border-color:var(--ach);color:var(--ach);background:var(--acs);}
.msg-row{display:flex;gap:12px;margin-bottom:20px;animation:msgIn .2s ease;}
@keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.msg-row.user{flex-direction:row-reverse;}
.msg-av{width:32px;height:32px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;margin-top:2px;}
.msg-av.user{background:linear-gradient(135deg,var(--ac),var(--ach));}
.msg-av.assistant{background:linear-gradient(135deg,var(--gr),#06b6d4);}
.msg-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;}
.msg-row.user .msg-body{align-items:flex-end;}
.msg-meta{display:flex;align-items:center;gap:6px;padding:0 2px;}
.msg-row.user .msg-meta{flex-direction:row-reverse;}
.msg-name{font-size:12px;font-weight:600;color:var(--tx2);}
.msg-time{font-size:11px;color:var(--tx3);}
.msg-bubble{padding:12px 16px;border-radius:14px;line-height:1.75;word-break:break-word;}
.msg-bubble.user{background:linear-gradient(135deg,var(--acs),rgba(168,85,247,.08));border:1px solid var(--acb);border-bottom-right-radius:4px;max-width:78%;}
.msg-bubble.assistant{background:var(--bg2);border:1px solid var(--bd);border-bottom-left-radius:4px;width:100%;}
.msg-files{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:8px;}
.mf-img{max-width:260px;max-height:180px;border-radius:10px;object-fit:cover;cursor:pointer;border:1px solid var(--bd2);display:block;}
.mf-chip{display:inline-flex;align-items:center;gap:7px;padding:7px 11px;background:var(--bg3);border:1px solid var(--bd2);border-radius:8px;font-size:12px;}
.mf-ico{font-size:16px;flex-shrink:0;}
.mf-name{max-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mf-sz{color:var(--tx3);font-size:11px;}
.msg-actions{display:flex;gap:4px;opacity:0;transition:opacity var(--tr);flex-wrap:wrap;padding:0 2px;}
.msg-row:hover .msg-actions{opacity:1;}
.msg-row.user .msg-actions{justify-content:flex-end;}
.act{padding:4px 9px;border:1px solid var(--bd);border-radius:6px;background:var(--bg2);color:var(--tx2);cursor:pointer;font-size:11px;transition:all var(--tr);display:flex;align-items:center;gap:3px;}
.act:hover{border-color:var(--ach);color:var(--ach);background:var(--acs);}
.act.danger:hover{border-color:var(--rd);color:var(--rd);background:var(--rds);}
.mc p{margin-bottom:10px;}.mc p:last-child{margin-bottom:0;}
.mc h1,.mc h2,.mc h3,.mc h4{margin:14px 0 8px;line-height:1.4;font-weight:600;}
.mc h1{font-size:1.35em;padding-bottom:6px;border-bottom:1px solid var(--bd);}
.mc h2{font-size:1.18em;}.mc h3{font-size:1.05em;}
.mc ul,.mc ol{margin:8px 0 8px 20px;}.mc li{margin-bottom:4px;line-height:1.6;}
.mc blockquote{border-left:3px solid var(--ac);padding:4px 14px;margin:10px 0;color:var(--tx2);background:var(--acs);border-radius:0 8px 8px 0;}
.mc a{color:#60a5fa;text-decoration:none;}.mc a:hover{text-decoration:underline;}
.mc table{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;}
.mc th,.mc td{border:1px solid var(--bd2);padding:8px 12px;text-align:left;}
.mc th{background:var(--bg3);font-weight:600;}
.mc tr:nth-child(even){background:rgba(255,255,255,.02);}
.mc p code,.mc li code,.mc td code{background:rgba(99,102,241,.12);padding:2px 6px;border-radius:5px;font-size:12.5px;font-family:'Fira Code',Consolas,monospace;color:#a5b4fc;border:1px solid rgba(99,102,241,.2);}
.mc pre{background:#0d1117;border-radius:10px;margin:12px 0;border:1px solid rgba(255,255,255,.08);overflow:hidden;}
.mc pre .cb{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;background:rgba(255,255,255,.03);border-bottom:1px solid rgba(255,255,255,.06);}
.mc pre .cb-lang{font-size:11px;color:var(--tx3);font-weight:600;text-transform:uppercase;letter-spacing:.5px;}
.mc pre .cb-copy{padding:3px 10px;border:1px solid rgba(255,255,255,.1);border-radius:5px;background:none;color:var(--tx2);cursor:pointer;font-size:11px;transition:all var(--tr);}
.mc pre .cb-copy:hover{border-color:var(--ach);color:var(--ach);}
.mc pre code{display:block;padding:14px 16px;overflow-x:auto;font-size:13px;font-family:'Fira Code',Consolas,monospace;line-height:1.6;background:none!important;}
.mc hr{border:none;border-top:1px solid var(--bd);margin:14px 0;}
.mc img{max-width:100%;border-radius:8px;margin:6px 0;}
.typing-row{display:flex;gap:12px;margin-bottom:20px;}
.typing-bub{padding:12px 16px;background:var(--bg2);border:1px solid var(--bd);border-radius:14px;border-bottom-left-radius:4px;display:flex;gap:5px;align-items:center;}
.td{width:6px;height:6px;border-radius:50%;background:var(--ac);animation:tdot 1.3s infinite ease-in-out;}
.td:nth-child(2){animation-delay:.18s;}.td:nth-child(3){animation-delay:.36s;}
@keyframes tdot{0%,80%,100%{transform:scale(0);opacity:.3}40%{transform:scale(1);opacity:1}}
.scur{display:inline-block;width:2px;height:.9em;background:var(--ach);margin-left:2px;vertical-align:text-bottom;animation:scur .55s infinite;}
@keyframes scur{0%,100%{opacity:1}50%{opacity:0}}
.input-area{padding:12px 16px 18px;border-top:1px solid var(--bd);flex-shrink:0;background:var(--bg);}
.input-wrap{max-width:740px;margin:0 auto;display:flex;flex-direction:column;gap:8px;}
.input-files{display:flex;flex-wrap:wrap;gap:6px;}
.fc{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--bg3);border:1px solid var(--bd2);border-radius:9px;max-width:220px;position:relative;}
.fc-img{width:48px;height:48px;border-radius:7px;object-fit:cover;cursor:pointer;border:1px solid var(--bd2);}
.fc-ico{font-size:18px;flex-shrink:0;}
.fc-info{display:flex;flex-direction:column;gap:1px;min-width:0;}
.fc-name{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px;}
.fc-size{font-size:11px;color:var(--tx3);}
.fc-del{position:absolute;top:-5px;right:-5px;width:16px;height:16px;border-radius:50%;background:var(--rd);border:none;color:#fff;cursor:pointer;font-size:10px;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity var(--tr);}
.fc:hover .fc-del{opacity:1;}
.input-box{background:var(--bg2);border:1.5px solid var(--bd2);border-radius:14px;overflow:hidden;transition:border-color var(--tr),box-shadow var(--tr);}
.input-box:focus-within{border-color:var(--ach);box-shadow:0 0 0 3px var(--acb);}
.input-toolbar{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--bd);}
.itool{width:30px;height:30px;border-radius:7px;border:none;background:none;color:var(--tx3);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all var(--tr);}
.itool:hover{background:var(--bg3);color:var(--tx);}
.itool-sep{width:1px;height:18px;background:var(--bd);margin:0 4px;}
.input-mid{display:flex;align-items:flex-end;padding:8px 10px;}
#userInput{flex:1;background:none;border:none;color:var(--tx);font-size:14px;resize:none;min-height:28px;max-height:180px;font-family:inherit;line-height:1.65;outline:none;padding:2px 4px;}
#userInput::placeholder{color:var(--tx3);}
.input-send-area{display:flex;gap:6px;align-items:flex-end;flex-shrink:0;}
.send-btn{width:34px;height:34px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--ac),var(--ach));color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all var(--tr);}
.send-btn:hover:not(:disabled){opacity:.85;transform:translateY(-1px);}
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;}
.stop-btn{width:34px;height:34px;border-radius:9px;border:1px solid rgba(239,68,68,.3);background:var(--rds);color:var(--rd);cursor:pointer;display:none;align-items:center;justify-content:center;font-size:16px;transition:all var(--tr);}
.stop-btn:hover{background:rgba(239,68,68,.2);}
.input-foot{display:flex;justify-content:space-between;font-size:11px;color:var(--tx3);padding:0 2px;}
.stbar{display:flex;justify-content:space-between;align-items:center;padding:4px 16px;font-size:11px;color:var(--tx3);border-top:1px solid var(--bd);background:var(--bg2);flex-shrink:0;}
.st-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--gr);margin-right:5px;vertical-align:middle;animation:stpulse 2s infinite;}
.st-dot.err{background:var(--rd);animation:none;}
.st-dot.ld{background:var(--yw);animation:stpulse .8s infinite;}
@keyframes stpulse{0%,100%{opacity:1}50%{opacity:.3}}
.sp{position:fixed;right:0;top:0;width:400px;height:100vh;background:var(--bg2);border-left:1px solid var(--bd);z-index:200;display:flex;flex-direction:column;transform:translateX(100%);transition:transform var(--tr);box-shadow:-8px 0 32px rgba(0,0,0,.4);}
.sp.open{transform:none;}
.sp-head{padding:14px 18px;border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center;}
.sp-head-title{font-size:15px;font-weight:700;}
.sp-body{flex:1;overflow-y:auto;padding:18px;}
.sp-sec{margin-bottom:24px;}
.sp-sec-ttl{font-size:11px;font-weight:600;color:var(--tx3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.sp-sec-ttl::after{content:'';flex:1;height:1px;background:var(--bd);}
.fr{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;}
.fr label{font-size:12px;color:var(--tx2);font-weight:500;}
.fi{padding:10px 13px;border:1px solid var(--bd2);border-radius:8px;background:var(--bg3);color:var(--tx);font-size:13px;width:100%;outline:none;transition:border-color var(--tr),box-shadow var(--tr);font-family:inherit;}
.fi:focus{border-color:var(--ach);box-shadow:0 0 0 3px var(--acb);}
.fi option{background:var(--bg2);}
textarea.fi{resize:vertical;min-height:68px;line-height:1.6;}
.sp-foot{padding:12px 18px;border-top:1px solid var(--bd);display:flex;gap:8px;}
.mg{display:flex;flex-direction:column;gap:5px;max-height:200px;overflow-y:auto;}
.mc2{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border:1px solid var(--bd);border-radius:8px;cursor:pointer;background:var(--bg3);transition:all var(--tr);}
.mc2:hover{border-color:var(--ach);background:var(--acs);}
.mc2.sel{border-color:var(--ach);background:var(--acs);}
.mc2-name{font-size:13px;font-weight:500;}
.mc2-badge{font-size:11px;padding:2px 8px;border-radius:20px;background:var(--grs);color:var(--gr);border:1px solid rgba(34,197,94,.2);}
.hf-st{display:flex;align-items:center;gap:8px;padding:10px 13px;border-radius:8px;background:var(--bg3);border:1px solid var(--bd);font-size:12px;color:var(--tx2);}
.hf-dot{width:7px;height:7px;border-radius:50%;background:var(--tx3);flex-shrink:0;}
.hf-dot.ok{background:var(--gr);}.hf-dot.err{background:var(--rd);}
.hf-dot.ck{background:var(--yw);animation:stpulse .8s infinite;}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:150;display:none;backdrop-filter:blur(2px);}
.overlay.show{display:block;}
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(80px);background:var(--bg3);border:1px solid var(--bd2);border-radius:10px;padding:10px 18px;font-size:13px;z-index:9999;transition:transform .25s cubic-bezier(.4,0,.2,1);display:flex;align-items:center;gap:8px;pointer-events:none;box-shadow:0 4px 24px rgba(0,0,0,.4);white-space:nowrap;}
.toast.show{transform:translateX(-50%) translateY(0);}
.toast.success{border-color:rgba(34,197,94,.35);}
.toast.error{border-color:rgba(239,68,68,.35);}
.toast.warning{border-color:rgba(245,158,11,.35);}
.btn{padding:7px 13px;border:1px solid var(--bd);border-radius:8px;background:var(--bg3);color:var(--tx);cursor:pointer;font-size:13px;transition:all var(--tr);display:inline-flex;align-items:center;gap:5px;white-space:nowrap;font-family:inherit;}
.btn:hover{border-color:var(--bd2);background:var(--bg4);}
.btn.primary{background:linear-gradient(135deg,var(--ac),var(--ach));border:none;color:#fff;font-weight:500;}
.btn.primary:hover{opacity:.85;}
.btn.danger{border-color:rgba(239,68,68,.25);color:var(--rd);}
.btn.danger:hover{background:var(--rds);}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.btn.sm{padding:6px 11px;font-size:12px;border-radius:7px;}
.ibtn{width:30px;height:30px;border-radius:7px;border:1px solid var(--bd);background:var(--bg3);color:var(--tx2);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:14px;transition:all var(--tr);}
.ibtn:hover{border-color:var(--bd2);color:var(--tx);}
.img-modal{position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:9998;display:none;align-items:center;justify-content:center;cursor:zoom-out;}
.img-modal.show{display:flex;}
.img-modal img{max-width:90vw;max-height:90vh;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.8);}
@media(max-width:768px){
.sidebar{position:fixed;left:0;top:0;height:100vh;transform:translateX(-100%);z-index:160;}
.sidebar.open{transform:none;}.sp{width:100%;}
.tb-title{max-width:130px;}.msgs-inner{padding:16px 12px 10px;}}
</style>
</head>
<body>
<div id="loginScreen">
<div class="lc">
<div class="lc-icon">🤖</div>
<div class="lc-title">AI Chat Pro</div>
<div class="lc-sub">请输入访问密码以继续</div>
<div class="lc-box">
<div>
<label style="font-size:12px;color:var(--tx2);font-weight:500;display:block;margin-bottom:6px;">访问密码</label>
<input class="lc-inp" type="password" id="loginPwd" placeholder="输入密码…">
</div>
<div class="lc-err" id="loginErr">密码错误,请重试</div>
<button class="lc-btn" id="loginBtn">进入</button>
</div>
<div class="lc-hint">默认密码:admin123 · 可在设置中修改</div>
</div>
</div>
<div id="app">
<div class="overlay" id="overlay"></div>
<div class="sidebar" id="sidebar">
<div class="sb-top">
<div class="sb-brand">
<div class="sb-brand-ico">🤖</div>
<div class="sb-brand-name">AI Chat Pro</div>
<div class="sb-sync idle" id="syncBadge">离线</div>
</div>
<button class="sb-new" id="newChatBtn"><span style="font-size:16px;line-height:1;"></span>新建对话</button>
<div class="sb-search">
<span class="sb-search-ico">🔍</span>
<input class="sb-sinp" id="searchInp" placeholder="搜索对话…">
</div>
</div>
<div class="conv-list" id="convList"></div>
<div class="sb-foot">
<div class="sb-foot-row">
<button class="btn sm" id="exportBtn" style="flex:1;justify-content:center;">📤 导出</button>
<button class="btn sm" id="importBtn" style="flex:1;justify-content:center;">📥 导入</button>
</div>
<div class="sb-user" id="logoutBtn">
<div class="sb-user-av">👤</div>
<div class="sb-user-info">
<div class="sb-user-name">当前用户</div>
<div class="sb-user-role">点击退出登录</div>
</div>
</div>
</div>
</div>
<div class="main">
<div class="topbar">
<div class="tb-l">
<button class="tb-menu" id="menuBtn"></button>
<span class="tb-title" id="topbarTitle">新对话</span>
<div class="tb-model"><div class="tb-model-dot"></div><span id="topbarModelName">-</span></div>
</div>
<div class="tb-r">
<button class="ibtn" id="clearBtn">🗑️</button>
<button class="ibtn" id="themeBtn">🌓</button>
<button class="btn primary" id="settingsBtn">⚙️ 设置</button>
</div>
</div>
<div class="msgs-wrap" id="msgsWrap"><div class="msgs-inner" id="msgs"></div></div>
<div class="input-area">
<div class="input-wrap">
<div class="input-files" id="inputFiles"></div>
<div class="input-box">
<div class="input-toolbar">
<button class="itool" id="uploadImgBtn">🖼️</button>
<button class="itool" id="uploadFileBtn">📎</button>
<button class="itool" id="uploadFolderBtn">📁</button>
<div class="itool-sep"></div>
<button class="itool" id="clearInputBtn"></button>
</div>
<div class="input-mid">
<textarea id="userInput" placeholder="输入消息… Enter发送,Shift+Enter换行" rows="1"></textarea>
<div class="input-send-area">
<button class="stop-btn" id="stopBtn"></button>
<button class="send-btn" id="sendBtn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="input-foot">
<span>支持图片·文件·文件夹·拖拽上传</span>
<span id="charCount"></span>
</div>
</div>
</div>
<div class="stbar">
<div><span class="st-dot" id="stDot"></span><span id="stText">就绪</span></div>
<div id="stInfo">0 条消息</div>
</div>
</div>
<div class="sp" id="sp">
<div class="sp-head">
<div class="sp-head-title">⚙️ 设置</div>
<button class="ibtn" id="closeSpBtn"></button>
</div>
<div class="sp-body">
<div class="sp-sec">
<div class="sp-sec-ttl">HuggingFace 同步</div>
<div class="fr"><label>HF Token(写入用)</label>
<input class="fi" type="password" id="cfgHfToken" placeholder="hf_xxxxxxxx"></div>
<div class="fr"><label>公开 Dataset 仓库</label>
<input class="fi" type="text" id="cfgHfRepo" placeholder="username/dataset-name"></div>
<div class="hf-st"><div class="hf-dot" id="hfDot"></div><span id="hfTxt">未配置</span></div>
<div style="display:flex;gap:7px;margin-top:10px;">
<button class="btn sm" id="testHfBtn" style="flex:1;justify-content:center;">🔌 测试连接</button>
<button class="btn sm" id="syncNowBtn" style="flex:1;justify-content:center;">🔄 立即同步</button>
</div>
</div>
<div class="sp-sec">
<div class="sp-sec-ttl">聊天 API</div>
<div class="fr"><label>API 地址</label>
<input class="fi" type="text" id="cfgUrl" placeholder="http://localhost:3010"></div>
<div class="fr"><label>API Token</label>
<input class="fi" type="password" id="cfgToken" placeholder="sk-…"></div>
<div class="fr"><label>模型</label><select class="fi" id="cfgModel"></select></div>
<div class="fr"><label>温度 (0–2)</label>
<input class="fi" type="number" id="cfgTemp" value="0.7" min="0" max="2" step="0.1"></div>
<div class="fr"><label>系统提示词</label>
<textarea class="fi" id="cfgSystem" placeholder="你是一个专业的AI助手…"></textarea></div>
<div style="display:flex;gap:7px;margin-top:4px;">
<button class="btn sm" id="refreshModelsBtn" style="flex:1;justify-content:center;">🔄 获取模型</button>
<button class="btn sm" id="testConnBtn" style="flex:1;justify-content:center;">🔌 测试API</button>
</div>
</div>
<div class="sp-sec">
<div class="sp-sec-ttl">可用模型</div>
<div class="mg" id="modelGrid"></div>
</div>
<div class="sp-sec">
<div class="sp-sec-ttl">安全</div>
<div class="fr"><label>新密码(同时作为加密密钥)</label>
<input class="fi" type="password" id="cfgPwd1" placeholder="至少4位"></div>
<div class="fr"><label>确认密码</label>
<input class="fi" type="password" id="cfgPwd2" placeholder="再次输入"></div>
</div>
<div class="sp-sec">
<div class="sp-sec-ttl">数据管理</div>
<button class="btn sm danger" id="clearAllBtn" style="width:100%;justify-content:center;">⚠️ 清空所有本地数据</button>
</div>
</div>
<div class="sp-foot">
<button class="btn primary" id="saveSpBtn" style="flex:1;justify-content:center;">💾 保存设置</button>
<button class="btn" id="cancelSpBtn">取消</button>
</div>
</div>
</div>
<div class="img-modal" id="imgModal"><img id="imgModalImg" src="" alt=""></div>
<div class="toast" id="toast"><span id="toastIco"></span><span id="toastMsg"></span></div>
<input type="file" id="fileImgInp" accept="image/*" multiple style="display:none;">
<input type="file" id="fileAnyInp" multiple style="display:none;">
<input type="file" id="fileFolderInp" webkitdirectory multiple style="display:none;">
<input type="file" id="importJsonInp" accept=".json" style="display:none;">
<script>
(function(){
'use strict';
const DB_NAME='AIChatProV4',DB_VER=1;
let db=null;
function openDB(){
return new Promise((res,rej)=>{
const r=indexedDB.open(DB_NAME,DB_VER);
r.onupgradeneeded=e=>{
const d=e.target.result;
if(!d.objectStoreNames.contains('conversations')) d.createObjectStore('conversations',{keyPath:'id'});
if(!d.objectStoreNames.contains('kv')) d.createObjectStore('kv',{keyPath:'k'});
};
r.onsuccess=e=>{db=e.target.result;res(db);};
r.onerror=e=>rej(e.target.error);
});
}
function txs(s,m='readonly'){return db.transaction(s,m).objectStore(s);}
function dbGet(s,k){return new Promise((res,rej)=>{const r=txs(s).get(k);r.onsuccess=()=>res(r.result);r.onerror=e=>rej(e.target.error);});}
function dbGetAll(s){return new Promise((res,rej)=>{const r=txs(s).getAll();r.onsuccess=()=>res(r.result);r.onerror=e=>rej(e.target.error);});}
function dbPut(s,v){return new Promise((res,rej)=>{const r=txs(s,'readwrite').put(v);r.onsuccess=()=>res();r.onerror=e=>rej(e.target.error);});}
function dbDel(s,k){return new Promise((res,rej)=>{const r=txs(s,'readwrite').delete(k);r.onsuccess=()=>res();r.onerror=e=>rej(e.target.error);});}
function dbClear(s){return new Promise((res,rej)=>{const r=txs(s,'readwrite').clear();r.onsuccess=()=>res();r.onerror=e=>rej(e.target.error);});}
async function kvGet(k){const r=await dbGet('kv',k);return r?r.v:undefined;}
async function kvSet(k,v){await dbPut('kv',{k,v});}
const DEFAULT_MODELS=[
{id:'claude-sonnet-4-20250514',name:'Claude Sonnet 4',provider:'Cursor'},
{id:'claude-opus-4-20250514',name:'Claude Opus 4',provider:'Cursor'},
{id:'gpt-4o',name:'GPT-4o',provider:'OpenAI'},
{id:'gpt-4o-mini',name:'GPT-4o Mini',provider:'OpenAI'},
{id:'gpt-4-turbo',name:'GPT-4 Turbo',provider:'OpenAI'},
{id:'claude-3-5-sonnet',name:'Claude 3.5 Sonnet',provider:'Anthropic'},
{id:'gemini-pro',name:'Gemini Pro',provider:'Google'},
];
const st={
convs:[],curId:null,
cfg:{apiUrl:'',apiToken:'',model:DEFAULT_MODELS[0].id,temperature:0.7,
systemPrompt:'',password:'admin123',hfToken:'',hfRepo:''},
models:[...DEFAULT_MODELS],generating:false,abort:null,
theme:'dark',pendingFiles:[],syncQueue:new Set(),syncTimer:null,searchQ:'',
};
const $=id=>document.getElementById(id);
function esc(t){const d=document.createElement('div');d.textContent=t;return d.innerHTML;}
function uid(){return Date.now().toString(36)+Math.random().toString(36).slice(2,8);}
function fmtSz(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(1)+'MB';}
function fmtTime(ts){return new Date(ts).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});}
function fmtDate(ts){const d=new Date(ts),n=new Date();if(d.toDateString()===n.toDateString())return fmtTime(ts);return(d.getMonth()+1)+'/'+(d.getDate());}
function toast(msg,type='success'){
const icons={success:'✓',error:'✕',warning:'⚠️',info:'ℹ️'};
$('toastIco').textContent=icons[type]||'✓';$('toastMsg').textContent=msg;
const t=$('toast');t.className='toast '+type+' show';
clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),3000);
}
function setStatus(type,text){
$('stDot').className='st-dot'+(type==='err'?' err':type==='ld'?' ld':'');
$('stText').textContent=text;
}
function setSyncBadge(s){
const b=$('syncBadge');b.className='sb-sync '+s;
b.textContent={ok:'已同步',syncing:'同步中',err:'同步失败',idle:'未配置'}[s]||s;
}
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,180)+'px';}
function updateCharCount(){const v=$('userInput').value;$('charCount').textContent=v.length>0?v.length+' 字':'';}
async function getKey(pwd){
const enc=new TextEncoder();
const km=await crypto.subtle.importKey('raw',enc.encode(pwd),'PBKDF2',false,['deriveKey']);
return crypto.subtle.deriveKey(
{name:'PBKDF2',salt:enc.encode('aichat-salt-v1'),iterations:100000,hash:'SHA-256'},
km,{name:'AES-GCM',length:256},false,['encrypt','decrypt']
);
}
async function encrypt(data,pwd){
const key=await getKey(pwd);
const iv=crypto.getRandomValues(new Uint8Array(12));
const enc=new TextEncoder();
const cipher=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,enc.encode(JSON.stringify(data)));
const buf=new Uint8Array(iv.byteLength+cipher.byteLength);
buf.set(iv,0);buf.set(new Uint8Array(cipher),iv.byteLength);
let bin='';const chunk=8192;
for(let i=0;i<buf.length;i+=chunk) bin+=String.fromCharCode(...buf.subarray(i,i+chunk));
return btoa(bin);
}
async function decrypt(b64,pwd){
try{
const key=await getKey(pwd);
const buf=Uint8Array.from(atob(b64),c=>c.charCodeAt(0));
const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:buf.slice(0,12)},key,buf.slice(12));
return JSON.parse(new TextDecoder().decode(plain));
}catch(e){throw new Error('解密失败');}
}
function detectLang(code){
if(!code||typeof code!=='string') return '';
if(/^\s*<[a-zA-Z]/.test(code)) return 'html';
if(/^\s*[\{\[]/.test(code)&&/[\}\]]/.test(code)) return 'json';
if(/def |import |print\(|elif /.test(code)) return 'python';
if(/function |const |let |var |=>/.test(code)) return 'javascript';
if(/interface |type |: string|: number/.test(code)) return 'typescript';
if(/SELECT|INSERT|UPDATE|DELETE/i.test(code)) return 'sql';
if(/#include|int main|printf/.test(code)) return 'cpp';
if(/public class|System\.out/.test(code)) return 'java';
if(/^func |^package /.test(code)) return 'go';
if(/^fn |^use |^impl /.test(code)) return 'rust';
return '';
}
function smartWrap(text){
if(!text||typeof text!=='string') return text||'';
if(/```/.test(text)) return text;
const lines=text.split('\n');
if(lines.length<4) return text;
const sc=lines.filter(l=>/^[\s]*[{}\[\]();]/.test(l)||/^\s{4,}/.test(l)||/^(import|export|const|let|var|function|class|def|return|if|for|while)\s/.test(l)).length;
if(sc/lines.length>0.4){const lang=detectLang(text);return '```'+(lang||'')+'\n'+text+'\n```';}
return text;
}
const _renderer=new marked.Renderer();
_renderer.code=function(token){
let code,lang;
if(token&&typeof token==='object'){
code=String(token.text||'');
lang=String(token.lang||'');
}else{
code=String(token||'');
lang='';
}
if(!lang) lang=detectLang(code);
let hi='';
try{
if(lang&&hljs.getLanguage(lang)) hi=hljs.highlight(code,{language:lang,ignoreIllegals:true}).value;
else hi=hljs.highlightAuto(code).value;
}catch(e){hi=esc(code);}
const sl=esc(lang||'code');
return '<pre><div class="cb"><span class="cb-lang">'+sl+'</span>'
+'<button class="cb-copy" onclick="cpCode(this)">复制</button></div>'
+'<code class="hljs'+(lang?' language-'+sl:'')+'">'
+hi+'</code></pre>';
};
_renderer.codespan=function(token){
const code=(token&&typeof token==='object')?String(token.text||''):String(token||'');
return '<code style="background:rgba(99,102,241,.12);padding:2px 6px;border-radius:5px;'
+'font-size:12.5px;font-family:\'Fira Code\',Consolas,monospace;color:#a5b4fc;'
+'border:1px solid rgba(99,102,241,.2);">'+esc(code)+'</code>';
};
marked.use({breaks:true,gfm:true,renderer:_renderer});
window.cpCode=function(btn){
const pre=btn.closest('pre');if(!pre) return;
const code=pre.querySelector('code');if(!code) return;
const text=code.innerText||code.textContent||'';
navigator.clipboard.writeText(text).then(()=>{
const orig=btn.textContent;btn.textContent='已复制!';
btn.style.borderColor='var(--gr)';btn.style.color='var(--gr)';
setTimeout(()=>{btn.textContent=orig;btn.style.borderColor='';btn.style.color='';},2000);
}).catch(()=>toast('复制失败','error'));
};
function fileIcon(name,type){
if(type&&type.startsWith('image/')) return '🖼️';
const ext=(name.split('.').pop()||'').toLowerCase();
const map={js:'📜',ts:'📜',jsx:'📜',tsx:'📜',py:'🐍',html:'🌐',css:'🎨',json:'📋',md:'📝',txt:'📄',pdf:'📕',zip:'📦',rar:'📦',mp4:'🎬',mp3:'🎵',doc:'📃',docx:'📃',xls:'📊',xlsx:'📊',sh:'⚙️',yml:'⚙️',yaml:'⚙️',xml:'📋',go:'🐹',rs:'⚙️',java:'☕',cpp:'⚙️',c:'⚙️',rb:'💎',php:'🐘',vue:'💚'};
return map[ext]||'📄';
}
function readAsText(f){return new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsText(f);});}
function readAsDataURL(f){return new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsDataURL(f);});}
async function processFiles(files){
for(const file of files){
if(st.pendingFiles.length>=10){toast('最多10个文件','warning');break;}
const isImg=file.type.startsWith('image/');
const entry={id:uid(),name:file.name,size:file.size,type:file.type,isImg};
if(isImg){try{entry.dataUrl=await readAsDataURL(file);}catch(e){toast('图片读取失败','error');continue;}}
else{try{entry.text=await readAsText(file);}catch(e){entry.text='[无法读取]';}}
st.pendingFiles.push(entry);
}
renderInputFiles();
}
function renderInputFiles(){
const wrap=$('inputFiles');wrap.innerHTML='';
st.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='fc';
if(f.isImg&&f.dataUrl){
const img=document.createElement('img');img.src=f.dataUrl;img.className='fc-img';
img.addEventListener('click',()=>openImgModal(f.dataUrl));
const info=document.createElement('div');info.className='fc-info';
info.innerHTML=`<span class="fc-name">${esc(f.name)}</span><span class="fc-size">${fmtSz(f.size)}</span>`;
chip.append(img,info);
}else{
chip.innerHTML=`<span class="fc-ico">${fileIcon(f.name,f.type)}</span><div class="fc-info"><span class="fc-name">${esc(f.name)}</span><span class="fc-size">${fmtSz(f.size)}</span></div>`;
}
const del=document.createElement('button');del.className='fc-del';del.textContent='✕';
del.addEventListener('click',e=>{e.stopPropagation();st.pendingFiles.splice(i,1);renderInputFiles();});
chip.appendChild(del);wrap.appendChild(chip);
});
}
function openImgModal(src){$('imgModalImg').src=src;$('imgModal').classList.add('show');}
window.openImgModal=openImgModal;
function getTextFromContent(content){
if(typeof content==='string') return content;
if(Array.isArray(content)) return content.filter(p=>p&&p.type==='text').map(p=>p.text).join('\n');
if(content!=null) return String(content);
return '';
}
function buildApiContent(text,files){
if(!files||files.length===0) return text||'';
const parts=[];const fileNames=[];
files.forEach(f=>{
if(f.isImg&&f.dataUrl){
parts.push({type:'image_url',image_url:{url:f.dataUrl}});fileNames.push(f.name);
}else if(f.text!=null){
const ext=(f.name.split('.').pop()||'').toLowerCase();
const lang=detectLang(f.text);
parts.push({type:'text',text:`**附件:${f.name}**(${fmtSz(f.size)})\n\`\`\`${lang||ext}\n${f.text}\n\`\`\``});
fileNames.push(f.name);
}
});
const finalText=text&&text.trim()?text:`请查看我发送的文件:${fileNames.join('、')}`;
parts.push({type:'text',text:finalText});
if(parts.length===1&&parts[0].type==='text') return parts[0].text;
return parts;
}
function renderMsgFiles(files){
if(!files||files.length===0) return '';
let html='<div class="msg-files">';
files.forEach(f=>{
if(f.isImg&&f.dataUrl){
const safe=f.dataUrl.replace(/'/g,"\\'");
html+=`<img class="mf-img" src="${f.dataUrl}" alt="${esc(f.name)}" onclick="openImgModal('${safe}')">`;
}else{
html+=`<div class="mf-chip"><span class="mf-ico">${fileIcon(f.name,f.type)}</span><div style="display:flex;flex-direction:column;gap:1px;min-width:0;"><span class="mf-name">${esc(f.name)}</span><span class="mf-sz">${fmtSz(f.size)}</span></div></div>`;
}
});
return html+'</div>';
}
async function hfGetFile(path){
const{hfRepo:repo}=st.cfg;if(!repo) return null;
try{
const res=await fetch(`https://huggingface.co/datasets/${repo}/resolve/main/${path}`,{headers:{'Cache-Control':'no-cache'}});
if(res.status===404) return null;
if(!res.ok) throw new Error('HTTP '+res.status);
return res.text();
}catch(e){if(e.message&&e.message.includes('404')) return null;throw e;}
}
async function hfUpload(filePath,content){
const{hfToken:token,hfRepo:repo}=st.cfg;if(!token||!repo) return;
const encStr=await encrypt(content,st.cfg.password);
const bytes=new TextEncoder().encode(encStr);
let bin='';const chunk=8192;
for(let i=0;i<bytes.length;i+=chunk) bin+=String.fromCharCode(...bytes.subarray(i,i+chunk));
const b64=btoa(bin);
const doCommit=async(parent)=>{
const body={summary:'update '+filePath,files:[{path:filePath,content:b64,encoding:'base64'}]};
if(parent) body.parentCommit=parent;
return fetch(`https://huggingface.co/api/datasets/${repo}/commit/main`,{
method:'POST',headers:{'Authorization':'Bearer '+token,'Content-Type':'application/json'},
body:JSON.stringify(body),
});
};
let res=await doCommit(null);
if(!res.ok){
const ej=await res.json().catch(()=>({}));
if([409,412,422].includes(res.status)||(ej.error&&ej.error.includes('commitOid'))){
const hr=await fetch(`https://huggingface.co/api/datasets/${repo}`,{headers:{'Authorization':'Bearer '+token}});
const hj=await hr.json().catch(()=>({}));
res=await doCommit(hj.sha||null);
}
if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.error||'HTTP '+res.status);}
}
return res.json().catch(()=>({}));
}
async function hfListConvs(){
const{hfRepo:repo}=st.cfg;if(!repo) return [];
try{
const res=await fetch(`https://huggingface.co/api/datasets/${repo}/tree/main/conversations`);
if(!res.ok) return [];
const data=await res.json();
return Array.isArray(data)?data:[];
}catch(e){return [];}
}
async function hfDeleteConv(id){
try{await hfUpload(`conversations/${id}.json`,{deleted:true,id,ts:Date.now()});}
catch(e){console.warn('HF delete failed',e);}
}
async function syncConvToHF(conv){await hfUpload(`conversations/${conv.id}.json`,conv);}
async function syncSettingsToHF(){await hfUpload('settings.json',{cfg:{...st.cfg},models:st.models,ts:Date.now()});}
async function loadFromHF(){
if(!st.cfg.hfRepo) return;
setSyncBadge('syncing');
try{
const sRaw=await hfGetFile('settings.json').catch(()=>null);
if(sRaw){
try{
const s=await decrypt(sRaw,st.cfg.password);
if(s.cfg){const pwd=st.cfg.password;Object.assign(st.cfg,s.cfg);st.cfg.password=pwd;}
if(s.models&&s.models.length>0) st.models=s.models;
}catch(e){console.warn('settings decrypt failed',e);}
}
const files=await hfListConvs().catch(()=>[]);
if(files.length>0){
const remotes=[];
await Promise.all(files.filter(f=>f.path&&f.path.endsWith('.json')).map(async f=>{
try{
const fname=f.path.includes('/')?f.path.split('/').pop():f.path;
const raw=await hfGetFile('conversations/'+fname).catch(()=>null);
if(!raw) return;
const data=await decrypt(raw,st.cfg.password).catch(()=>null);
if(data&&!data.deleted&&data.id) remotes.push(data);
}catch(e){}
}));
if(remotes.length>0){
const lm=new Map(st.convs.map(c=>[c.id,c]));
remotes.forEach(rc=>{const lc=lm.get(rc.id);if(!lc||(rc.updatedAt||0)>(lc.updatedAt||0)) lm.set(rc.id,rc);});
st.convs=Array.from(lm.values()).sort((a,b)=>a.createdAt-b.createdAt);
for(const c of st.convs) await dbPut('conversations',c);
}
}
setSyncBadge('ok');
}catch(e){console.warn('HF load failed',e);setSyncBadge('err');}
}
function scheduleSyncConv(conv){
st.syncQueue.add(conv.id);clearTimeout(st.syncTimer);
st.syncTimer=setTimeout(flushSync,1500);
}
async function flushSync(){
if(!st.cfg.hfToken||!st.cfg.hfRepo||st.syncQueue.size===0) return;
setSyncBadge('syncing');
const ids=[...st.syncQueue];st.syncQueue.clear();let ok=true;
for(const id of ids){
const conv=st.convs.find(c=>c.id===id);
if(conv){try{await syncConvToHF(conv);}catch(e){ok=false;}}
}
setSyncBadge(ok?'ok':'err');
}
async function saveConv(conv){conv.updatedAt=Date.now();await dbPut('conversations',conv);scheduleSyncConv(conv);}
async function saveCfg(){await kvSet('cfg',st.cfg);}
async function saveModels(){await kvSet('models',st.models);}
async function saveTheme(){await kvSet('theme',st.theme);}
async function loadLocal(){
const cfg=await kvGet('cfg');if(cfg) Object.assign(st.cfg,cfg);
const theme=await kvGet('theme');if(theme) st.theme=theme;
const models=await kvGet('models');if(models&&models.length>0) st.models=models;
const convs=await dbGetAll('conversations');
if(convs.length>0) st.convs=convs.sort((a,b)=>a.createdAt-b.createdAt);
}
function applyTheme(){document.body.classList.toggle('light',st.theme==='light');$('themeBtn').textContent=st.theme==='light'?'🌙':'🌓';}
async function toggleTheme(){st.theme=st.theme==='dark'?'light':'dark';applyTheme();await saveTheme();}
function checkLogin(){
if(sessionStorage.getItem('auth')==='1'){
$('loginScreen').style.display='none';
showApp().catch(e=>{
console.error('showApp error',e);
$('loginScreen').style.display='flex';
$('loginErr').style.display='block';
$('loginErr').textContent='加载失败:'+e.message;
});
}
}
function doLogin(){
const v=$('loginPwd').value;
if(v===st.cfg.password){
sessionStorage.setItem('auth','1');
$('loginScreen').style.display='none';
$('loginErr').style.display='none';
showApp().catch(e=>{
console.error('showApp error',e);
$('loginScreen').style.display='flex';
$('loginErr').style.display='block';
$('loginErr').textContent='加载失败:'+e.message;
});
}else{
$('loginErr').style.display='block';
$('loginErr').textContent='密码错误,请重试';
$('loginPwd').value='';$('loginPwd').focus();
}
}
function doLogout(){sessionStorage.removeItem('auth');location.reload();}
async function showApp(){
try{await loadLocal();}catch(e){console.warn('loadLocal error',e);}
try{if(st.cfg.hfRepo) await loadFromHF();else setSyncBadge('idle');}
catch(e){console.warn('loadFromHF error',e);setSyncBadge('err');}
if(st.convs.length===0){try{await newConv(false);}catch(e){console.warn(e);}}
else st.curId=st.convs[st.convs.length-1].id;
try{applyTheme();}catch(e){}
try{syncCfgToUI();}catch(e){}
try{renderModelSelect();}catch(e){}
try{renderModelGrid();}catch(e){}
try{renderConvList();}catch(e){}
try{renderMsgs();}catch(e){}
try{updateTopbar();}catch(e){}
try{updateStInfo();}catch(e){}
$('app').classList.add('show');
}
function syncCfgToUI(){
$('cfgUrl').value=st.cfg.apiUrl||'';$('cfgToken').value=st.cfg.apiToken||'';
$('cfgTemp').value=st.cfg.temperature||0.7;$('cfgSystem').value=st.cfg.systemPrompt||'';
$('cfgHfToken').value=st.cfg.hfToken||'';$('cfgHfRepo').value=st.cfg.hfRepo||'';
$('cfgPwd1').value='';$('cfgPwd2').value='';updateHfUI();
}
function updateHfUI(){
const dot=$('hfDot'),txt=$('hfTxt');dot.className='hf-dot';
if(st.cfg.hfRepo){dot.classList.add('ok');txt.textContent='公开仓库:'+st.cfg.hfRepo+'(内容已加密)';}
else txt.textContent='未配置,数据仅存本地';
}
function curConv(){return st.convs.find(c=>c.id===st.curId)||null;}
async function newConv(render=true){
const conv={id:uid(),title:'新对话',messages:[],createdAt:Date.now(),updatedAt:Date.now()};
st.convs.push(conv);st.curId=conv.id;await saveConv(conv);
if(render){renderConvList();renderMsgs();updateTopbar();updateStInfo();if(window.innerWidth<=768) closeSidebar();}
}
async function switchConv(id){
if(st.generating){toast('请先停止当前生成','warning');return;}
st.curId=id;renderConvList();renderMsgs();updateTopbar();updateStInfo();
if(window.innerWidth<=768) closeSidebar();
}
async function delConv(id){
if(!confirm('确定删除?')) return;
st.convs=st.convs.filter(c=>c.id!==id);
await dbDel('conversations',id);await hfDeleteConv(id);
if(st.curId===id){
if(st.convs.length>0) st.curId=st.convs[st.convs.length-1].id;
else{await newConv(false);return;}
}
renderConvList();renderMsgs();updateTopbar();updateStInfo();toast('已删除');
}
async function clearConv(){
const c=curConv();if(!c||!confirm('确定清空?')) return;
c.messages=[];c.title='新对话';await saveConv(c);
renderConvList();renderMsgs();updateTopbar();updateStInfo();toast('已清空');
}
function groupConvs(convs){
const tod=new Date();tod.setHours(0,0,0,0);
const todTs=tod.getTime(),wkTs=todTs-6*864e5;
const g={today:[],week:[],older:[]};
const filtered=st.searchQ?convs.filter(c=>c.title.toLowerCase().includes(st.searchQ.toLowerCase())):convs;
[...filtered].reverse().forEach(c=>{
if(c.createdAt>=todTs) g.today.push(c);
else if(c.createdAt>=wkTs) g.week.push(c);
else g.older.push(c);
});
return g;
}
function renderConvList(){
const list=$('convList');list.innerHTML='';
const g=groupConvs(st.convs);
['today','week','older'].forEach(key=>{
if(g[key].length===0) return;
const lbl=document.createElement('div');lbl.className='conv-grp-lbl';
lbl.textContent={today:'今天',week:'最近7天',older:'更早'}[key];
list.appendChild(lbl);
g[key].forEach(conv=>{
const item=document.createElement('div');
item.className='conv-item'+(conv.id===st.curId?' active':'');
item.innerHTML=`<span class="conv-item-ico">💬</span>
<div class="conv-item-body">
<div class="conv-item-title">${esc(conv.title)}</div>
<div class="conv-item-time">${fmtDate(conv.updatedAt||conv.createdAt)}</div>
</div>
<button class="conv-item-del">✕</button>`;
item.querySelector('.conv-item-del').addEventListener('click',e=>{e.stopPropagation();delConv(conv.id);});
item.addEventListener('click',()=>switchConv(conv.id));
list.appendChild(item);
});
});
if(list.children.length===0)
list.innerHTML='<div style="text-align:center;padding:24px;font-size:12px;color:var(--tx3);">无对话记录</div>';
}
function updateTopbar(){
const c=curConv();
$('topbarTitle').textContent=c?c.title:'新对话';
$('topbarModelName').textContent=st.cfg.model||'-';
}
function updateStInfo(){const c=curConv();$('stInfo').textContent=(c?c.messages.length:0)+' 条消息';}
function renderMsgs(){
const wrap=$('msgs');wrap.innerHTML='';
const c=curConv();
if(!c||c.messages.length===0){
wrap.innerHTML=`<div class="msgs-empty">
<div class="empty-ico">🤖</div>
<div class="empty-title">开始一段新对话</div>
<div class="empty-sub">支持发送文字、代码、图片和文件,AI 实时流式响应</div>
<div class="chips">
<div class="chip" onclick="useChip(this)">✨ 写一个 Python 爬虫</div>
<div class="chip" onclick="useChip(this)">🔍 解释 async/await</div>
<div class="chip" onclick="useChip(this)">📝 优化这段代码</div>
<div class="chip" onclick="useChip(this)">🌐 写一个响应式页面</div>
<div class="chip" onclick="useChip(this)">🐛 帮我找 bug</div>
<div class="chip" onclick="useChip(this)">📊 写一个 SQL 查询</div>
</div>
</div>`;
return;
}
c.messages.forEach((m,i)=>appendMsgDOM(m,i,false));
scrollBottom();
}
window.useChip=function(el){
$('userInput').value=el.textContent.replace(/^.{2}/,'').trim();
$('userInput').focus();autoResize($('userInput'));updateCharCount();
};
function appendMsgDOM(msg,idx,animate){
const wrap=$('msgs');
const row=document.createElement('div');row.className='msg-row '+msg.role;row.dataset.idx=idx;
const av=document.createElement('div');av.className='msg-av '+msg.role;
av.textContent=msg.role==='user'?'👤':'🤖';
const body=document.createElement('div');body.className='msg-body';
const meta=document.createElement('div');meta.className='msg-meta';
const name=document.createElement('span');name.className='msg-name';
name.textContent=msg.role==='user'?'你':'AI 助手';
const time=document.createElement('span');time.className='msg-time';
time.textContent=fmtTime(msg.ts||Date.now());
meta.append(name,time);
const bubble=document.createElement('div');bubble.className='msg-bubble '+msg.role;
if(msg.files&&msg.files.length>0){
const fd=document.createElement('div');fd.innerHTML=renderMsgFiles(msg.files);bubble.appendChild(fd);
}
const mc=document.createElement('div');mc.className='mc';
if(msg.role==='user'){
const textContent=msg.displayText||getTextFromContent(msg.content);
if(textContent.trim()){
const p=document.createElement('p');
p.style.cssText='white-space:pre-wrap;margin:0;word-break:break-word;';
p.textContent=textContent;mc.appendChild(p);
}
}else{
const raw=getTextFromContent(msg.content);
if(raw.trim()){
try{
const tmp=document.createElement('div');
tmp.innerHTML=marked.parse(smartWrap(raw));
mc.appendChild(tmp);
}catch(e){
const p=document.createElement('pre');
p.style.cssText='white-space:pre-wrap;word-break:break-word;';
p.textContent=raw;mc.appendChild(p);
}
}
}
bubble.appendChild(mc);
const actions=document.createElement('div');actions.className='msg-actions';
if(msg.role==='assistant'){
actions.appendChild(mkAct('📋 复制',()=>{
navigator.clipboard.writeText(getTextFromContent(msg.content))
.then(()=>toast('已复制')).catch(()=>toast('复制失败','error'));
}));
actions.appendChild(mkAct('🔄 重新生成',()=>regenFrom(idx)));
}else{
actions.appendChild(mkAct('✏️ 编辑',()=>editMsg(idx)));
actions.appendChild(mkAct('🗑️ 删除',()=>delMsgFrom(idx),'danger'));
}
body.append(meta,bubble,actions);row.append(av,body);wrap.appendChild(row);
if(animate) scrollBottom(true);
return row;
}
function mkAct(text,onClick,cls=''){
const b=document.createElement('button');b.className='act'+(cls?' '+cls:'');
b.innerHTML=text;b.addEventListener('click',onClick);return b;
}
function scrollBottom(smooth){const w=$('msgsWrap');w.scrollTo({top:w.scrollHeight,behavior:smooth?'smooth':'auto'});}
function showTyping(){
const wrap=$('msgs');
const row=document.createElement('div');row.id='typingRow';row.className='typing-row';
const av=document.createElement('div');av.className='msg-av assistant';av.textContent='🤖';
const bub=document.createElement('div');bub.className='typing-bub';
bub.innerHTML='<div class="td"></div><div class="td"></div><div class="td"></div>';
row.append(av,bub);wrap.appendChild(row);scrollBottom();
}
function removeTyping(){const e=$('typingRow');if(e)e.remove();}
function createStreamBubble(){
const wrap=$('msgs');
const row=document.createElement('div');row.id='streamRow';row.className='msg-row assistant';
const av=document.createElement('div');av.className='msg-av assistant';av.textContent='🤖';
const body=document.createElement('div');body.className='msg-body';
const meta=document.createElement('div');meta.className='msg-meta';
const name=document.createElement('span');name.className='msg-name';name.textContent='AI 助手';
const time=document.createElement('span');time.className='msg-time';time.textContent=fmtTime(Date.now());
meta.append(name,time);
const bubble=document.createElement('div');bubble.className='msg-bubble assistant';
const mc=document.createElement('div');mc.className='mc';mc.id='streamMC';
mc.innerHTML='<span class="scur"></span>';
bubble.appendChild(mc);body.append(meta,bubble);row.append(av,body);wrap.appendChild(row);
scrollBottom();return mc;
}
function updateStream(mc,text){
if(typeof text!=='string') return;
try{mc.innerHTML=marked.parse(text)+'<span class="scur"></span>';}
catch(e){mc.textContent=text;}
scrollBottom();
}
function finalizeStream(mc,text){
if(typeof text!=='string') return;
try{mc.innerHTML=marked.parse(text);}
catch(e){mc.textContent=text;}
}
async function sendMessage(){
if(st.generating) return;
const input=$('userInput');
const text=input.value.trim();
const files=[...st.pendingFiles];
if(!text&&files.length===0) return;
let c=curConv();if(!c){await newConv(false);c=curConv();}
const displayText=text||(files.length>0?`[发送了 ${files.length} 个文件:${files.map(f=>f.name).join('、')}]`:'');
const userMsg={
role:'user',content:text,displayText,
files:files.map(f=>({id:f.id,name:f.name,size:f.size,type:f.type,isImg:f.isImg,
dataUrl:f.isImg?f.dataUrl:undefined,text:!f.isImg?f.text:undefined})),
ts:Date.now()
};
c.messages.push(userMsg);
if(c.title==='新对话'){
c.title=text?text.substring(0,32)+(text.length>32?'…':''):files.map(f=>f.name).join('、').substring(0,32);
updateTopbar();
}
await saveConv(c);
appendMsgDOM(userMsg,c.messages.length-1,true);
renderConvList();updateStInfo();
input.value='';input.style.height='auto';
st.pendingFiles=[];renderInputFiles();updateCharCount();
await runGen(c);
}
async function regenFrom(idx){
if(st.generating){toast('请先停止','warning');return;}
const c=curConv();if(!c) return;
c.messages=c.messages.slice(0,idx);
await saveConv(c);renderMsgs();updateStInfo();await runGen(c);
}
async function delMsgFrom(idx){
const c=curConv();if(!c||!confirm('删除此消息及之后所有内容?')) return;
c.messages=c.messages.slice(0,idx);
await saveConv(c);renderMsgs();updateStInfo();toast('已删除');
}
async function editMsg(idx){
const c=curConv();if(!c) return;
const msg=c.messages[idx];if(!msg||msg.role!=='user') return;
const cur=msg.displayText||getTextFromContent(msg.content);
const newText=prompt('编辑消息:',cur);
if(newText===null||newText.trim()==='') return;
c.messages=c.messages.slice(0,idx);
c.messages.push({...msg,content:newText.trim(),displayText:newText.trim(),ts:Date.now()});
await saveConv(c);renderMsgs();updateStInfo();await runGen(c);
}
async function runGen(conv){
st.generating=true;st.abort=new AbortController();
$('sendBtn').disabled=true;$('stopBtn').style.display='flex';
setStatus('ld','生成中…');showTyping();
const cfg=st.cfg;
const apiUrl=(cfg.apiUrl||'').replace(/\/$/,'');
const token=cfg.apiToken||'';
const model=$('cfgModel').value||cfg.model;
const temperature=parseFloat($('cfgTemp').value)||cfg.temperature||0.7;
const systemPrompt=($('cfgSystem').value||cfg.systemPrompt||'').trim();
const apiMsgs=conv.messages.map(m=>{
if(m.files&&m.files.length>0) return{role:m.role,content:buildApiContent(m.content||'',m.files)};
const content=getTextFromContent(m.content);
return{role:m.role,content:content||'(空消息)'};
});
if(systemPrompt) apiMsgs.unshift({role:'system',content:systemPrompt});
let full='';
try{
const res=await fetch(apiUrl+'/v1/chat/completions',{
method:'POST',
headers:{'Authorization':'Bearer '+token,'Content-Type':'application/json'},
body:JSON.stringify({model,messages:apiMsgs,stream:true,temperature,max_tokens:2147483647}),
signal:st.abort.signal,
});
if(!res.ok){
const e=await res.json().catch(()=>({}));
throw new Error((e.error&&e.error.message)||'HTTP '+res.status);
}
removeTyping();
const mc=createStreamBubble();
const reader=res.body.getReader();
const dec=new TextDecoder('utf-8');
let buf='';
while(true){
const{done,value}=await reader.read();if(done) break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n');buf=lines.pop()||'';
for(const line of lines){
const t=line.trim();if(!t.startsWith('data:')) continue;
const d=t.slice(5).trim();if(d==='[DONE]') continue;
try{
const p=JSON.parse(d);
const delta=p.choices?.[0]?.delta?.content;
if(delta&&typeof delta==='string'){full+=delta;updateStream(mc,full);}
}catch(_){}
}
}
finalizeStream($('streamMC'),full);
const sr=$('streamRow');if(sr) sr.remove();
const aMsg={role:'assistant',content:full,ts:Date.now()};
conv.messages.push(aMsg);await saveConv(conv);
appendMsgDOM(aMsg,conv.messages.length-1,false);
updateStInfo();setStatus('','就绪');
}catch(e){
removeTyping();
const sr=$('streamRow');if(sr) sr.remove();
if(e.name==='AbortError'){
if(full.trim()){
const aMsg={role:'assistant',content:full,ts:Date.now()};
conv.messages.push(aMsg);await saveConv(conv);
appendMsgDOM(aMsg,conv.messages.length-1,false);updateStInfo();
}
setStatus('','已停止');toast('已停止','warning');
}else{
const errRow=document.createElement('div');errRow.className='msg-row assistant';
errRow.innerHTML=`<div class="msg-av assistant">🤖</div>
<div class="msg-body"><div class="msg-bubble assistant" style="border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.06);">
<div class="mc"><p style="color:var(--rd);">❌ ${esc(e.message)}</p></div></div></div>`;
$('msgs').appendChild(errRow);scrollBottom();
setStatus('err','失败');toast(e.message,'error');
}
}finally{
st.generating=false;st.abort=null;
$('sendBtn').disabled=false;$('stopBtn').style.display='none';
}
}
function stopGen(){if(st.abort) st.abort.abort();}
function renderModelSelect(){
const sel=$('cfgModel');const cur=st.cfg.model||'';sel.innerHTML='';
st.models.forEach(m=>{const o=document.createElement('option');o.value=m.id;o.textContent=m.name||m.id;sel.appendChild(o);});
if(cur&&st.models.some(m=>m.id===cur)) sel.value=cur;
else if(st.models.length>0){sel.value=st.models[0].id;st.cfg.model=st.models[0].id;}
}
function renderModelGrid(){
const grid=$('modelGrid');grid.innerHTML='';const cur=$('cfgModel').value||st.cfg.model;
st.models.forEach(m=>{
const chip=document.createElement('div');chip.className='mc2'+(m.id===cur?' sel':'');
chip.innerHTML=`<span class="mc2-name">${esc(m.name||m.id)}</span><span class="mc2-badge">${esc(m.provider||'')}</span>`;
chip.addEventListener('click',()=>{$('cfgModel').value=m.id;st.cfg.model=m.id;renderModelGrid();updateTopbar();});
grid.appendChild(chip);
});
}
async function refreshModels(){
const btn=$('refreshModelsBtn');btn.disabled=true;btn.textContent='⏳ 获取中…';
const url=($('cfgUrl').value||st.cfg.apiUrl||'').replace(/\/$/,'');
const token=$('cfgToken').value||st.cfg.apiToken||'';
if(!url){toast('请先填写 API 地址','warning');btn.disabled=false;btn.textContent='🔄 获取模型';return;}
try{
const res=await fetch(url+'/v1/models',{headers:{'Authorization':'Bearer '+token}});
if(!res.ok) throw new Error('HTTP '+res.status);
const data=await res.json();
let models=[];
if(data.data&&Array.isArray(data.data))
models=data.data.map(m=>({id:m.id,name:m.name||m.id,provider:m.owned_by||m.provider||'Unknown'}));
else if(Array.isArray(data))
models=data.map(m=>({id:m.id,name:m.name||m.id,provider:m.owned_by||m.provider||'Unknown'}));
else if(data.models&&Array.isArray(data.models))
models=data.models.map(m=>({id:m.id||m.name,name:m.name||m.id,provider:m.owned_by||m.provider||'Unknown'}));
if(models.length===0) throw new Error('未获取到模型列表');
st.models=models;await saveModels();
renderModelSelect();renderModelGrid();
toast('已加载 '+st.models.length+' 个模型');
}catch(e){
console.error('refreshModels',e);
toast('获取失败:'+e.message,'error');
}
btn.disabled=false;btn.textContent='🔄 获取模型';
}
async function testConn(){
const btn=$('testConnBtn');btn.disabled=true;btn.textContent='⏳ 测试中…';
const url=($('cfgUrl').value||st.cfg.apiUrl||'').replace(/\/$/,'');
const token=$('cfgToken').value||st.cfg.apiToken||'';
setStatus('ld','测试连接…');
try{
const res=await fetch(url+'/v1/models',{headers:{'Authorization':'Bearer '+token}});
if(res.ok){setStatus('','连接成功');toast('API 连接成功 ✓');}
else throw new Error('HTTP '+res.status);
}catch(e){setStatus('err','连接失败');toast('连接失败:'+e.message,'error');}
btn.disabled=false;btn.textContent='🔌 测试API';
}
async function testHf(){
const btn=$('testHfBtn');btn.disabled=true;btn.textContent='⏳ 测试中…';
const dot=$('hfDot'),txt=$('hfTxt');
dot.className='hf-dot ck';txt.textContent='测试中…';
const repo=$('cfgHfRepo').value.trim();
if(!repo){toast('请先填写 Dataset 仓库','warning');btn.disabled=false;btn.textContent='🔌 测试连接';dot.className='hf-dot';txt.textContent='未配置';return;}
try{
const res=await fetch('https://huggingface.co/api/datasets/'+repo);
if(res.ok){dot.className='hf-dot ok';txt.textContent='连接成功:'+repo+'(公开)';toast('Dataset 连接成功 ✓');}
else throw new Error('HTTP '+res.status);
}catch(e){dot.className='hf-dot err';txt.textContent='连接失败:'+e.message;toast('连接失败:'+e.message,'error');}
btn.disabled=false;btn.textContent='🔌 测试连接';
}
async function syncNow(){
const btn=$('syncNowBtn');btn.disabled=true;btn.textContent='⏳ 同步中…';
setSyncBadge('syncing');
try{
await syncSettingsToHF();
for(const conv of st.convs) await syncConvToHF(conv);
setSyncBadge('ok');toast('已同步 '+st.convs.length+' 个对话');
}catch(e){setSyncBadge('err');toast('同步失败:'+e.message,'error');}
btn.disabled=false;btn.textContent='🔄 立即同步';
}
function openSp(){
syncCfgToUI();renderModelSelect();renderModelGrid();
$('sp').classList.add('open');$('overlay').classList.add('show');
}
function closeSp(){$('sp').classList.remove('open');$('overlay').classList.remove('show');}
async function saveSp(){
const p1=$('cfgPwd1').value,p2=$('cfgPwd2').value;
if(p1){
if(p1!==p2){toast('两次密码不一致','error');return;}
if(p1.length<4){toast('密码至少4位','warning');return;}
st.cfg.password=p1;
}
st.cfg.apiUrl=$('cfgUrl').value.trim();
st.cfg.apiToken=$('cfgToken').value.trim();
st.cfg.model=$('cfgModel').value;
st.cfg.temperature=parseFloat($('cfgTemp').value)||0.7;
st.cfg.systemPrompt=$('cfgSystem').value.trim();
st.cfg.hfToken=$('cfgHfToken').value.trim();
st.cfg.hfRepo=$('cfgHfRepo').value.trim();
await saveCfg();
if(st.cfg.hfToken&&st.cfg.hfRepo){
setSyncBadge('syncing');
try{await syncSettingsToHF();setSyncBadge('ok');}
catch(e){setSyncBadge('err');}
}
updateTopbar();updateHfUI();closeSp();toast('设置已保存');
}
async function clearAllData(){
if(!confirm('确定清空所有本地数据?不可恢复!')) return;
await dbClear('conversations');await dbClear('kv');
sessionStorage.clear();toast('已清空','warning');
setTimeout(()=>location.reload(),1200);
}
function openSidebar(){$('sidebar').classList.add('open');$('overlay').classList.add('show');}
function closeSidebar(){
$('sidebar').classList.remove('open');
if(!$('sp').classList.contains('open')) $('overlay').classList.remove('show');
}
function exportData(){
const payload={version:4,exportedAt:new Date().toISOString(),cfg:{...st.cfg},conversations:st.convs,models:st.models};
const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;
a.download='ai-chat-'+new Date().toISOString().slice(0,10)+'.json';
a.click();URL.revokeObjectURL(url);toast('已导出');
}
function importData(e){
const file=e.target.files[0];if(!file) return;
const reader=new FileReader();
reader.onload=async ev=>{
try{
const data=JSON.parse(ev.target.result);
if(data.cfg) Object.assign(st.cfg,data.cfg);
if(data.conversations) st.convs=data.conversations;
if(data.models&&data.models.length>0) st.models=data.models;
await saveCfg();await saveModels();
for(const c of st.convs) await dbPut('conversations',c);
st.curId=st.convs.length>0?st.convs[st.convs.length-1].id:null;
if(!st.curId) await newConv(false);
renderConvList();renderMsgs();updateTopbar();updateStInfo();toast('数据已导入');
}catch(err){toast('导入失败:格式错误','error');}
e.target.value='';
};
reader.readAsText(file);
}
function bindEvents(){
$('loginBtn').addEventListener('click',doLogin);
$('loginPwd').addEventListener('keydown',e=>{if(e.key==='Enter') doLogin();});
$('menuBtn').addEventListener('click',()=>{$('sidebar').classList.contains('open')?closeSidebar():openSidebar();});
$('overlay').addEventListener('click',()=>{closeSidebar();closeSp();});
$('newChatBtn').addEventListener('click',()=>newConv(true));
$('clearBtn').addEventListener('click',clearConv);
$('themeBtn').addEventListener('click',toggleTheme);
$('settingsBtn').addEventListener('click',openSp);
$('closeSpBtn').addEventListener('click',closeSp);
$('cancelSpBtn').addEventListener('click',closeSp);
$('saveSpBtn').addEventListener('click',saveSp);
$('refreshModelsBtn').addEventListener('click',refreshModels);
$('testConnBtn').addEventListener('click',testConn);
$('testHfBtn').addEventListener('click',testHf);
$('syncNowBtn').addEventListener('click',syncNow);
$('clearAllBtn').addEventListener('click',clearAllData);
$('exportBtn').addEventListener('click',exportData);
$('importBtn').addEventListener('click',()=>$('importJsonInp').click());
$('importJsonInp').addEventListener('change',importData);
$('logoutBtn').addEventListener('click',doLogout);
$('sendBtn').addEventListener('click',sendMessage);
$('stopBtn').addEventListener('click',stopGen);
$('clearInputBtn').addEventListener('click',()=>{$('userInput').value='';$('userInput').style.height='auto';updateCharCount();});
$('userInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}});
$('userInput').addEventListener('input',()=>{autoResize($('userInput'));updateCharCount();});
$('uploadImgBtn').addEventListener('click',()=>$('fileImgInp').click());
$('uploadFileBtn').addEventListener('click',()=>$('fileAnyInp').click());
$('uploadFolderBtn').addEventListener('click',()=>$('fileFolderInp').click());
$('fileImgInp').addEventListener('change',e=>processFiles(Array.from(e.target.files)));
$('fileAnyInp').addEventListener('change',e=>processFiles(Array.from(e.target.files)));
$('fileFolderInp').addEventListener('change',e=>processFiles(Array.from(e.target.files)));
$('searchInp').addEventListener('input',e=>{st.searchQ=e.target.value;renderConvList();});
const ib=$('userInput').closest('.input-box');
ib.addEventListener('dragover',e=>{e.preventDefault();ib.style.borderColor='var(--ach)';});
ib.addEventListener('dragleave',()=>{ib.style.borderColor='';});
ib.addEventListener('drop',e=>{
e.preventDefault();ib.style.borderColor='';
const files=Array.from(e.dataTransfer.files);
if(files.length>0) processFiles(files);
});
$('imgModal').addEventListener('click',()=>$('imgModal').classList.remove('show'));
$('cfgModel').addEventListener('change',()=>{st.cfg.model=$('cfgModel').value;renderModelGrid();updateTopbar();});
document.addEventListener('keydown',e=>{if(e.key==='Escape'){$('imgModal').classList.remove('show');closeSp();}});
window.addEventListener('beforeunload',()=>{if(st.syncQueue.size>0) flushSync();});
}
document.addEventListener('DOMContentLoaded',async()=>{
try{await openDB();}catch(e){console.warn('IndexedDB failed',e);}
try{const cfg=await kvGet('cfg');if(cfg) Object.assign(st.cfg,cfg);}catch(e){console.warn('kvGet failed',e);}
try{bindEvents();}catch(e){console.error('bindEvents error',e);}
try{checkLogin();}catch(e){console.error('checkLogin error',e);}
});
})();
</script>
</body>
</html>