| <!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> |