1 line
2.5 MiB
Plaintext
1 line
2.5 MiB
Plaintext
{"repo": "200011113zdy-collab/neuise-shg-team-ai", "files": {"index.html": {"39da0a803b3802c07a477ca09b6b523858595ffa": {"t": 1785747370.3256397, "n": 96495, "h": "10a4ffc4f5a82e60", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">只填域名部分即可,例如 <code>https://xxxx.ngrok-free.app</code>,无需加 /api/... 后缀。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\"></div>\n <div id=\"usageHint\" style=\"display:none;align-items:center;margin-left:2px\">\n <span style=\"cursor:help;font-size:12px;color:var(--muted);opacity:0.6;position:relative\" id=\"usageHintIcon\" onmouseenter=\"showUsageTip(true)\" onmouseleave=\"showUsageTip(false)\">?\n <span id=\"usageTip\" style=\"display:none;position:absolute;top:22px;right:0;width:240px;background:#333;color:#fff;font-size:12px;line-height:1.8;padding:10px 12px;border-radius:8px;z-index:999;pointer-events:none;white-space:normal\">\n <b>5h 窗口</b>:每5小时滚动重置<br>\n <span id=\"tip5hReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><b>7d 窗口</b>:每7天累计用量<br>\n <span id=\"tip7dReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><span style=\"opacity:0.6\">🟢<50% · 🟡50~80% · 🔴80%+</span>\n </span>\n </span>\n </div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-v4-pro\">DeepSeek V4 Pro(旗舰)</option>\n <option value=\"deepseek|deepseek-v4-pro:think\">DeepSeek V4 Pro(深度思考)</option>\n <option value=\"deepseek|deepseek-v4-flash\">DeepSeek V4 Flash(快速)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nfunction showUsageTip(show) {\n document.getElementById('usageTip').style.display = show ? 'block' : 'none';\n}\n\n// ── 本地 Claude Code 账号用量查询 ──\nlet _usageCache = null;\nlet _usageCacheTime = 0;\nconst USAGE_CACHE_TTL = 3 * 60 * 1000; // 3分钟缓存,避免频繁调用\n\nasync function fetchLocalUsage(badge) {\n try {\n // 先取缓存\n if (_usageCache && Date.now() - _usageCacheTime < USAGE_CACHE_TTL) {\n renderUsageBadge(badge, _usageCache);\n return;\n }\n\n const serverUrl = (localStorage.getItem('local_server_url') || '').replace(/\\/(api\\/(claude|config))\\/?$/, '');\n if (!serverUrl) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 请先在设置中填写服务地址';\n return;\n }\n\n const res = await fetch(serverUrl + '/api/usage', {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const data = await res.json();\n if (data.error) throw new Error(data.error);\n\n _usageCache = data;\n _usageCacheTime = Date.now();\n renderUsageBadge(badge, data);\n\n } catch(e) {\n badge.className = 'balance-badge show err';\n badge.innerHTML = `⚠ 用量获取失败`;\n }\n}\n\nfunction renderUsageBadge(badge, data) {\n document.getElementById('usageHint').style.display = 'flex';\n const fh = data.five_hour;\n const sd = data.seven_day;\n if (!fh) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 暂无用量数据';\n return;\n }\n const pct5h = Math.round(fh.utilization || 0);\n const pct7d = sd ? Math.round(sd.utilization || 0) : null;\n\n // 重置倒计时\n function timeLeft(resetAt) {\n if (!resetAt) return '';\n const diff = new Date(resetAt) - new Date();\n if (diff <= 0) return '即将重置';\n const h = Math.floor(diff / 3600000);\n const m = Math.floor((diff % 3600000) / 60000);\n return h > 0 ? `${h}h${m}m后重置` : `${m}m后重置`;\n }\n\n // 格式化具体时刻\n function resetWallClock(resetAt) {\n if (!resetAt) return '';\n const d = new Date(resetAt);\n const mm = String(d.getMonth()+1).padStart(2,'0');\n const dd = String(d.getDate()).padStart(2,'0');\n const hh = String(d.getHours()).padStart(2,'0');\n const min = String(d.getMinutes()).padStart(2,'0');\n return `重置于 ${mm}/${dd} ${hh}:${min}`;\n }\n\n // 更新 tooltip 里的重置时间\n const el5h = document.getElementById('tip5hReset');\n const el7d = document.getElementById('tip7dReset');\n if (el5h) el5h.textContent = fh.resets_at ? `${timeLeft(fh.resets_at)}(${resetWallClock(fh.resets_at)})` : '';\n if (el7d) el7d.textContent = sd?.resets_at ? `${timeLeft(sd.resets_at)}(${resetWallClock(sd.resets_at)})` : '暂无数据';\n\n const color = pct5h >= 80 ? 'err' : pct5h >= 50 ? 'warn' : 'ok';\n badge.className = `balance-badge show ${color}`;\n\n const bar = (pct) => {\n const filled = Math.round(pct / 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n };\n\n let html = `📊 5h: ${bar(pct5h)} ${pct5h}% <span style=\"font-size:10px;opacity:0.7\">${timeLeft(fh.resets_at)}</span>`;\n if (pct7d !== null) {\n const color7d = pct7d >= 80 ? '#e05050' : pct7d >= 50 ? '#a07020' : 'inherit';\n html += ` | 7d: <span style=\"color:${color7d}\">${pct7d}%</span> <span style=\"font-size:10px;opacity:0.7\">${timeLeft(sd.resets_at)}</span>`;\n }\n badge.innerHTML = html;\n}\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 本地模式:查询真实用量\n if (provider === 'local') {\n badge.className = 'balance-badge show';\n badge.innerHTML = '📊 查询用量…';\n fetchLocalUsage(badge);\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n document.getElementById('usageHint').style.display = 'none';\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n \n // ✨ 新增:检测文件数量,超过1个则自动生成“一键打包”按钮\n setTimeout(() => {\n const fileLinks = bubble.querySelectorAll('.file-dl-btn');\n if (fileLinks.length >= 2) {\n const zipWrap = document.createElement('div');\n zipWrap.style.cssText = 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border); display:flex; justify-content:flex-end;';\n const zipBtn = document.createElement('button');\n zipBtn.style.cssText = 'display:inline-flex; align-items:center; gap:6px; padding:6px 12px; background:var(--accent); color:#fff; border:none; border-radius:6px; cursor:pointer; font-size:12px; font-weight:500; transition:opacity 0.15s;';\n zipBtn.onmouseover = () => zipBtn.style.opacity = '0.85';\n zipBtn.onmouseout = () => zipBtn.style.opacity = '1';\n zipBtn.innerHTML = '📦 一键打包下载全部 (.zip)';\n zipBtn.onclick = () => downloadAllAsZip(zipBtn);\n zipWrap.appendChild(zipBtn);\n bubble.appendChild(zipWrap);\n }\n }, 100); // 延迟 100ms,等待前台的 Word 文档异步 Blob 构建完成\n\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // 判断是否为深度思考模式(模型名含 :think 后缀)\n const thinkMode = model.endsWith(':think');\n const realModel = thinkMode ? model.replace(':think','') : model;\n\n const body={\n model: realModel,\n max_tokens: 8192,\n temperature: thinkMode ? 1 : params.temperature, // 思考模式官方建议 temperature=1\n messages:[{role:'system',content:`你是DeepSeek开发的AI助手。你当前运行的模型版本是 ${realModel}${thinkMode?' (深度思考模式)':''}。这是由调用方API确认的事实,如果用户问你是什么模型或是否是V4,请直接告知你是 ${realModel},不要否认或模糊回答。请用简洁清晰的中文回答问题。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)],\n };\n\n // 深度思考模式加入 thinking 参数\n if(thinkMode){\n body.thinking = { type: 'enabled' };\n body.reasoning_effort = 'high';\n }\n\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填任意后缀:/api/claude、/api/config、或纯域名\n const baseUrl = serverUrl.replace(/\\/(api\\/(claude|config))\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data, type: f.type, kind: f.kind });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\n// ── 📦 前端一键打包 ZIP 功能 ──\nasync function downloadAllAsZip(btn) {\n const bubble = btn.closest('.bubble');\n // 抓取该气泡内所有有效的下载按钮(排除还在生成中的)\n const links = Array.from(bubble.querySelectorAll('.file-dl-btn')).filter(a => !a.hasAttribute('disabled') && a.href);\n if (links.length === 0) return;\n\n const originalText = btn.innerHTML;\n btn.innerHTML = '⏳ 正在压缩打包中...';\n btn.style.pointerEvents = 'none';\n btn.style.opacity = '0.7';\n\n try {\n const zip = new JSZip();\n for (const link of links) {\n const filename = link.getAttribute('download');\n const href = link.getAttribute('href');\n\n if (href.startsWith('blob:')) {\n // 处理 Word 文档等以 Blob 对象 URL 存在的格式\n const response = await fetch(href);\n const blob = await response.blob();\n zip.file(filename, blob);\n } else if (href.startsWith('data:')) {\n // 处理纯文本或 Base64 的 Excel、图片等\n const arr = href.split(',');\n const isBase64 = arr[0].includes('base64');\n const data = arr[1];\n if (isBase64) {\n zip.file(filename, data, {base64: true});\n } else {\n zip.file(filename, decodeURIComponent(data));\n }\n }\n }\n\n // 内存中生成 ZIP 文件并触发下载\n const content = await zip.generateAsync({type: \"blob\"});\n const dlLink = document.createElement('a');\n dlLink.href = URL.createObjectURL(content);\n \n // 生成带时间戳的文件名\n const d = new Date();\n const timeStr = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}_${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}`;\n dlLink.download = `项目文件打包_${timeStr}.zip`;\n \n document.body.appendChild(dlLink);\n dlLink.click();\n document.body.removeChild(dlLink);\n\n } catch (e) {\n console.error(\"打包失败:\", e);\n alert(\"打包失败,请尝试逐个点击下载\");\n } finally {\n btn.innerHTML = '✅ 打包完成!';\n setTimeout(() => {\n btn.innerHTML = originalText;\n btn.style.pointerEvents = 'auto';\n btn.style.opacity = '1';\n }, 2000);\n }\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "77391ce1621e9e2d8082c0592aa36b253bcea33d": {"t": 1785772869.12987, "n": 96423, "h": "f0a54d7af7dc8d89", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">只填域名部分即可,例如 <code>https://xxxx.ngrok-free.app</code>,无需加 /api/... 后缀。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\"></div>\n <div id=\"usageHint\" style=\"display:none;align-items:center;margin-left:2px\">\n <span style=\"cursor:help;font-size:12px;color:var(--muted);opacity:0.6;position:relative\" id=\"usageHintIcon\" onmouseenter=\"showUsageTip(true)\" onmouseleave=\"showUsageTip(false)\">?\n <span id=\"usageTip\" style=\"display:none;position:absolute;top:22px;right:0;width:240px;background:#333;color:#fff;font-size:12px;line-height:1.8;padding:10px 12px;border-radius:8px;z-index:999;pointer-events:none;white-space:normal\">\n <b>5h 窗口</b>:每5小时滚动重置<br>\n <span id=\"tip5hReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><b>7d 窗口</b>:每7天累计用量<br>\n <span id=\"tip7dReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><span style=\"opacity:0.6\">🟢<50% · 🟡50~80% · 🔴80%+</span>\n </span>\n </span>\n </div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-v4-pro\">DeepSeek V4 Pro(旗舰)</option>\n <option value=\"deepseek|deepseek-v4-pro:think\">DeepSeek V4 Pro(深度思考)</option>\n <option value=\"deepseek|deepseek-v4-flash\">DeepSeek V4 Flash(快速)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nfunction showUsageTip(show) {\n document.getElementById('usageTip').style.display = show ? 'block' : 'none';\n}\n\n// ── 本地 Claude Code 账号用量查询 ──\nlet _usageCache = null;\nlet _usageCacheTime = 0;\nconst USAGE_CACHE_TTL = 3 * 60 * 1000; // 3分钟缓存,避免频繁调用\n\nasync function fetchLocalUsage(badge) {\n try {\n // 先取缓存\n if (_usageCache && Date.now() - _usageCacheTime < USAGE_CACHE_TTL) {\n renderUsageBadge(badge, _usageCache);\n return;\n }\n\n const serverUrl = (localStorage.getItem('local_server_url') || '').replace(/\\/(api\\/(claude|config))\\/?$/, '');\n if (!serverUrl) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 请先在设置中填写服务地址';\n return;\n }\n\n const res = await fetch(serverUrl + '/api/usage', {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const data = await res.json();\n if (data.error) throw new Error(data.error);\n\n _usageCache = data;\n _usageCacheTime = Date.now();\n renderUsageBadge(badge, data);\n\n } catch(e) {\n badge.className = 'balance-badge show err';\n badge.innerHTML = `⚠ 用量获取失败`;\n }\n}\n\nfunction renderUsageBadge(badge, data) {\n document.getElementById('usageHint').style.display = 'flex';\n const fh = data.five_hour;\n const sd = data.seven_day;\n if (!fh) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 暂无用量数据';\n return;\n }\n const pct5h = Math.round(fh.utilization || 0);\n const pct7d = sd ? Math.round(sd.utilization || 0) : null;\n\n // 重置倒计时\n function timeLeft(resetAt) {\n if (!resetAt) return '';\n const diff = new Date(resetAt) - new Date();\n if (diff <= 0) return '即将重置';\n const h = Math.floor(diff / 3600000);\n const m = Math.floor((diff % 3600000) / 60000);\n return h > 0 ? `${h}h${m}m后重置` : `${m}m后重置`;\n }\n\n // 格式化具体时刻\n function resetWallClock(resetAt) {\n if (!resetAt) return '';\n const d = new Date(resetAt);\n const mm = String(d.getMonth()+1).padStart(2,'0');\n const dd = String(d.getDate()).padStart(2,'0');\n const hh = String(d.getHours()).padStart(2,'0');\n const min = String(d.getMinutes()).padStart(2,'0');\n return `重置于 ${mm}/${dd} ${hh}:${min}`;\n }\n\n // 更新 tooltip 里的重置时间\n const el5h = document.getElementById('tip5hReset');\n const el7d = document.getElementById('tip7dReset');\n if (el5h) el5h.textContent = fh.resets_at ? `${timeLeft(fh.resets_at)}(${resetWallClock(fh.resets_at)})` : '';\n if (el7d) el7d.textContent = sd?.resets_at ? `${timeLeft(sd.resets_at)}(${resetWallClock(sd.resets_at)})` : '暂无数据';\n\n const color = pct5h >= 80 ? 'err' : pct5h >= 50 ? 'warn' : 'ok';\n badge.className = `balance-badge show ${color}`;\n\n const bar = (pct) => {\n const filled = Math.round(pct / 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n };\n\n let html = `📊 5h: ${bar(pct5h)} ${pct5h}% <span style=\"font-size:10px;opacity:0.7\">${timeLeft(fh.resets_at)}</span>`;\n if (pct7d !== null) {\n const color7d = pct7d >= 80 ? '#e05050' : pct7d >= 50 ? '#a07020' : 'inherit';\n html += ` | 7d: <span style=\"color:${color7d}\">${pct7d}%</span> <span style=\"font-size:10px;opacity:0.7\">${timeLeft(sd.resets_at)}</span>`;\n }\n badge.innerHTML = html;\n}\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 本地模式:查询真实用量\n if (provider === 'local') {\n badge.className = 'balance-badge show';\n badge.innerHTML = '📊 查询用量…';\n fetchLocalUsage(badge);\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n document.getElementById('usageHint').style.display = 'none';\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n \n // ✨ 新增:检测文件数量,超过1个则自动生成“一键打包”按钮\n setTimeout(() => {\n const fileLinks = bubble.querySelectorAll('.file-dl-btn');\n if (fileLinks.length >= 2) {\n const zipWrap = document.createElement('div');\n zipWrap.style.cssText = 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border); display:flex; justify-content:flex-end;';\n const zipBtn = document.createElement('button');\n zipBtn.style.cssText = 'display:inline-flex; align-items:center; gap:6px; padding:6px 12px; background:var(--accent); color:#fff; border:none; border-radius:6px; cursor:pointer; font-size:12px; font-weight:500; transition:opacity 0.15s;';\n zipBtn.onmouseover = () => zipBtn.style.opacity = '0.85';\n zipBtn.onmouseout = () => zipBtn.style.opacity = '1';\n zipBtn.innerHTML = '📦 一键打包下载全部 (.zip)';\n zipBtn.onclick = () => downloadAllAsZip(zipBtn);\n zipWrap.appendChild(zipBtn);\n bubble.appendChild(zipWrap);\n }\n }, 100); // 延迟 100ms,等待前台的 Word 文档异步 Blob 构建完成\n\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // 判断是否为深度思考模式(模型名含 :think 后缀)\n const thinkMode = model.endsWith(':think');\n const realModel = thinkMode ? model.replace(':think','') : model;\n\n const body={\n model: realModel,\n max_tokens: 8192,\n temperature: thinkMode ? 1 : params.temperature, // 思考模式官方建议 temperature=1\n messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${realModel}${thinkMode?' (深度思考)':''}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)],\n };\n\n // 深度思考模式加入 thinking 参数\n if(thinkMode){\n body.thinking = { type: 'enabled' };\n body.reasoning_effort = 'high';\n }\n\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填任意后缀:/api/claude、/api/config、或纯域名\n const baseUrl = serverUrl.replace(/\\/(api\\/(claude|config))\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data, type: f.type, kind: f.kind });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\n// ── 📦 前端一键打包 ZIP 功能 ──\nasync function downloadAllAsZip(btn) {\n const bubble = btn.closest('.bubble');\n // 抓取该气泡内所有有效的下载按钮(排除还在生成中的)\n const links = Array.from(bubble.querySelectorAll('.file-dl-btn')).filter(a => !a.hasAttribute('disabled') && a.href);\n if (links.length === 0) return;\n\n const originalText = btn.innerHTML;\n btn.innerHTML = '⏳ 正在压缩打包中...';\n btn.style.pointerEvents = 'none';\n btn.style.opacity = '0.7';\n\n try {\n const zip = new JSZip();\n for (const link of links) {\n const filename = link.getAttribute('download');\n const href = link.getAttribute('href');\n\n if (href.startsWith('blob:')) {\n // 处理 Word 文档等以 Blob 对象 URL 存在的格式\n const response = await fetch(href);\n const blob = await response.blob();\n zip.file(filename, blob);\n } else if (href.startsWith('data:')) {\n // 处理纯文本或 Base64 的 Excel、图片等\n const arr = href.split(',');\n const isBase64 = arr[0].includes('base64');\n const data = arr[1];\n if (isBase64) {\n zip.file(filename, data, {base64: true});\n } else {\n zip.file(filename, decodeURIComponent(data));\n }\n }\n }\n\n // 内存中生成 ZIP 文件并触发下载\n const content = await zip.generateAsync({type: \"blob\"});\n const dlLink = document.createElement('a');\n dlLink.href = URL.createObjectURL(content);\n \n // 生成带时间戳的文件名\n const d = new Date();\n const timeStr = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}_${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}`;\n dlLink.download = `项目文件打包_${timeStr}.zip`;\n \n document.body.appendChild(dlLink);\n dlLink.click();\n document.body.removeChild(dlLink);\n\n } catch (e) {\n console.error(\"打包失败:\", e);\n alert(\"打包失败,请尝试逐个点击下载\");\n } finally {\n btn.innerHTML = '✅ 打包完成!';\n setTimeout(() => {\n btn.innerHTML = originalText;\n btn.style.pointerEvents = 'auto';\n btn.style.opacity = '1';\n }, 2000);\n }\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "40904580e6c01115d78284e2f0f39ab2667e355c": {"t": 1785772872.9397912, "n": 95985, "h": "d638b64c9c3fb793", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">只填域名部分即可,例如 <code>https://xxxx.ngrok-free.app</code>,无需加 /api/... 后缀。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\"></div>\n <div id=\"usageHint\" style=\"display:none;align-items:center;margin-left:2px\">\n <span style=\"cursor:help;font-size:12px;color:var(--muted);opacity:0.6;position:relative\" id=\"usageHintIcon\" onmouseenter=\"showUsageTip(true)\" onmouseleave=\"showUsageTip(false)\">?\n <span id=\"usageTip\" style=\"display:none;position:absolute;top:22px;right:0;width:240px;background:#333;color:#fff;font-size:12px;line-height:1.8;padding:10px 12px;border-radius:8px;z-index:999;pointer-events:none;white-space:normal\">\n <b>5h 窗口</b>:每5小时滚动重置<br>\n <span id=\"tip5hReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><b>7d 窗口</b>:每7天累计用量<br>\n <span id=\"tip7dReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><span style=\"opacity:0.6\">🟢<50% · 🟡50~80% · 🔴80%+</span>\n </span>\n </span>\n </div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nfunction showUsageTip(show) {\n document.getElementById('usageTip').style.display = show ? 'block' : 'none';\n}\n\n// ── 本地 Claude Code 账号用量查询 ──\nlet _usageCache = null;\nlet _usageCacheTime = 0;\nconst USAGE_CACHE_TTL = 3 * 60 * 1000; // 3分钟缓存,避免频繁调用\n\nasync function fetchLocalUsage(badge) {\n try {\n // 先取缓存\n if (_usageCache && Date.now() - _usageCacheTime < USAGE_CACHE_TTL) {\n renderUsageBadge(badge, _usageCache);\n return;\n }\n\n const serverUrl = (localStorage.getItem('local_server_url') || '').replace(/\\/(api\\/(claude|config))\\/?$/, '');\n if (!serverUrl) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 请先在设置中填写服务地址';\n return;\n }\n\n const res = await fetch(serverUrl + '/api/usage', {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const data = await res.json();\n if (data.error) throw new Error(data.error);\n\n _usageCache = data;\n _usageCacheTime = Date.now();\n renderUsageBadge(badge, data);\n\n } catch(e) {\n badge.className = 'balance-badge show err';\n badge.innerHTML = `⚠ 用量获取失败`;\n }\n}\n\nfunction renderUsageBadge(badge, data) {\n document.getElementById('usageHint').style.display = 'flex';\n const fh = data.five_hour;\n const sd = data.seven_day;\n if (!fh) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 暂无用量数据';\n return;\n }\n const pct5h = Math.round(fh.utilization || 0);\n const pct7d = sd ? Math.round(sd.utilization || 0) : null;\n\n // 重置倒计时\n function timeLeft(resetAt) {\n if (!resetAt) return '';\n const diff = new Date(resetAt) - new Date();\n if (diff <= 0) return '即将重置';\n const h = Math.floor(diff / 3600000);\n const m = Math.floor((diff % 3600000) / 60000);\n return h > 0 ? `${h}h${m}m后重置` : `${m}m后重置`;\n }\n\n // 格式化具体时刻\n function resetWallClock(resetAt) {\n if (!resetAt) return '';\n const d = new Date(resetAt);\n const mm = String(d.getMonth()+1).padStart(2,'0');\n const dd = String(d.getDate()).padStart(2,'0');\n const hh = String(d.getHours()).padStart(2,'0');\n const min = String(d.getMinutes()).padStart(2,'0');\n return `重置于 ${mm}/${dd} ${hh}:${min}`;\n }\n\n // 更新 tooltip 里的重置时间\n const el5h = document.getElementById('tip5hReset');\n const el7d = document.getElementById('tip7dReset');\n if (el5h) el5h.textContent = fh.resets_at ? `${timeLeft(fh.resets_at)}(${resetWallClock(fh.resets_at)})` : '';\n if (el7d) el7d.textContent = sd?.resets_at ? `${timeLeft(sd.resets_at)}(${resetWallClock(sd.resets_at)})` : '暂无数据';\n\n const color = pct5h >= 80 ? 'err' : pct5h >= 50 ? 'warn' : 'ok';\n badge.className = `balance-badge show ${color}`;\n\n const bar = (pct) => {\n const filled = Math.round(pct / 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n };\n\n let html = `📊 5h: ${bar(pct5h)} ${pct5h}% <span style=\"font-size:10px;opacity:0.7\">${timeLeft(fh.resets_at)}</span>`;\n if (pct7d !== null) {\n const color7d = pct7d >= 80 ? '#e05050' : pct7d >= 50 ? '#a07020' : 'inherit';\n html += ` | 7d: <span style=\"color:${color7d}\">${pct7d}%</span> <span style=\"font-size:10px;opacity:0.7\">${timeLeft(sd.resets_at)}</span>`;\n }\n badge.innerHTML = html;\n}\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 本地模式:查询真实用量\n if (provider === 'local') {\n badge.className = 'balance-badge show';\n badge.innerHTML = '📊 查询用量…';\n fetchLocalUsage(badge);\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n document.getElementById('usageHint').style.display = 'none';\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n \n // ✨ 新增:检测文件数量,超过1个则自动生成“一键打包”按钮\n setTimeout(() => {\n const fileLinks = bubble.querySelectorAll('.file-dl-btn');\n if (fileLinks.length >= 2) {\n const zipWrap = document.createElement('div');\n zipWrap.style.cssText = 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border); display:flex; justify-content:flex-end;';\n const zipBtn = document.createElement('button');\n zipBtn.style.cssText = 'display:inline-flex; align-items:center; gap:6px; padding:6px 12px; background:var(--accent); color:#fff; border:none; border-radius:6px; cursor:pointer; font-size:12px; font-weight:500; transition:opacity 0.15s;';\n zipBtn.onmouseover = () => zipBtn.style.opacity = '0.85';\n zipBtn.onmouseout = () => zipBtn.style.opacity = '1';\n zipBtn.innerHTML = '📦 一键打包下载全部 (.zip)';\n zipBtn.onclick = () => downloadAllAsZip(zipBtn);\n zipWrap.appendChild(zipBtn);\n bubble.appendChild(zipWrap);\n }\n }, 100); // 延迟 100ms,等待前台的 Word 文档异步 Blob 构建完成\n\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填任意后缀:/api/claude、/api/config、或纯域名\n const baseUrl = serverUrl.replace(/\\/(api\\/(claude|config))\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data, type: f.type, kind: f.kind });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\n// ── 📦 前端一键打包 ZIP 功能 ──\nasync function downloadAllAsZip(btn) {\n const bubble = btn.closest('.bubble');\n // 抓取该气泡内所有有效的下载按钮(排除还在生成中的)\n const links = Array.from(bubble.querySelectorAll('.file-dl-btn')).filter(a => !a.hasAttribute('disabled') && a.href);\n if (links.length === 0) return;\n\n const originalText = btn.innerHTML;\n btn.innerHTML = '⏳ 正在压缩打包中...';\n btn.style.pointerEvents = 'none';\n btn.style.opacity = '0.7';\n\n try {\n const zip = new JSZip();\n for (const link of links) {\n const filename = link.getAttribute('download');\n const href = link.getAttribute('href');\n\n if (href.startsWith('blob:')) {\n // 处理 Word 文档等以 Blob 对象 URL 存在的格式\n const response = await fetch(href);\n const blob = await response.blob();\n zip.file(filename, blob);\n } else if (href.startsWith('data:')) {\n // 处理纯文本或 Base64 的 Excel、图片等\n const arr = href.split(',');\n const isBase64 = arr[0].includes('base64');\n const data = arr[1];\n if (isBase64) {\n zip.file(filename, data, {base64: true});\n } else {\n zip.file(filename, decodeURIComponent(data));\n }\n }\n }\n\n // 内存中生成 ZIP 文件并触发下载\n const content = await zip.generateAsync({type: \"blob\"});\n const dlLink = document.createElement('a');\n dlLink.href = URL.createObjectURL(content);\n \n // 生成带时间戳的文件名\n const d = new Date();\n const timeStr = `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}_${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}`;\n dlLink.download = `项目文件打包_${timeStr}.zip`;\n \n document.body.appendChild(dlLink);\n dlLink.click();\n document.body.removeChild(dlLink);\n\n } catch (e) {\n console.error(\"打包失败:\", e);\n alert(\"打包失败,请尝试逐个点击下载\");\n } finally {\n btn.innerHTML = '✅ 打包完成!';\n setTimeout(() => {\n btn.innerHTML = originalText;\n btn.style.pointerEvents = 'auto';\n btn.style.opacity = '1';\n }, 2000);\n }\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "c6a83286fddf526fca9328187d0072d381be0128": {"t": 1785772876.0021493, "n": 92869, "h": "a5073e383b29a0d3", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">只填域名部分即可,例如 <code>https://xxxx.ngrok-free.app</code>,无需加 /api/... 后缀。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\"></div>\n <div id=\"usageHint\" style=\"display:none;align-items:center;margin-left:2px\">\n <span style=\"cursor:help;font-size:12px;color:var(--muted);opacity:0.6;position:relative\" id=\"usageHintIcon\" onmouseenter=\"showUsageTip(true)\" onmouseleave=\"showUsageTip(false)\">?\n <span id=\"usageTip\" style=\"display:none;position:absolute;top:22px;right:0;width:240px;background:#333;color:#fff;font-size:12px;line-height:1.8;padding:10px 12px;border-radius:8px;z-index:999;pointer-events:none;white-space:normal\">\n <b>5h 窗口</b>:每5小时滚动重置<br>\n <span id=\"tip5hReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><b>7d 窗口</b>:每7天累计用量<br>\n <span id=\"tip7dReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><span style=\"opacity:0.6\">🟢<50% · 🟡50~80% · 🔴80%+</span>\n </span>\n </span>\n </div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nfunction showUsageTip(show) {\n document.getElementById('usageTip').style.display = show ? 'block' : 'none';\n}\n\n// ── 本地 Claude Code 账号用量查询 ──\nlet _usageCache = null;\nlet _usageCacheTime = 0;\nconst USAGE_CACHE_TTL = 3 * 60 * 1000; // 3分钟缓存,避免频繁调用\n\nasync function fetchLocalUsage(badge) {\n try {\n // 先取缓存\n if (_usageCache && Date.now() - _usageCacheTime < USAGE_CACHE_TTL) {\n renderUsageBadge(badge, _usageCache);\n return;\n }\n\n const serverUrl = (localStorage.getItem('local_server_url') || '').replace(/\\/(api\\/(claude|config))\\/?$/, '');\n if (!serverUrl) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 请先在设置中填写服务地址';\n return;\n }\n\n const res = await fetch(serverUrl + '/api/usage', {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const data = await res.json();\n if (data.error) throw new Error(data.error);\n\n _usageCache = data;\n _usageCacheTime = Date.now();\n renderUsageBadge(badge, data);\n\n } catch(e) {\n badge.className = 'balance-badge show err';\n badge.innerHTML = `⚠ 用量获取失败`;\n }\n}\n\nfunction renderUsageBadge(badge, data) {\n document.getElementById('usageHint').style.display = 'flex';\n const fh = data.five_hour;\n const sd = data.seven_day;\n if (!fh) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 暂无用量数据';\n return;\n }\n const pct5h = Math.round(fh.utilization || 0);\n const pct7d = sd ? Math.round(sd.utilization || 0) : null;\n\n // 重置倒计时\n function timeLeft(resetAt) {\n if (!resetAt) return '';\n const diff = new Date(resetAt) - new Date();\n if (diff <= 0) return '即将重置';\n const h = Math.floor(diff / 3600000);\n const m = Math.floor((diff % 3600000) / 60000);\n return h > 0 ? `${h}h${m}m后重置` : `${m}m后重置`;\n }\n\n // 格式化具体时刻\n function resetWallClock(resetAt) {\n if (!resetAt) return '';\n const d = new Date(resetAt);\n const mm = String(d.getMonth()+1).padStart(2,'0');\n const dd = String(d.getDate()).padStart(2,'0');\n const hh = String(d.getHours()).padStart(2,'0');\n const min = String(d.getMinutes()).padStart(2,'0');\n return `重置于 ${mm}/${dd} ${hh}:${min}`;\n }\n\n // 更新 tooltip 里的重置时间\n const el5h = document.getElementById('tip5hReset');\n const el7d = document.getElementById('tip7dReset');\n if (el5h) el5h.textContent = fh.resets_at ? `${timeLeft(fh.resets_at)}(${resetWallClock(fh.resets_at)})` : '';\n if (el7d) el7d.textContent = sd?.resets_at ? `${timeLeft(sd.resets_at)}(${resetWallClock(sd.resets_at)})` : '暂无数据';\n\n const color = pct5h >= 80 ? 'err' : pct5h >= 50 ? 'warn' : 'ok';\n badge.className = `balance-badge show ${color}`;\n\n const bar = (pct) => {\n const filled = Math.round(pct / 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n };\n\n let html = `📊 5h: ${bar(pct5h)} ${pct5h}% <span style=\"font-size:10px;opacity:0.7\">${timeLeft(fh.resets_at)}</span>`;\n if (pct7d !== null) {\n const color7d = pct7d >= 80 ? '#e05050' : pct7d >= 50 ? '#a07020' : 'inherit';\n html += ` | 7d: <span style=\"color:${color7d}\">${pct7d}%</span> <span style=\"font-size:10px;opacity:0.7\">${timeLeft(sd.resets_at)}</span>`;\n }\n badge.innerHTML = html;\n}\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 本地模式:查询真实用量\n if (provider === 'local') {\n badge.className = 'balance-badge show';\n badge.innerHTML = '📊 查询用量…';\n fetchLocalUsage(badge);\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n document.getElementById('usageHint').style.display = 'none';\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填任意后缀:/api/claude、/api/config、或纯域名\n const baseUrl = serverUrl.replace(/\\/(api\\/(claude|config))\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data, type: f.type, kind: f.kind });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "ad5662892531ec7b3c320d8d80139758fe96e833": {"t": 1785772878.2106578, "n": 92841, "h": "3211456a440fd9ec", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">只填域名部分即可,例如 <code>https://xxxx.ngrok-free.app</code>,无需加 /api/... 后缀。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\"></div>\n <div id=\"usageHint\" style=\"display:none;align-items:center;margin-left:2px\">\n <span style=\"cursor:help;font-size:12px;color:var(--muted);opacity:0.6;position:relative\" id=\"usageHintIcon\" onmouseenter=\"showUsageTip(true)\" onmouseleave=\"showUsageTip(false)\">?\n <span id=\"usageTip\" style=\"display:none;position:absolute;top:22px;right:0;width:240px;background:#333;color:#fff;font-size:12px;line-height:1.8;padding:10px 12px;border-radius:8px;z-index:999;pointer-events:none;white-space:normal\">\n <b>5h 窗口</b>:每5小时滚动重置<br>\n <span id=\"tip5hReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><b>7d 窗口</b>:每7天累计用量<br>\n <span id=\"tip7dReset\" style=\"opacity:0.75;font-size:11px\"></span>\n <br><span style=\"opacity:0.6\">🟢<50% · 🟡50~80% · 🔴80%+</span>\n </span>\n </span>\n </div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nfunction showUsageTip(show) {\n document.getElementById('usageTip').style.display = show ? 'block' : 'none';\n}\n\n// ── 本地 Claude Code 账号用量查询 ──\nlet _usageCache = null;\nlet _usageCacheTime = 0;\nconst USAGE_CACHE_TTL = 3 * 60 * 1000; // 3分钟缓存,避免频繁调用\n\nasync function fetchLocalUsage(badge) {\n try {\n // 先取缓存\n if (_usageCache && Date.now() - _usageCacheTime < USAGE_CACHE_TTL) {\n renderUsageBadge(badge, _usageCache);\n return;\n }\n\n const serverUrl = (localStorage.getItem('local_server_url') || '').replace(/\\/(api\\/(claude|config))\\/?$/, '');\n if (!serverUrl) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 请先在设置中填写服务地址';\n return;\n }\n\n const res = await fetch(serverUrl + '/api/usage', {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const data = await res.json();\n if (data.error) throw new Error(data.error);\n\n _usageCache = data;\n _usageCacheTime = Date.now();\n renderUsageBadge(badge, data);\n\n } catch(e) {\n badge.className = 'balance-badge show err';\n badge.innerHTML = `⚠ 用量获取失败`;\n }\n}\n\nfunction renderUsageBadge(badge, data) {\n document.getElementById('usageHint').style.display = 'flex';\n const fh = data.five_hour;\n const sd = data.seven_day;\n if (!fh) {\n badge.className = 'balance-badge show warn';\n badge.innerHTML = '⚠ 暂无用量数据';\n return;\n }\n const pct5h = Math.round(fh.utilization || 0);\n const pct7d = sd ? Math.round(sd.utilization || 0) : null;\n\n // 重置倒计时\n function timeLeft(resetAt) {\n if (!resetAt) return '';\n const diff = new Date(resetAt) - new Date();\n if (diff <= 0) return '即将重置';\n const h = Math.floor(diff / 3600000);\n const m = Math.floor((diff % 3600000) / 60000);\n return h > 0 ? `${h}h${m}m后重置` : `${m}m后重置`;\n }\n\n // 格式化具体时刻\n function resetWallClock(resetAt) {\n if (!resetAt) return '';\n const d = new Date(resetAt);\n const mm = String(d.getMonth()+1).padStart(2,'0');\n const dd = String(d.getDate()).padStart(2,'0');\n const hh = String(d.getHours()).padStart(2,'0');\n const min = String(d.getMinutes()).padStart(2,'0');\n return `重置于 ${mm}/${dd} ${hh}:${min}`;\n }\n\n // 更新 tooltip 里的重置时间\n const el5h = document.getElementById('tip5hReset');\n const el7d = document.getElementById('tip7dReset');\n if (el5h) el5h.textContent = fh.resets_at ? `${timeLeft(fh.resets_at)}(${resetWallClock(fh.resets_at)})` : '';\n if (el7d) el7d.textContent = sd?.resets_at ? `${timeLeft(sd.resets_at)}(${resetWallClock(sd.resets_at)})` : '暂无数据';\n\n const color = pct5h >= 80 ? 'err' : pct5h >= 50 ? 'warn' : 'ok';\n badge.className = `balance-badge show ${color}`;\n\n const bar = (pct) => {\n const filled = Math.round(pct / 10);\n return '█'.repeat(filled) + '░'.repeat(10 - filled);\n };\n\n let html = `📊 5h: ${bar(pct5h)} ${pct5h}% <span style=\"font-size:10px;opacity:0.7\">${timeLeft(fh.resets_at)}</span>`;\n if (pct7d !== null) {\n const color7d = pct7d >= 80 ? '#e05050' : pct7d >= 50 ? '#a07020' : 'inherit';\n html += ` | 7d: <span style=\"color:${color7d}\">${pct7d}%</span> <span style=\"font-size:10px;opacity:0.7\">${timeLeft(sd.resets_at)}</span>`;\n }\n badge.innerHTML = html;\n}\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 本地模式:查询真实用量\n if (provider === 'local') {\n badge.className = 'balance-badge show';\n badge.innerHTML = '📊 查询用量…';\n fetchLocalUsage(badge);\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n document.getElementById('usageHint').style.display = 'none';\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填任意后缀:/api/claude、/api/config、或纯域名\n const baseUrl = serverUrl.replace(/\\/(api\\/(claude|config))\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "48c023f0616901f9d48a3ab3b2c90c6b9b2b9d8e": {"t": 1785772879.896738, "n": 88643, "h": "1c841214c7772f0f", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app/api/claude\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">填写服务提供者给你的地址。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填的是 /api/claude 结尾或只填了域名的情况\n const baseUrl = serverUrl.replace(/\\/api\\/claude\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "b15f095b2dbd60732890421af226fed71b32453d": {"t": 1785772881.9832075, "n": 88657, "h": "81886c56593fd299", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">服务地址</div>\n <input class=\"modal-input\" id=\"local-server-url\" type=\"text\" placeholder=\"https://xxxx.ngrok-free.app/api/claude\">\n <div class=\"model-note\" style=\"margin-bottom:12px\">填写服务提供者给你的地址(每次服务重启地址可能变化)。</div>\n <div class=\"field-label\">访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">密码和地址缺一不可,只保存在你自己的浏览器里。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('local-server-url').value=localStorage.getItem('local_server_url')||'';\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存本地服务地址\n const serverUrl = document.getElementById('local-server-url').value.trim();\n localStorage.setItem('local_server_url', serverUrl);\n _claudeProxyUrl = null; // 地址变了,清除缓存\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 从用户填写的地址获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const serverUrl = localStorage.getItem('local_server_url') || '';\n if (!serverUrl) throw new Error('请在设置 → 本地服务Claude 中填写服务地址');\n // 兼容用户填的是 /api/claude 结尾或只填了域名的情况\n const baseUrl = serverUrl.replace(/\\/api\\/claude\\/?$/, '');\n const configUrl = baseUrl + '/api/config';\n const res = await fetch(configUrl, {\n headers: {\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || ''),\n 'ngrok-skip-browser-warning': 'true'\n }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认地址和密码是否正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "c559dfa9a4cacd8b21a9fd54f74c2932657d9cc8": {"t": 1785772883.8151133, "n": 87820, "h": "c1295d594e4ac7ce", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n // reasoning 只保留 STEPS 步骤提示,不放 Claude Code 原始输出(避免与回复重复)\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || '';\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "675faaeeb5f3435ac1f70466944eb01ee4bb04be": {"t": 1785772885.7495747, "n": 88049, "h": "47f6c662ee27af13", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 在 typing bubble 里插入实时进度面板\n const typingWrap = document.getElementById('typing-wrap');\n const currentBubble = typingWrap ? typingWrap.querySelector('.bubble.ai') : null;\n let progressEl = null;\n let progressLines = [];\n const progressId = 'progress-' + Date.now();\n\n if(currentBubble) {\n currentBubble.innerHTML = '';\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"${progressId}-label\">⚙️ Claude Code 启动中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"${progressId}-content\" style=\"font-family:monospace;font-size:12px;white-space:pre-wrap;\"></div>\n `;\n currentBubble.appendChild(progressEl);\n }\n\n // ── 定时进度动画:每隔几秒显示一条有意义的步骤提示 ──\n const STEPS = [\n [1, '📖 正在读取对话历史...'],\n [4, '🧠 正在分析任务需求...'],\n [8, '⚙️ 正在规划执行步骤...'],\n [14, '🔨 正在执行主要任务...'],\n [25, '📝 正在生成文件内容...'],\n [40, '🔍 正在检查和优化...'],\n [55, '📦 正在保存文件...'],\n [70, '🗂️ 正在归档整理...'],\n [85, '⏳ 即将完成,请稍候...'],\n ];\n const stepTimers = [];\n const startTime = Date.now();\n STEPS.forEach(([sec, msg]) => {\n const t = setTimeout(() => {\n const el = document.getElementById(progressId+'-content');\n const label = document.getElementById(progressId+'-label');\n if(el) el.textContent += msg + '\\n';\n if(label) label.textContent = msg;\n if(el) el.scrollTop = el.scrollHeight;\n }, sec * 1000);\n stepTimers.push(t);\n });\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 清除所有定时器,停止动画\n stepTimers.forEach(t => clearTimeout(t));\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n\n // 把真实的 Claude Code 输出追加到进度内容里\n if(progressLines.length > 0) {\n const el = document.getElementById(progressId+'-content');\n if(el) {\n el.textContent += '\\n── Claude Code 输出 ──\\n' + progressLines.join('');\n el.scrollTop = el.scrollHeight;\n }\n }\n\n // 折叠面板,更新标题\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById(progressId+'-label');\n if(label) label.textContent = `✅ 执行完成(耗时 ${elapsed}s,点击展开查看过程)`;\n }\n const progressText = (document.getElementById(progressId+'-content') || {}).textContent || progressLines.join('');\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: progressText };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local'){\n // local 通道:callLocalClaude 会直接在 typing bubble 里实时显示进度\n // 完成后我们保留那个 bubble,只把最终内容追加进去,不重建\n res = await callLocalClaude(history);\n removeTyping(); // 移除 typing wrap(callLocalClaude 用的是里面的 bubble,已被改造)\n addMsg('ai', res.content, null, res.reasoning);\n } else {\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n }\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "0f3fefefedd0fb141d954907ebd9b46c5d8bf1e1": {"t": 1785772888.317105, "n": 86412, "h": "b3929c560c2fa30c", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // ── 流式 SSE 接收,实时展示 Claude Code 执行过程 ──\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({\n prompt: fullPrompt,\n files: localFiles,\n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n clearTimeout(timeoutId);\n\n if(!res.ok) throw new Error('请求失败: ' + res.status);\n\n // 找到当前 AI 气泡,插入实时进度面板\n const bubbles = document.querySelectorAll('.bubble.ai');\n const currentBubble = bubbles[bubbles.length - 1];\n let progressEl = null;\n let progressLines = [];\n\n if(currentBubble) {\n progressEl = document.createElement('details');\n progressEl.className = 'reasoning-box';\n progressEl.open = true;\n progressEl.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\"><path d=\"M4 2l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\"/></svg>\n <span id=\"progress-label\">⚙️ Claude Code 执行中...</span>\n </summary>\n <div class=\"reasoning-content\" id=\"progress-content\" style=\"font-family:monospace;font-size:12px;\"></div>\n `;\n currentBubble.innerHTML = '';\n currentBubble.appendChild(progressEl);\n }\n\n // 逐行读取 SSE 流\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finalData = null;\n\n while(true) {\n const { done, value } = await reader.read();\n if(done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop(); // 保留未完整的行\n\n for(const line of lines) {\n if(!line.startsWith('data: ')) continue;\n try {\n const msg = JSON.parse(line.slice(6));\n if(msg.type === 'progress') {\n progressLines.push(msg.text);\n if(progressEl) {\n const el = document.getElementById('progress-content');\n if(el) {\n el.textContent += msg.text;\n el.scrollTop = el.scrollHeight;\n }\n }\n } else if(msg.type === 'done') {\n finalData = msg;\n }\n } catch(e) {}\n }\n }\n\n if(!finalData) throw new Error('未收到最终响应');\n if(finalData.error) throw new Error(finalData.error);\n\n // 折叠进度面板,标题改为\"已完成\"\n if(progressEl) {\n progressEl.open = false;\n const label = document.getElementById('progress-label');\n if(label) label.textContent = '✅ 执行完成(点击展开查看过程)';\n }\n\n // 处理二进制文件下载按钮\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (finalData.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (finalData.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "d2cad535bd0c7b3ee89c6ea8b3b4795514a047d0": {"t": 1785772889.9007647, "n": 84424, "h": "27e223ea55617fc3", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n // 前端超时设为10分钟,匹配服务器端,避免复杂任务被浏览器提前断开\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 600000);\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n signal: controller.signal,\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n }).finally(() => clearTimeout(timeoutId));\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n\n // 📦 处理二进制文件(docx/pptx/xlsx等),拼成下载按钮追加到回复末尾\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (data.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (data.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "50fca43148e180f04bca50ffdc0d425983b58d37": {"t": 1785772891.110496, "n": 5951, "h": "256910b9077d8e80", "c": "import os\nimport time\nimport base64\nimport shutil\nimport subprocess\nfrom flask import Flask, request, jsonify\nfrom flask_cors import CORS\nfrom dotenv import load_dotenv\nimport sys\n\n# 加载 .env 文件中的配置\nload_dotenv()\n\n# 🔑 尝试读取密码\nMY_SECRET_KEY = os.getenv(\"MY_SECRET_KEY\")\n\nif not MY_SECRET_KEY:\n print(\"🚨 致命错误:未找到访问密码或密码为空!\")\n print(\"👉 请确保在 server.py 同级目录下创建了 .env 文件,并正确填写了 MY_SECRET_KEY=你的密码\")\n sys.exit(1)\n\nCLAUDE_PROXY_URL = os.getenv(\"CLAUDE_PROXY_URL\", \"\")\n\napp = Flask(__name__)\nCORS(app)\n\n@app.route('/api/config', methods=['GET'])\ndef get_config():\n auth_header = request.headers.get('Authorization', '')\n if auth_header != f\"Bearer {MY_SECRET_KEY}\":\n return jsonify({\"error\": \"访问拒绝:密码错误!\"}), 401\n return jsonify({\"claude_url\": CLAUDE_PROXY_URL})\n\n@app.route('/api/claude', methods=['POST'])\ndef call_claude():\n auth_header = request.headers.get('Authorization', '')\n if auth_header != f\"Bearer {MY_SECRET_KEY}\":\n return jsonify({\"error\": \"访问拒绝:密码错误!请在设置中填写正确的本地服务密码。\"}), 401\n\n data = request.json\n user_prompt = data.get('prompt', '')\n files_data = data.get('files', [])\n search_enabled = data.get('search_enabled', False)\n\n if not user_prompt:\n return jsonify({\"error\": \"请求内容不能为空\"}), 400\n\n try:\n with open(\"chat_history.txt\", \"w\", encoding=\"utf-8\") as f:\n f.write(user_prompt)\n\n # 📁 处理上传的文件\n saved_filenames = []\n for idx, file_info in enumerate(files_data):\n orig_name = file_info.get('name', f'upload_{idx}')\n ext = os.path.splitext(orig_name)[1] if '.' in orig_name else '.bin'\n safe_filename = f\"temp_upload_{int(time.time())}_{idx}{ext}\"\n file_b64 = file_info.get('data', '')\n if file_b64:\n with open(safe_filename, \"wb\") as f_out:\n f_out.write(base64.b64decode(file_b64))\n saved_filenames.append(safe_filename)\n\n my_env = os.environ.copy()\n my_env['HTTP_PROXY'] = 'http://127.0.0.1:10808'\n my_env['HTTPS_PROXY'] = 'http://127.0.0.1:10808'\n\n file_instruction = (\n f\"用户上传了以下文件,已保存在当前目录:{', '.join(saved_filenames)}。请使用文件读取工具读取并分析它们。\"\n if saved_filenames else \"\"\n )\n\n search_instruction = (\n \"如有需要,可以使用 web_search 工具搜索最新信息。\"\n if search_enabled else \"本次任务无需联网搜索。\"\n )\n\n # 📂 准备专用输出目录:每次请求前清空,确保只收集本次生成的文件\n output_dir = os.path.join(os.getcwd(), \"claude_output\")\n history_dir = os.path.join(os.getcwd(), \"claude_history\")\n for d in [output_dir, history_dir]:\n os.makedirs(d, exist_ok=True)\n # 清空上次残留(正常情况下应已被归档,但作为保险)\n for f in os.listdir(output_dir):\n os.remove(os.path.join(output_dir, f))\n\n agent_command = (\n f\"请读取当前目录下的 chat_history.txt,其中包含用户的对话历史。\"\n f\"{file_instruction} \"\n f\"{search_instruction} \"\n f\"根据对话历史,回答用户最后提出的问题。\"\n f\"【文件生成规则】如果用户要求生成文件,请遵守以下规则:\"\n f\"1. 所有生成的文件必须保存到 ./claude_output/ 目录,文件名要有意义,不要保存到其他位置。\"\n f\"2. 生成 .docx 时:使用 python-docx 库,设置中文字体(如宋体/黑体),\"\n f\" 合理使用标题样式(Heading 1/2/3)、正文段落、表格,段落间距适当,内容详尽专业。\"\n f\"3. 生成 .pptx 时:使用 python-pptx 库,每页有清晰标题,内容条理分明,\"\n f\" 设置合适的字体大小和颜色,幻灯片数量不少于8页。\"\n f\"4. 生成 .xlsx 时:使用 openpyxl 库,设置列宽、表头加粗,数据格式规范。\"\n f\"5. 生成代码文件时:代码完整可运行,有必要的注释。\"\n ).strip()\n\n result = subprocess.run(\n ['claude.cmd', '--dangerously-skip-permissions', '-p'],\n input=agent_command,\n capture_output=True,\n text=True,\n timeout=600, # 10分钟,支持复杂文档生成\n encoding='utf-8',\n errors='ignore',\n env=my_env\n )\n\n # 🧹 清理上传的临时文件\n for f_name in saved_filenames:\n if os.path.exists(f_name):\n os.remove(f_name)\n\n # 📦 读取 claude_output/ 里的文件,文本用 <file> 标签,二进制 base64 回传\n file_tags = \"\"\n binary_files = []\n timestamp = time.strftime(\"%Y%m%d_%H%M%S\")\n BINARY_EXTS = {'.docx', '.xlsx', '.pptx', '.pdf', '.zip', '.png', '.jpg', '.jpeg', '.gif'}\n\n for fname in sorted(os.listdir(output_dir)):\n fpath = os.path.join(output_dir, fname)\n if not os.path.isfile(fpath):\n continue\n ext = os.path.splitext(fname)[1].lower()\n\n if ext in BINARY_EXTS:\n # 二进制文件:base64 编码后单独回传\n try:\n with open(fpath, 'rb') as fh:\n b64 = base64.b64encode(fh.read()).decode('utf-8')\n binary_files.append({'name': fname, 'data': b64})\n except Exception:\n pass\n else:\n # 文本文件:拼成 <file> 标签嵌入回复\n try:\n with open(fpath, 'r', encoding='utf-8', errors='replace') as fh:\n fc = fh.read()\n file_tags += f'\\n<file name=\"{fname}\">\\n{fc}\\n</file>\\n'\n except Exception:\n pass\n\n # 📁 归档到 claude_history/,文件名加时间戳前缀防重名\n name_base, name_ext = os.path.splitext(fname)\n archive_path = os.path.join(history_dir, f\"{timestamp}_{name_base}{name_ext}\")\n counter = 1\n while os.path.exists(archive_path):\n archive_path = os.path.join(history_dir, f\"{timestamp}_{name_base}_{counter}{name_ext}\")\n counter += 1\n shutil.move(fpath, archive_path)\n\n output = (result.stdout or result.stderr or \"\").strip()\n if not output:\n output = f\"[调试] 无输出。退出码={result.returncode}\\nSTDOUT={result.stdout!r}\\nSTDERR={result.stderr!r}\"\n\n return jsonify({\"response\": output.strip() + file_tags, \"binary_files\": binary_files})\n\n except Exception as e:\n return jsonify({\"error\": f\"系统错误: {str(e)}\"}), 500\n\nif __name__ == '__main__':\n app.run(host='127.0.0.1', port=5000)\n"}, "971b598f251fb8c7499552a2d73feca920a2c652": {"t": 1785772892.9161859, "n": 84230, "h": "bb8e5efeae039df0", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n // 👇 把 files 数组一起发过去\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n\n // 📦 处理二进制文件(docx/pptx/xlsx等),拼成下载按钮追加到回复末尾\n const MIME_MAP = {\n docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n pdf: 'application/pdf',\n zip: 'application/zip',\n png: 'image/png',\n jpg: 'image/jpeg',\n };\n const ICON_MAP = { docx:'📝', pptx:'📊', xlsx:'📊', pdf:'📄', zip:'🗜️', png:'🖼️', jpg:'🖼️' };\n let binaryBlocks = '';\n for(const f of (data.binary_files || [])){\n const ext = f.name.split('.').pop().toLowerCase();\n const mime = MIME_MAP[ext] || 'application/octet-stream';\n const icon = ICON_MAP[ext] || '📄';\n const url = `data:${mime};base64,${f.data}`;\n binaryBlocks += `<div class=\"file-download-block\"><span class=\"file-dl-icon\">${icon}</span><span class=\"file-dl-name\">${f.name}</span><a href=\"${url}\" download=\"${f.name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }\n\n return { content: (data.response || '') + (binaryBlocks ? '\\n\\n' + binaryBlocks : ''), reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "01484e705e64efb8fe1c2a0e2981f610d9c2761f": {"t": 1785772894.430195, "n": 83171, "h": "44d98006dffe4fc0", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n\n // ── <xlsx name=\"...\"> CSV内容 </xlsx> → 真正的 Excel 文件 ──\n text=text.replace(/<xlsx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/xlsx>/g,(_,name,content)=>{\n try{\n const rows=content.trim().split('\\n').map(r=>r.split(','));\n const ws=XLSX.utils.aoa_to_sheet(rows);\n const wb=XLSX.utils.book_new();\n XLSX.utils.book_append_sheet(wb,ws,'Sheet1');\n const b64=XLSX.write(wb,{bookType:'xlsx',type:'base64'});\n const url='data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,'+b64;\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📊</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">xlsx生成失败</span></div>`;}\n });\n\n // ── <docx name=\"...\"> 纯文本 </docx> → 真正的 Word 文件 ──\n text=text.replace(/<docx\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/docx>/g,(_,name,content)=>{\n try{\n const lines=content.trim().split('\\n');\n const paras=lines.map(l=>{\n const esc=l.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');\n return `<w:p><w:r><w:t xml:space=\"preserve\">${esc}</w:t></w:r></w:p>`;\n }).join('');\n const docXml=`<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>${paras}<w:sectPr/></w:body></w:document>`;\n const relsXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"></Relationships>`;\n const ctXml=`<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>`;\n const zip=new JSZip();\n zip.file('[Content_Types].xml',ctXml);\n zip.folder('_rels').file('.rels',relsXml);\n zip.folder('word').file('document.xml',docXml);\n zip.folder('word/_rels').file('document.xml.rels',relsXml);\n const dlId='docx_dl_'+Math.random().toString(36).slice(2);\n setTimeout(async()=>{\n const blob=await zip.generateAsync({type:'blob'});\n const a=document.getElementById(dlId);\n if(a){a.href=URL.createObjectURL(blob);a.removeAttribute('disabled');}\n },0);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><a id=\"${dlId}\" download=\"${name}\" class=\"file-dl-btn\" disabled>⬇ 下载</a></div>`;\n }catch(e){return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📝</span><span class=\"file-dl-name\">${name}</span><span style=\"color:red;font-size:12px\">docx生成失败</span></div>`;}\n });\n\n // ── <file name=\"...\"> 文本内容 </file> → 纯文本下载 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件时,请根据类型使用对应标签,前端会自动渲染为下载按钮:\\n• 代码/文本类(.py .js .txt .md .csv .json等):\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n• Excel表格(.xlsx):用CSV格式写内容,前端自动转换:\\n<xlsx name=\"表格.xlsx\">\\n列1,列2,列3\\n值1,值2,值3\\n</xlsx>\\n• Word文档(.docx):写纯文本内容,前端自动转换:\\n<docx name=\"文档.docx\">\\n文档内容\\n</docx>\\n普通回复文字写在标签外面即可。不要同时用多种标签嵌套。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n // 👇 把 files 数组一起发过去\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n \n return { content: data.response || '', reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "1955f6f8a092449af15ae5f587c5b780865fbff0": {"t": 1785772896.355973, "n": 79826, "h": "7881fd24a12142bf", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n /* ── 文件下载块 ── */\n .file-download-block{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin:8px 0;font-size:13px}\n .file-dl-icon{font-size:16px;flex-shrink:0}\n .file-dl-name{color:var(--text);font-weight:500;flex:1}\n .file-dl-btn{padding:4px 12px;background:var(--accent);color:#fff !important;border-radius:6px;font-size:12px;text-decoration:none !important;font-weight:500;white-space:nowrap;transition:opacity 0.15s}\n .file-dl-btn:hover{opacity:0.85}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n // ── 解析 <file name=\"...\"> 标签,渲染为下载按钮 ──\n text=text.replace(/<file\\s+name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/file>/g,(_,name,content)=>{\n const clean=content.trim();\n const ext=name.split('.').pop()||'txt';\n const mimeMap={py:'text/plain',js:'text/javascript',html:'text/html',css:'text/css',\n ts:'text/typescript',json:'application/json',csv:'text/csv',md:'text/markdown',\n txt:'text/plain',sh:'text/plain',sql:'text/plain',xml:'text/xml'};\n const mime=mimeMap[ext]||'text/plain';\n const url='data:text/plain;charset=utf-8,'+encodeURIComponent(clean);\n return `<div class=\"file-download-block\"><span class=\"file-dl-icon\">📄</span><span class=\"file-dl-name\">${name}</span><a href=\"${url}\" download=\"${name}\" class=\"file-dl-btn\">⬇ 下载</a></div>`;\n });\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(code);\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件(代码、文档、表格、配置等)时,请用以下格式包裹文件内容,前端会自动渲染为下载按钮:\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n普通回复文字写在标签外面即可。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件(代码、文档、表格、配置等)时,请用以下格式包裹文件内容,前端会自动渲染为下载按钮:\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n普通回复文字写在标签外面即可。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。\\n当你需要输出可下载的文件(代码、文档、表格、配置等)时,请用以下格式包裹文件内容,前端会自动渲染为下载按钮:\\n<file name=\"文件名.扩展名\">\\n文件内容\\n</file>\\n普通回复文字写在标签外面即可。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n // 👇 把 files 数组一起发过去\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n \n return { content: data.response || '', reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "99e49b04a9410daf6c4e0c2cef38f146dc924f77": {"t": 1785772899.1615908, "n": 78168, "h": "d4aaef99928b333c", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n// 启动时从本地服务器获取真实的 Claude 代理 URL\nlet _claudeProxyUrl = null;\nasync function getClaudeProxyUrl() {\n if (_claudeProxyUrl) return _claudeProxyUrl;\n const res = await fetch('http://127.0.0.1:5000/api/config', {\n headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '') }\n });\n const cfg = await res.json();\n if (!cfg.claude_url) throw new Error('无法获取服务地址,请确认本地服务已启动且密码正确');\n _claudeProxyUrl = cfg.claude_url;\n return _claudeProxyUrl;\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = await getClaudeProxyUrl();\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n // 👇 把 files 数组一起发过去\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n \n return { content: data.response || '', reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "07545274b486b448d806f80f3b0331f37fb5c5bf": {"t": 1785772900.9508595, "n": 77736, "h": "9933f47b424fade2", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocalClaude(messages){\n const NGROK_URL = \"https://quickness-earwig-service.ngrok-free.dev/api/claude\";\n \n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n let localFiles = []; // 📦 改名:用来收集图片、PDF等需要后端亲自读的二进制文件\n \n messages.forEach(m => {\n let textContent = \"\";\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n if (m._files && m._files.length) {\n m._files.forEach(f => {\n // 👇 核心改动:如果是图片或 PDF,提取 Base64 数据打包给后端\n if (f.kind === 'image' || f.kind === 'pdf') {\n localFiles.push({ name: f.name, data: f.data });\n textContent = `【用户上传了文件:${f.name}】\\n` + textContent;\n } \n // 👇 Word/Excel/PPT/TXT 等,前端已经提取出文本了,直接塞进文字历史里\n else if (f.text) {\n textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n }\n });\n }\n \n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n \n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n // 👇 把 files 数组一起发过去\n body: JSON.stringify({ \n prompt: fullPrompt, \n files: localFiles, \n search_enabled: typeof searchEnabled !== 'undefined' ? searchEnabled : false\n })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n \n return { content: data.response || '', reasoning: '' };\n}\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "36109ba7e29d54c0d9541987194a1a5edb82fa40": {"t": 1785772902.9541683, "n": 77354, "h": "1d79604ac86331a7", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n <button class=\"ptab\" onclick=\"switchTab('local')\">本地服务Claude</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-local\">\n <div class=\"field-label\">本地服务访问密码</div>\n <input class=\"modal-input\" id=\"key-local\" type=\"password\" placeholder=\"输入专属密码\">\n <div class=\"key-status\" id=\"st-local\"></div>\n <div class=\"model-note\">请输入专属密码连接本地 Claude 服务。</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n <optgroup label=\"── 我的本地服务 ──\">\n <option value=\"local|claude-code\">本地运行的 Claude Code</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||'',\n local: localStorage.getItem('key_local') || '',\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge = document.getElementById('balanceBadge');\n const key = keys[provider];\n\n // 1. 如果是本地模式,直接显示跳转官网的链接,不需要 Key 校验\n if (provider === 'local') {\n badge.className = 'balance-badge show ok'; // 绿色外观,代表连接正常\n badge.innerHTML = `💬 <a href=\"https://claude.ai/chats\" target=\"_blank\">前往 Claude 官网 ↗</a>`;\n return;\n }\n\n // 2. 没有 Key 就隐藏 (针对其他 API 模式)\n if(!key){\n badge.className = 'balance-badge';\n badge.textContent = '';\n return;\n }\n\n badge.className = 'balance-badge show err';\n badge.innerHTML = '💰 查询中…';\n\n try{\n if(provider === 'deepseek'){\n // DeepSeek 有公开余额接口\n const res = await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data = await res.json();\n if(!res.ok) throw new Error(data.message || '请求失败');\n const infos = data.balance_infos || [];\n if(!infos.length) throw new Error('无余额信息');\n \n const parts = infos.map(b => {\n const total = parseFloat(b.total_balance || 0);\n const currency = b.currency || '';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal = parseFloat(infos[0].total_balance || 0);\n badge.className = 'balance-badge show ' + (firstTotal > 1 ? 'ok' : 'warn');\n badge.innerHTML = '💰 ' + parts.join(' · ') + ` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names = {claude: 'Anthropic 控制台', openai: 'OpenAI 控制台', gemini: 'Google AI Studio'};\n badge.className = 'balance-badge show err';\n badge.innerHTML = `💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider] || '官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className = 'balance-badge show err';\n const names = {claude: 'Anthropic', openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek'};\n badge.innerHTML = `💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider] || ''}官网 ↗</a>`;\n }\n}\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek','local'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek','local'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys() {\n // 遍历所有平台,包括新增的 'local'\n ['claude', 'openai', 'gemini', 'deepseek', 'local'].forEach(p => {\n const inputEl = document.getElementById('key-' + p);\n if (inputEl) {\n const v = inputEl.value.trim();\n // 直接执行赋值和保存,不再进行 if(v) 判断\n // 这样即便 v 是空字符串,也会覆盖掉 localStorage 里的旧密码\n keys[p] = v;\n localStorage.setItem('key_' + p, v);\n }\n });\n\n // 保存其他模型参数\n params.temperature = parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens = parseInt(document.getElementById('paramMaxTokens').value) || 2000;\n localStorage.setItem('p_temp', params.temperature);\n localStorage.setItem('p_maxtokens', params.maxTokens);\n\n // 隐藏设置弹窗\n document.getElementById('keyModal').style.display = 'none';\n\n // 刷新当前选定模型的余额显示\n const { provider } = getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey() {\n const { provider } = getProviderModel();\n // 不再特指跳过 local,而是统一检查 keys 对象里对应的 provider 是否有值\n if (!keys[provider]) {\n openKeys(); // 自动打开设置弹窗\n switchTab(provider); // 自动切换到当前 provider(如 local)的标签页\n return false; // 拦截发送动作\n }\n return true;\n}\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocalClaude(messages){\n // ⚠️ 记得把这里换成你最新的 Ngrok 地址!\n const NGROK_URL = \"https://quickness-earwig-service.ngrok-free.dev/api/claude\";\n \n // 1. 我们来拼凑一段包含“历史记忆”的超级提示词\n let fullPrompt = \"以下是我们之前的对话历史记录:\\n\\n\";\n \n // 2. 遍历网页上所有的聊天记录\n messages.forEach(m => {\n let textContent = \"\";\n // 兼容文件和普通文本\n if (Array.isArray(m.content)) {\n textContent = m.content.map(c => c.text || '').join('\\n');\n } else {\n textContent = m.content || '';\n }\n \n // 如果上传了文件,带上文件内容\n if (m._files && m._files.length) {\n m._files.forEach(f => {\n if (f.text) textContent = `【参考文件:${f.name}】\\n${f.text}\\n\\n` + textContent;\n });\n }\n \n // 区分是谁说的话\n let roleName = m.role === 'user' ? '用户' : 'AI';\n fullPrompt += `[${roleName}]: ${textContent}\\n\\n`;\n });\n\n \n // 3. 在最后加上行动指令\n\n fullPrompt += \"请仔细阅读上述对话历史,并自然地回答用户最后提出的问题。\";\n \n\n // 4. 把打包好的长文本发给你的本地 Python 服务器\n const res = await fetch(NGROK_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'ngrok-skip-browser-warning': 'true',\n 'Authorization': 'Bearer ' + (localStorage.getItem('key_local') || '')\n },\n body: JSON.stringify({ prompt: fullPrompt })\n });\n\n const data = await res.json();\n if(data.error) throw new Error(data.error);\n \n return { content: data.response || '', reasoning: '' };\n}\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='local') res=await callLocalClaude(history); // <--- 新增这行\n else if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "291c9967e2e6e6ca0d1118f21531005bcda053f6": {"t": 1785772904.4394796, "n": 74547, "h": "b8a82d53e09d4e60", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "eaf2f65dd86df2bd9d71d6358df3cccb45c4b7e4": {"t": 1785772906.3615496, "n": 75649, "h": "252dfa4e94d87538", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.7(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.7</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\"> Claude Pro(自己的账号,默认Sonnet 4.6)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 1. 正确映射消息格式,保持原始的 role,避免全部被洗成 user\n const formattedMessages = messages.map(m => ({\n role: m.role, \n content: typeof m.content === 'string' ? m.content : \n (m.content && Array.isArray(m.content) ? \n m.content.map(p => p.text || '').join('\\n') : '')\n }));\n \n // 调试用:你可以在 F12 控制台确认发送给后端的上下文是否正确携带了 assistant\n console.log('发送给本地服务器的消息:', formattedMessages); \n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递修复后的完整历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.7' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "bdc23f364f83e6fa2919da4216bedae826984c07": {"t": 1785772908.9008622, "n": 75638, "h": "7be0caeffceb49d6", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model, messages) {\n const msgs = toClaudeMessages(messages);\n\n // 构造请求体,联网时加入 web_search 工具\n const body = {\n model,\n max_tokens: 8192,\n temperature: params.temperature,\n system: `你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,\n messages: msgs\n };\n if (searchEnabled) {\n body.tools = [{ type: \"web_search_20250305\", name: \"web_search\" }];\n }\n\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': keys.claude,\n 'anthropic-version': '2023-06-01',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify(body)\n });\n\n const data = await res.json();\n if (data.error) throw new Error(data.error.message);\n\n // 联网模式下 content 数组里会混有 tool_use/tool_result 块,\n // 用 find 精确取 text 块,而不是直接取 [0]\n const textBlock = data.content?.find(b => b.type === 'text');\n return { content: textBlock?.text || '', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 1. 正确映射消息格式,保持原始的 role,避免全部被洗成 user\n const formattedMessages = messages.map(m => ({\n role: m.role, \n content: typeof m.content === 'string' ? m.content : \n (m.content && Array.isArray(m.content) ? \n m.content.map(p => p.text || '').join('\\n') : '')\n }));\n \n // 调试用:你可以在 F12 控制台确认发送给后端的上下文是否正确携带了 assistant\n console.log('发送给本地服务器的消息:', formattedMessages); \n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递修复后的完整历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "1a0bb5e973d0e15bb336a37ed80181276d9d9ba3": {"t": 1785772913.522075, "n": 75278, "h": "4f45506f4fbe3189", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n // 解除隐藏,允许 Claude 点击联网按钮\n sep.style.display='';\n btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 1. 正确映射消息格式,保持原始的 role,避免全部被洗成 user\n const formattedMessages = messages.map(m => ({\n role: m.role, \n content: typeof m.content === 'string' ? m.content : \n (m.content && Array.isArray(m.content) ? \n m.content.map(p => p.text || '').join('\\n') : '')\n }));\n \n // 调试用:你可以在 F12 控制台确认发送给后端的上下文是否正确携带了 assistant\n console.log('发送给本地服务器的消息:', formattedMessages); \n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递修复后的完整历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "75464e67c16ec2c119fad2a4a5a48dc4d95502fe": {"t": 1785772915.4499128, "n": 75119, "h": "327615167a484141", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"').replace(/'/g,\"'\");\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 1. 正确映射消息格式,保持原始的 role,避免全部被洗成 user\n const formattedMessages = messages.map(m => ({\n role: m.role, \n content: typeof m.content === 'string' ? m.content : \n (m.content && Array.isArray(m.content) ? \n m.content.map(p => p.text || '').join('\\n') : '')\n }));\n \n // 调试用:你可以在 F12 控制台确认发送给后端的上下文是否正确携带了 assistant\n console.log('发送给本地服务器的消息:', formattedMessages); \n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递修复后的完整历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "823e34fc1f219c304cb2d2a1a5f0800ce62a1f7e": {"t": 1785772917.115877, "n": 75097, "h": "a7640bd90b05941f", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 1. 正确映射消息格式,保持原始的 role,避免全部被洗成 user\n const formattedMessages = messages.map(m => ({\n role: m.role, \n content: typeof m.content === 'string' ? m.content : \n (m.content && Array.isArray(m.content) ? \n m.content.map(p => p.text || '').join('\\n') : '')\n }));\n \n // 调试用:你可以在 F12 控制台确认发送给后端的上下文是否正确携带了 assistant\n console.log('发送给本地服务器的消息:', formattedMessages); \n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递修复后的完整历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "033318e11d2e3c5ef1d0c581ea4a27663a00a640": {"t": 1785772918.8174455, "n": 74896, "h": "c184f5ca68699211", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user', \n content: typeof m.content === 'string' ? m.content : ''\n }));\n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "07e95c1b0fd9ab175a7054a24804466760d7f951": {"t": 1785772921.2024736, "n": 74897, "h": "d6142cacabef4c8a", "c": "\n<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user', \n content: typeof m.content === 'string' ? m.content : ''\n }));\n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "62a516e2e69e20f415a6736b4a185be09ad4ce99": {"t": 1785772922.9488578, "n": 74960, "h": "8fcee8eb5ccbde05", "c": "没问题,这是修复了所有语法错误和逻辑问题后的完整 HTML 代码。你可以直接复制并覆盖你原来的文件直接运行:\n\n```html\n<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`,messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:`你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。`},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\nasync function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user', \n content: typeof m.content === 'string' ? m.content : ''\n }));\n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n```\n"}, "0bff2e9adeb72a9c78addebab974c0f779b76d28": {"t": 1785772925.654111, "n": 75193, "h": "69a8c95d66b53f30", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<!-- Keys & Settings Modal -->\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <!-- Claude -->\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <!-- Gemini -->\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <!-- DeepSeek -->\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <!-- 通用参数 -->\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <!-- 团队共享花费同步 -->\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<!-- Cost Analysis Modal -->\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <!-- 视图切换 -->\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <!-- 预算 -->\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <!-- 总览卡片 -->\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <!-- 进度条 -->\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <!-- 内容区 -->\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。',messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n async function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === ';assistant'; ? ';assistant'; : ';user';, // 确保角色名正确\n content: typeof m.content === ';string'; ? m.content : ';';\n }));\n \n const res = await fetch(';http://localhost:8765';, {\n method: ';POST';,\n headers: {';Content-Type';:';application/json';},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || ';抱歉,本地服务器没有返回有效回答。';, \n reasoning: ';'; \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n"}, "527f9e8f9ccfbb7a73bf0cdecf193fd0bfb1ffd6": {"t": 1785772927.3633876, "n": 75193, "h": "7b634ba2e420b36c", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<!-- Keys & Settings Modal -->\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <!-- Claude -->\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <!-- Gemini -->\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <!-- DeepSeek -->\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <!-- 通用参数 -->\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <!-- 团队共享花费同步 -->\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<!-- Cost Analysis Modal -->\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <!-- 视图切换 -->\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <!-- 预算 -->\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <!-- 总览卡片 -->\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <!-- 进度条 -->\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <!-- 内容区 -->\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。',messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n async function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === ‘;assistant‘; ? ‘;assistant‘; : ‘;user‘;, // 确保角色名正确\n content: typeof m.content === ‘;string‘; ? m.content : ‘;‘;\n }));\n \n const res = await fetch(‘;http://localhost:8765‘;, {\n method: ‘;POST‘;,\n headers: {‘;Content-Type‘;:‘;application/json‘;},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || ‘;抱歉,本地服务器没有返回有效回答。‘;, \n reasoning: ‘;‘; \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n"}, "4e9d8a968e53f9927ae4a5594f12346e58f416d4": {"t": 1785772931.0040424, "n": 75259, "h": "0074d9002ff9b0c9", "c": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>407AI(界面还在测试)</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css\">\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js\"></script>\n<style>\n\n/* ── 思考过程折叠面板 ── */\n .reasoning-box {\n margin-bottom: 12px;\n border: 1px solid var(--border);\n border-radius: 8px;\n background: #fdfdfc;\n overflow: hidden;\n }\n .reasoning-summary {\n padding: 10px 14px;\n font-size: 13px;\n color: var(--muted);\n cursor: pointer;\n user-select: none;\n list-style: none; /* 隐藏默认小箭头 */\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--bg);\n transition: background 0.15s;\n }\n .reasoning-summary::-webkit-details-marker { display: none; }\n .reasoning-summary:hover { background: var(--border); color: var(--text); }\n .reasoning-summary svg { transition: transform 0.2s; }\n .reasoning-box[open] .reasoning-summary svg { transform: rotate(90deg); }\n .reasoning-content {\n padding: 12px 14px;\n font-size: 13px;\n color: var(--muted);\n border-top: 1px solid var(--border);\n white-space: pre-wrap;\n max-height: 500px;\n overflow-y: auto;\n line-height: 1.6;\n background: #fdfdfc;\n }\n\n/* 粘贴提示 */\n.paste-hint {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--accent-light);\n color: var(--accent);\n padding: 12px 20px;\n border-radius: 8px;\n font-size: 13px;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.2s;\n z-index: 10;\n}\n\n.paste-hint.show {\n opacity: 1;\n}\n\n.input-wrap.paste-active {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-light);\n}\n \n *{box-sizing:border-box;margin:0;padding:0}\n :root{\n --bg:#f7f6f2;--sidebar:#efede8;--white:#fff;--border:#e2dfd8;\n --text:#1a1a18;--muted:#7a7870;--accent:#c96a3b;--accent-light:#fdf0e8;\n --user-bubble:#1a1a18;--user-text:#f7f6f2;--radius:14px;\n }\n body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;overflow:hidden}\n\n /* ── Sidebar ── */\n .sidebar{width:230px;min-width:230px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}\n .sidebar-header{padding:18px 16px 12px;border-bottom:1px solid var(--border)}\n .brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}\n .brand-dot{width:8px;height:8px;background:var(--accent);border-radius:50%}\n .new-chat-btn{margin:10px 12px 4px;padding:9px 14px;background:var(--white);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px;transition:all 0.15s;font-family:inherit;width:calc(100% - 24px)}\n .new-chat-btn:hover{border-color:var(--accent);color:var(--accent)}\n .new-chat-btn:active{transform:scale(0.98)}\n .sidebar-label{font-size:11px;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;padding:12px 16px 6px}\n .chat-list{flex:1;overflow-y:auto;padding:0 10px 10px}\n .chat-item{padding:8px 10px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:1px;transition:all 0.15s;display:flex;align-items:center;justify-content:space-between;gap:4px}\n .chat-item:hover{background:var(--white);color:var(--text)}\n .chat-item.active{background:var(--white);color:var(--text);font-weight:500}\n .chat-item-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .chat-del{opacity:0;background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px;border-radius:4px;flex-shrink:0}\n .chat-item:hover .chat-del{opacity:1}\n .chat-del:hover{background:var(--border);color:#c0392b}\n .sidebar-footer{padding:10px;border-top:1px solid var(--border)}\n .footer-btns{display:flex;gap:6px;padding:4px 4px}\n .footer-btn{flex:1;padding:7px 6px;font-size:11px;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;text-align:center;transition:all 0.15s}\n .footer-btn:hover{background:var(--accent-light);color:var(--accent);border-color:var(--accent)}\n\n /* ── Main ── */\n .main{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0}\n .topbar{height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:10px;background:var(--white);flex-shrink:0}\n .topbar-title{font-size:14px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .model-select-wrap{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 12px 4px 10px;transition:all 0.15s}\n .model-select-wrap:hover{border-color:var(--accent)}\n .provider-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}\n .model-select{background:transparent;border:none;outline:none;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;color:var(--text);padding:0}\n /* ── 余额徽章 ── */\n .balance-badge{display:none;align-items:center;gap:5px;background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:12px;color:var(--muted);transition:all 0.2s;white-space:nowrap}\n .balance-badge.show{display:flex}\n .balance-badge.ok{border-color:#b7e0c0;background:#f2fbf4;color:#2d7a3a}\n .balance-badge.warn{border-color:#f5d08a;background:#fffbf0;color:#a07020}\n .balance-badge.err{border-color:var(--border);color:var(--muted)}\n .balance-badge a{color:inherit;text-decoration:none;opacity:0.75;font-size:11px}\n .balance-badge a:hover{opacity:1;text-decoration:underline}\n\n /* ── Messages ── */\n .messages{flex:1;overflow-y:auto;padding:28px 0;display:flex;flex-direction:column}\n .messages::-webkit-scrollbar{width:4px}\n .messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}\n .msg-wrap{display:flex;padding:5px 28px;gap:12px;max-width:860px;margin:0 auto;width:100%}\n .msg-wrap.user{flex-direction:row-reverse}\n .msg-avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;margin-top:3px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}\n .msg-avatar.ai{background:var(--accent-light);color:var(--accent)}\n .msg-avatar.user{background:var(--user-bubble);color:var(--user-text)}\n .bubble{padding:11px 15px;border-radius:var(--radius);font-size:14px;line-height:1.8;max-width:74%;word-break:break-word}\n .bubble.ai{background:var(--white);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px;white-space:normal}\n .bubble.user{background:var(--user-bubble);color:var(--user-text);border-top-right-radius:4px;white-space:pre-wrap}\n .bubble img{max-width:100%;border-radius:8px;display:block;margin-top:6px}\n /* Markdown styles */\n .bubble.ai p{margin:0 0 10px}\n .bubble.ai p:last-child{margin-bottom:0}\n .bubble.ai h1,.bubble.ai h2,.bubble.ai h3{font-weight:600;margin:16px 0 8px;line-height:1.4}\n .bubble.ai h1{font-size:17px}.bubble.ai h2{font-size:15px}.bubble.ai h3{font-size:14px}\n .bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0 10px}\n .bubble.ai li{margin-bottom:4px}\n .bubble.ai code{background:#f0ede8;padding:1px 5px;border-radius:4px;font-size:13px;font-family:'Menlo','Consolas',monospace}\n .bubble.ai pre{background:#f0ede8;border-radius:8px;padding:12px 14px;overflow-x:auto;margin:10px 0;position:relative}\n .bubble.ai pre code{background:none;padding:0;font-size:13px;line-height:1.6}\n .bubble.ai blockquote{border-left:3px solid var(--border);padding-left:12px;color:var(--muted);margin:8px 0}\n .bubble.ai table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}\n .bubble.ai th,.bubble.ai td{border:1px solid var(--border);padding:6px 10px;text-align:left}\n .bubble.ai th{background:var(--bg);font-weight:500}\n .bubble.ai strong{font-weight:600}\n .bubble.ai a{color:var(--accent);text-decoration:underline}\n /* ── 消息底部操作栏 ── */\n .msg-wrap.ai:hover .msg-actions { opacity: 1; }\n .msg-actions {\n opacity: 0;\n transition: opacity 0.15s;\n display: flex;\n gap: 6px;\n align-self: flex-end;\n margin-bottom: 4px;\n flex-shrink: 0;\n }\n .action-btn {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 7px;\n padding: 4px 8px;\n font-size: 11px;\n color: var(--muted);\n cursor: pointer;\n white-space: nowrap;\n font-family: inherit;\n transition: all 0.15s;\n }\n .action-btn:hover { background: var(--white); color: var(--text); }\n .action-btn.copied { color: var(--accent); border-color: var(--accent); }\n .typing{display:flex;gap:4px;align-items:center;padding:13px 15px}\n .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.2s infinite}\n .dot:nth-child(2){animation-delay:0.2s}.dot:nth-child(3){animation-delay:0.4s}\n @keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}\n\n /* ── Welcome ── */\n .welcome{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;padding:40px 24px;max-width:620px;margin:0 auto;width:100%}\n .welcome-title{font-size:24px;font-weight:600;text-align:center}\n .welcome-sub{font-size:13px;color:var(--muted);text-align:center;line-height:1.6}\n .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}\n .sug-btn{padding:12px 14px;background:var(--white);border:1px solid var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer;text-align:left;transition:all 0.15s;line-height:1.5;font-family:inherit}\n .sug-btn:hover{border-color:var(--accent);background:var(--accent-light)}\n\n /* ── Input ── */\n .input-area{padding:10px 20px 14px;border-top:1px solid var(--border);background:var(--white);flex-shrink:0}\n .attachments{max-width:860px;margin:0 auto 8px;display:flex;gap:8px;flex-wrap:wrap}\n .attach-chip{display:flex;align-items:center;gap:6px;padding:5px 8px 5px 10px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;max-width:180px}\n .attach-chip .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n .attach-chip .xbtn,.img-thumb .xbtn{background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:0 0 0 3px}\n .img-thumb{position:relative;width:60px;height:60px;flex-shrink:0}\n .img-thumb img{width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border)}\n .img-thumb .xbtn{position:absolute;top:-6px;right:-6px;width:17px;height:17px;background:var(--user-bubble);color:var(--user-text);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px}\n .input-wrap{max-width:860px;margin:0 auto;background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:10px 12px;transition:border-color 0.15s}\n .input-wrap:focus-within{border-color:#b0ada6}\n .input-row{display:flex;align-items:flex-end;gap:8px}\n textarea{flex:1;background:transparent;border:none;outline:none;resize:none;font-family:inherit;font-size:14px;color:var(--text);line-height:1.6;max-height:140px;min-height:24px}\n textarea::placeholder{color:var(--muted)}\n .toolbar{display:flex;align-items:center;gap:2px;padding-top:7px;border-top:1px solid var(--border);margin-top:7px;flex-wrap:wrap}\n .tool-btn{padding:4px 8px;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px;font-family:inherit;transition:all 0.15s}\n .tool-btn:hover{background:var(--border);color:var(--text)}\n .tool-btn.on{background:var(--accent-light);color:var(--accent)}\n .tool-btn.disabled{opacity:0.45;cursor:not-allowed}\n .tool-btn.disabled:hover{background:transparent;color:var(--muted)}\n .tool-sep{width:1px;height:14px;background:var(--border);margin:0 3px}\n .send-btn{width:32px;height:32px;background:var(--user-bubble);border:none;border-radius:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:opacity 0.15s;opacity:0.3}\n .send-btn.active{opacity:1}\n .input-hint{text-align:center;font-size:11px;color:var(--muted);margin-top:7px;max-width:860px;margin-left:auto;margin-right:auto}\n input[type=file]{display:none}\n\n /* ── Modals ── */\n .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100}\n .modal-box{background:var(--white);border-radius:18px;padding:28px;width:480px;max-width:94vw;max-height:90vh;overflow-y:auto}\n .modal-box h2{font-size:17px;font-weight:600;margin-bottom:6px}\n .modal-box p{font-size:13px;color:var(--muted);margin-bottom:18px;line-height:1.6}\n .provider-tabs{display:flex;gap:6px;margin-bottom:18px;background:var(--bg);padding:4px;border-radius:10px}\n .ptab{flex:1;padding:7px;border:none;border-radius:7px;font-size:13px;cursor:pointer;font-family:inherit;background:transparent;color:var(--muted);transition:all 0.15s}\n .ptab.active{background:var(--white);color:var(--text);font-weight:500;box-shadow:0 1px 3px rgba(0,0,0,0.08)}\n .key-section{display:none}\n .key-section.show{display:block}\n .field-label{font-size:12px;color:var(--muted);margin-bottom:5px;margin-top:12px}\n .field-label:first-child{margin-top:0}\n .modal-input{width:100%;padding:9px 13px;border:1px solid var(--border);border-radius:9px;font-size:13px;font-family:inherit;outline:none;margin-bottom:4px}\n .modal-input:focus{border-color:var(--accent)}\n .key-status{font-size:12px;padding:2px 0 8px;color:var(--muted)}\n .key-status.ok{color:#3a7d44}\n .modal-btn{width:100%;padding:10px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:9px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;margin-top:8px}\n .modal-btn:hover{opacity:0.85}\n\n /* ── Params Panel ── */\n .params-section{border-top:1px solid var(--border);margin-top:16px;padding-top:16px}\n .params-title{font-size:12px;font-weight:500;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px}\n .param-row{display:flex;align-items:center;gap:12px;margin-bottom:14px}\n .param-row label{font-size:13px;color:var(--text);width:100px;flex-shrink:0}\n .param-row input[type=range]{flex:1;accent-color:var(--accent)}\n .param-row .val{font-size:12px;color:var(--muted);width:36px;text-align:right;flex-shrink:0}\n .param-row input[type=number]{width:80px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none}\n .param-row input[type=number]:focus{border-color:var(--accent)}\n .toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}\n .toggle-row label{font-size:13px;color:var(--text)}\n .toggle-row span{font-size:11px;color:var(--muted)}\n .toggle{position:relative;width:36px;height:20px;flex-shrink:0}\n .toggle input{opacity:0;width:0;height:0}\n .toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:0.2s}\n .toggle-slider:before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:white;border-radius:50%;transition:0.2s}\n .toggle input:checked+.toggle-slider{background:var(--accent)}\n .toggle input:checked+.toggle-slider:before{transform:translateX(16px)}\n\n .processing-msg{font-size:12px;color:var(--muted);max-width:860px;margin:0 auto 6px;padding:0 2px}\n .empty-history{padding:8px 10px;font-size:12px;color:var(--muted)}\n .model-note{font-size:11px;color:var(--muted);margin-top:4px;line-height:1.5;padding:6px 8px;background:var(--bg);border-radius:6px}\n .katex-block{overflow-x:auto;padding:6px 0;text-align:center}\n.mermaid-wrap{background:var(--white);border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0;overflow-x:auto}\n .code-wrap{position:relative;margin:10px 0}\n .code-toolbar{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:opacity 0.15s}\n .code-wrap:hover .code-toolbar{opacity:1}\n .code-tool-btn{background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit}\n .code-tool-btn:hover{background:var(--white);color:var(--text)}\n .html-preview{width:100%;border:1px solid var(--border);border-radius:8px;margin-top:8px;background:white;min-height:120px;display:none}\n .html-preview.show{display:block}\n /* ── 花费分析 ── */\n .cost-summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n .cost-card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px 14px;text-align:center}\n .cost-card .cv{font-size:20px;font-weight:600;color:var(--text);margin-bottom:2px}\n .cost-card .cl{font-size:11px;color:var(--muted)}\n .cost-card.accent{background:var(--accent-light);border-color:var(--accent)}\n .cost-card.accent .cv{color:var(--accent)}\n .cost-row{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:9px 13px;display:flex;align-items:center;gap:10px;font-size:13px}\n .cost-row-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}\n .cost-row-meta{font-size:11px;color:var(--muted);white-space:nowrap}\n .cost-row-price{font-weight:600;color:var(--accent);white-space:nowrap;min-width:54px;text-align:right}\n .cost-row-model{font-size:10px;background:var(--bg);border-radius:4px;padding:2px 6px;color:var(--muted);white-space:nowrap}\n /* ── 团队视图 ── */\n .team-row{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;align-items:center;gap:12px}\n .team-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent-light);color:var(--accent);font-size:13px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}\n .team-name{font-size:13px;font-weight:500;flex:1}\n .team-meta{font-size:11px;color:var(--muted)}\n .team-cost{font-size:15px;font-weight:600;color:var(--accent)}\n .team-bar-wrap{height:4px;background:var(--border);border-radius:99px;margin-top:4px;overflow:hidden}\n .team-bar{height:100%;border-radius:99px;background:var(--accent);transition:width 0.4s}\n .sync-status{font-size:11px;padding:4px 10px;border-radius:20px;display:inline-flex;align-items:center;gap:5px}\n .sync-status.ok{background:#f2fbf4;color:#2d7a3a;border:1px solid #b7e0c0}\n .sync-status.err{background:#fff3f0;color:#c0392b;border:1px solid #f5c0b8}\n .sync-status.ing{background:var(--bg);color:var(--muted);border:1px solid var(--border)}\n .tab-sync{background:var(--bg);border:1px dashed var(--border);border-radius:8px;padding:12px 14px;margin-top:12px}\n .tab-sync .field-label{margin-top:8px}\n .tab-sync .field-label:first-child{margin-top:0}\n</style>\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n</head>\n<body>\n\n<!-- Keys & Settings Modal -->\n<div class=\"modal-bg\" id=\"keyModal\" style=\"display:none\">\n <div class=\"modal-box\">\n <h2>API Key 与模型设置</h2>\n <p>填写你想使用的 AI 服务的 Key。Key 只保存在你的浏览器里,不会上传任何服务器。</p>\n <div class=\"provider-tabs\">\n <button class=\"ptab active\" onclick=\"switchTab('claude')\">Claude</button>\n <button class=\"ptab\" onclick=\"switchTab('openai')\">OpenAI</button>\n <button class=\"ptab\" onclick=\"switchTab('gemini')\">Gemini</button>\n <button class=\"ptab\" onclick=\"switchTab('deepseek')\">DeepSeek</button>\n </div>\n\n <!-- Claude -->\n <div class=\"key-section show\" id=\"tab-claude\">\n <div class=\"field-label\">Anthropic API Key</div>\n <input class=\"modal-input\" id=\"key-claude\" type=\"password\" placeholder=\"sk-ant-...\">\n <div class=\"key-status\" id=\"st-claude\"></div>\n <div class=\"model-note\">申请地址:console.anthropic.com<br>最新模型:Sonnet 4.6(推荐)· Opus 4.6(最强)· Haiku 4.5(最快最便宜)</div>\n </div>\n\n <!-- OpenAI -->\n <div class=\"key-section\" id=\"tab-openai\">\n <div class=\"field-label\">OpenAI API Key</div>\n <input class=\"modal-input\" id=\"key-openai\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-openai\"></div>\n <div class=\"model-note\">申请地址:platform.openai.com<br>最新模型:GPT-5.4(旗舰)· GPT-5.4 Pro(最强推理)· GPT-5.4 mini(性价比)· GPT-5.3(快速)</div>\n </div>\n\n <!-- Gemini -->\n <div class=\"key-section\" id=\"tab-gemini\">\n <div class=\"field-label\">Google Gemini API Key</div>\n <input class=\"modal-input\" id=\"key-gemini\" type=\"password\" placeholder=\"AIza...\">\n <div class=\"key-status\" id=\"st-gemini\"></div>\n <div class=\"model-note\">申请地址:aistudio.google.com(免费额度较多)<br>最新模型:Gemini 3.1 Pro(最强推理)· Gemini 3 Flash(速度/能力平衡)· Gemini 2.5 Flash(稳定快速)</div>\n </div>\n\n <!-- DeepSeek -->\n <div class=\"key-section\" id=\"tab-deepseek\">\n <div class=\"field-label\">DeepSeek API Key</div>\n <input class=\"modal-input\" id=\"key-deepseek\" type=\"password\" placeholder=\"sk-...\">\n <div class=\"key-status\" id=\"st-deepseek\"></div>\n <div class=\"model-note\">申请地址:platform.deepseek.com(国内直连,无需梯子,价格最低)<br>V3 = 快速模式 · R1 = 深度思考模式</div>\n </div>\n\n <!-- 通用参数 -->\n <div class=\"params-section\">\n <div class=\"params-title\">模型参数</div>\n\n <div class=\"param-row\">\n <label>温度 Temperature</label>\n <input type=\"range\" min=\"0\" max=\"2\" step=\"0.1\" id=\"paramTemp\" oninput=\"document.getElementById('tempVal').textContent=this.value\">\n <span class=\"val\" id=\"tempVal\">0.7</span>\n </div>\n <div style=\"font-size:11px;color:var(--muted);margin:-8px 0 14px 112px\">0 = 保守精确 · 1 = 均衡 · 2 = 创意发散</div>\n\n <div class=\"param-row\">\n <label>最大回复长度</label>\n <input type=\"number\" id=\"paramMaxTokens\" min=\"256\" max=\"8000\" step=\"256\" value=\"2000\">\n <span style=\"font-size:12px;color:var(--muted)\">tokens</span>\n </div>\n </div>\n\n <button class=\"modal-btn\" onclick=\"saveKeys()\">保存设置</button>\n\n <!-- 团队共享花费同步 -->\n <div class=\"params-section\">\n <div class=\"params-title\">👥 团队花费同步(可选)</div>\n <p style=\"font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.6\">基于 JSONBin.io 免费存储,团队所有人花费实时可见。<br>① 在 <a href=\"https://jsonbin.io\" target=\"_blank\" style=\"color:var(--accent)\">jsonbin.io</a> 注册并创建一个 Bin → ② 把 Bin ID 和 Master Key 发给团队成员</p>\n <div class=\"tab-sync\">\n <div class=\"field-label\">你的昵称</div>\n <input class=\"modal-input\" id=\"sync-username\" placeholder=\"例:小明\" maxlength=\"12\">\n <div class=\"field-label\">Bin ID</div>\n <input class=\"modal-input\" id=\"sync-binid\" placeholder=\"68xxxxxxxxxxxxxxxxxxxxxxxx\">\n <div class=\"field-label\">Master Key(用于写入,不会上传服务器)</div>\n <input class=\"modal-input\" id=\"sync-masterkey\" type=\"password\" placeholder=\"$2a$10$...\">\n <div id=\"sync-test-status\" style=\"margin-top:6px;font-size:12px;color:var(--muted)\"></div>\n <button onclick=\"testAndSaveSync()\" style=\"margin-top:8px;padding:7px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;cursor:pointer;font-family:inherit\">🔗 测试连接并保存</button>\n </div>\n </div>\n </div>\n</div>\n\n<div class=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"brand\"><div class=\"brand-dot\"></div>407AI(界面还在测试)</div>\n </div>\n <button class=\"new-chat-btn\" onclick=\"newChat()\">\n <svg width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n 新对话\n </button>\n <div class=\"sidebar-label\">历史对话</div>\n <div class=\"chat-list\" id=\"chatList\"></div>\n <div class=\"sidebar-footer\">\n <div class=\"footer-btns\">\n <button class=\"footer-btn\" onclick=\"openKeys()\">⚙ 设置</button>\n <button class=\"footer-btn\" onclick=\"openCostModal()\">💰 花费分析</button>\n </div>\n </div>\n</div>\n\n<div class=\"main\">\n <div class=\"topbar\">\n <div class=\"topbar-title\" id=\"topbarTitle\">新对话</div>\n <div id=\"balanceBadge\" class=\"balance-badge\" title=\"当前所选 API 账户余额\"></div>\n <div class=\"model-select-wrap\">\n <div class=\"provider-dot\" id=\"providerDot\" style=\"background:#c96a3b\"></div>\n <select class=\"model-select\" id=\"modelSelect\" onchange=\"onModelChange()\">\n <optgroup label=\"── Claude (Anthropic) ──\">\n <option value=\"claude|claude-sonnet-4-20250514\">Claude Sonnet 4.6</option>\n <option value=\"claude|claude-opus-4-20250514\">Claude Opus 4.6</option>\n <option value=\"claude|claude-haiku-4-5-20251001\">Claude Haiku 4.5</option>\n </optgroup>\n <optgroup label=\"── OpenAI (ChatGPT) ──\">\n <option value=\"openai|gpt-5.4\">GPT-5.4</option>\n <option value=\"openai|gpt-5.4-pro\">GPT-5.4 Pro</option>\n <option value=\"openai|gpt-5.4-mini\">GPT-5.4 mini</option>\n <option value=\"openai|gpt-5.3\">GPT-5.3</option>\n </optgroup>\n <optgroup label=\"── Google Gemini ──\">\n <option value=\"gemini|gemini-3.1-pro-preview\">Gemini 3.1 Pro</option>\n <option value=\"gemini|gemini-3-flash\">Gemini 3 Flash</option>\n <option value=\"gemini|gemini-2.5-flash\">Gemini 2.5 Flash</option>\n <option value=\"gemini|gemini-2.5-flash-lite\">Gemini 2.5 Flash Lite</option>\n </optgroup>\n <optgroup label=\"── 本地 ──\">\n <option value=\"local|claude-pro\">本地 Claude Pro(自己的账号)</option>\n </optgroup>\n <optgroup label=\"── DeepSeek ──\">\n <option value=\"deepseek|deepseek-chat\">DeepSeek V3(快速)</option>\n <option value=\"deepseek|deepseek-reasoner\">DeepSeek R1(深度思考)</option>\n </optgroup>\n </select>\n </div>\n </div>\n\n <div class=\"messages\" id=\"messages\"></div>\n\n <div class=\"input-area\">\n <div class=\"processing-msg\" id=\"procMsg\"></div>\n <div class=\"attachments\" id=\"attachments\"></div>\n <div class=\"input-wrap\">\n <div class=\"paste-hint\" id=\"pasteHint\">🖼️ 粘贴图片中...</div>\n <div class=\"input-row\">\n <textarea id=\"input\" placeholder=\"输入问题,按 Enter 发送... 也可以直接粘贴图片 Ctrl+V\" rows=\"1\" onkeydown=\"handleKey(event)\" oninput=\"handleInput(this)\" onpaste=\"handlePaste(event)\"></textarea>\n <button class=\"send-btn\" id=\"sendBtn\" onclick=\"send()\">\n <svg width=\"14\" height=\"14\" fill=\"none\" stroke=\"white\" stroke-width=\"2.5\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"/><polyline points=\"5 12 12 5 19 12\"/></svg>\n </button>\n </div>\n <div class=\"toolbar\">\n <button class=\"tool-btn\" onclick=\"document.getElementById('imgInput').click()\">🖼️ 图片</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pdfInput').click()\">📄 PDF</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('wordInput').click()\">📝 Word</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('excelInput').click()\">📊 Excel</button>\n <button class=\"tool-btn\" onclick=\"document.getElementById('pptInput').click()\">📑 PPT</button>\n <div class=\"tool-sep\"></div>\n <button class=\"tool-btn\" onclick=\"document.getElementById('txtInput').click()\">📃 文本/代码</button>\n <div class=\"tool-sep\" id=\"searchSep\" style=\"display:none\"></div>\n <button class=\"tool-btn\" id=\"searchToggleBtn\" style=\"display:none\" onclick=\"toggleSearch()\">🔍 联网</button>\n </div>\n </div>\n <div class=\"input-hint\" id=\"inputHint\">支持 JPG·PNG·PDF·DOCX·XLSX·PPTX·TXT·CSV·JSON·代码 · 可直接粘贴图片 Ctrl+V · 历史自动保存</div>\n </div>\n</div>\n\n<!-- Cost Analysis Modal -->\n<div class=\"modal-bg\" id=\"costModal\" style=\"display:none\" onclick=\"if(event.target===this)closeCostModal()\">\n <div class=\"modal-box\" style=\"width:580px;max-width:96vw\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:6px\">\n <h2 style=\"margin:0\">💰 花费分析</h2>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span id=\"syncStatusBadge\" class=\"sync-status ing\" style=\"display:none\"></span>\n <button onclick=\"closeCostModal()\" style=\"background:none;border:none;font-size:18px;cursor:pointer;color:var(--muted)\">✕</button>\n </div>\n </div>\n <p style=\"margin-bottom:14px;font-size:13px;color:var(--muted)\">重建每次 API 调用的真实上下文大小并按官方定价估算。<span id=\"teamModeLabel\"></span></p>\n\n <!-- 视图切换 -->\n <div id=\"viewToggleWrap\" style=\"display:none;margin-bottom:14px\">\n <div style=\"display:flex;gap:6px;background:var(--bg);padding:4px;border-radius:10px;width:fit-content\">\n <button id=\"btnViewTeam\" class=\"ptab active\" onclick=\"setView('team')\">👥 全员视图</button>\n <button id=\"btnViewMe\" class=\"ptab\" onclick=\"setView('me')\">🙋 我的明细</button>\n </div>\n </div>\n\n <!-- 预算 -->\n <div style=\"display:flex;align-items:center;gap:10px;background:var(--bg);border-radius:10px;padding:10px 14px;margin-bottom:16px\">\n <span style=\"font-size:13px;color:var(--muted)\">团队预算</span>\n <span style=\"font-size:13px\">$</span>\n <input id=\"budgetInput\" type=\"number\" min=\"0\" step=\"1\" value=\"20\" style=\"width:70px;padding:4px 8px;border:1px solid var(--border);border-radius:7px;font-size:13px;font-family:inherit;outline:none\" oninput=\"renderCostDetails()\">\n <span style=\"font-size:13px;color:var(--muted)\">美元</span>\n <button onclick=\"refreshCostModal()\" style=\"margin-left:auto;padding:5px 12px;background:var(--user-bubble);color:var(--user-text);border:none;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit\">⟳ 刷新</button>\n </div>\n\n <!-- 总览卡片 -->\n <div id=\"costSummary\" style=\"margin-bottom:16px\"></div>\n\n <!-- 进度条 -->\n <div id=\"costProgressWrap\" style=\"margin-bottom:16px;display:none\">\n <div style=\"display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:5px\">\n <span id=\"costProgressLabel\">已用 $0.00</span>\n <span id=\"costProgressRight\">剩余 $20.00</span>\n </div>\n <div style=\"background:var(--border);border-radius:99px;height:8px;overflow:hidden\">\n <div id=\"costProgressBar\" style=\"height:100%;border-radius:99px;background:var(--accent);width:0%;transition:width 0.4s\"></div>\n </div>\n </div>\n\n <!-- 内容区 -->\n <div id=\"costMainContent\"></div>\n </div>\n</div>\n\n<input type=\"file\" id=\"imgInput\" accept=\"image/*\" multiple onchange=\"handleFiles(this.files,'image')\">\n<input type=\"file\" id=\"pdfInput\" accept=\".pdf\" multiple onchange=\"handleFiles(this.files,'pdf')\">\n<input type=\"file\" id=\"wordInput\" accept=\".docx,.doc\" multiple onchange=\"handleFiles(this.files,'word')\">\n<input type=\"file\" id=\"excelInput\" accept=\".xlsx,.xls,.csv\" multiple onchange=\"handleFiles(this.files,'excel')\">\n<input type=\"file\" id=\"pptInput\" accept=\".pptx\" multiple onchange=\"handleFiles(this.files,'ppt')\">\n<input type=\"file\" id=\"txtInput\" accept=\".txt,.md,.json,.csv,.js,.ts,.py,.java,.c,.cpp,.html,.css,.xml,.yaml,.yml,.sh,.sql\" multiple onchange=\"handleFiles(this.files,'text')\">\n\n<script>\n// ── 配置 ──\nconst PROVIDER_COLORS={claude:'#c96a3b',openai:'#19c37d',gemini:'#4285f4',deepseek:'#4d6bfe'};\nconst ICONS={image:'🖼️',pdf:'📄',word:'📝',excel:'📊',ppt:'📑',text:'📃'};\nconst STORAGE_KEY='team_ai_v7_sessions';\nconst MAX_SESSIONS=50;\n\nlet history=[],currentSession=null,isLoading=false,pendingItems=[];\nlet searchEnabled=false;\n\nlet keys={\n claude:localStorage.getItem('key_claude')||'',\n openai:localStorage.getItem('key_openai')||'',\n gemini:localStorage.getItem('key_gemini')||'',\n deepseek:localStorage.getItem('key_deepseek')||''\n};\nlet params={\n temperature:parseFloat(localStorage.getItem('p_temp')||'0.7'),\n maxTokens:parseInt(localStorage.getItem('p_maxtokens')||'2000')\n};\n\n// ── 历史存储 ──\nfunction loadSessions(){try{return JSON.parse(localStorage.getItem(STORAGE_KEY))||[];}catch{return [];}}\nfunction saveSessions(s){try{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,MAX_SESSIONS)));}catch{localStorage.setItem(STORAGE_KEY,JSON.stringify(s.slice(0,10)));}}\nfunction saveCurrentSession(){\n if(!currentSession)return;\n const sessions=loadSessions();\n const idx=sessions.findIndex(s=>s.id===currentSession.id);\n const{provider,model}=getProviderModel();\n const data={\n id:currentSession.id,\n title:currentSession.title,\n provider:provider,\n model:model,\n messages:history.map(m=>({\n role:m.role,\n content:typeof m.content==='string'?m.content:'[含文件内容]',\n reasoning:m.reasoning||''\n })),\n updated:Date.now()\n };\n if(idx>=0)sessions[idx]=data;else sessions.unshift(data);\n saveSessions(sessions);\n}\n\n// ── 初始化 ──\nfunction init(){\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n renderChatList();\n appendWelcome();\n onModelChange();\n}\n\nfunction renderChatList(){\n const sessions=loadSessions();\n const list=document.getElementById('chatList');\n if(!sessions.length){list.innerHTML='<div class=\"empty-history\">暂无历史对话</div>';return;}\n list.innerHTML=sessions.map(s=>`\n <div class=\"chat-item${currentSession&&s.id===currentSession.id?' active':''}\" onclick=\"loadSession('${s.id}')\">\n <span class=\"chat-item-title\">${s.title}</span>\n <button class=\"chat-del\" onclick=\"delSession(event,'${s.id}')\">✕</button>\n </div>`).join('');\n}\n\nfunction loadSession(id){\n const s=loadSessions().find(x=>x.id===id);\n if(!s)return;\n currentSession={id:s.id,title:s.title};\n history=s.messages.map(m=>({role:m.role,content:m.content,reasoning:m.reasoning}));\n document.getElementById('topbarTitle').textContent=s.title;\n document.getElementById('messages').innerHTML='';\n \n // 【修复Bug】:将历史记录里的 assistant 映射为前端样式需要的 ai\n s.messages.forEach(m => {\n const displayRole = (m.role === 'assistant' || m.role === 'model') ? 'ai' : 'user';\n addMsg(displayRole, m.content, null, m.reasoning);\n });\n \n document.getElementById('messages').scrollTop=99999;\n setTimeout(renderMermaid,100);\n renderChatList();\n}\n\nfunction delSession(e,id){\n e.stopPropagation();\n saveSessions(loadSessions().filter(s=>s.id!==id));\n if(currentSession&¤tSession.id===id){currentSession=null;history=[];appendWelcome();document.getElementById('topbarTitle').textContent='新对话';}\n renderChatList();\n}\n\n// ── 模型 & Key ──\nfunction getProviderModel(){const v=document.getElementById('modelSelect').value;const[p,m]=v.split('|');return{provider:p,model:m};}\n\nfunction onModelChange(){\n const{provider}=getProviderModel();\n document.getElementById('providerDot').style.background=PROVIDER_COLORS[provider]||'#888';\n const btn=document.getElementById('searchToggleBtn');\n const sep=document.getElementById('searchSep');\n if(provider==='claude'){\n sep.style.display='none';btn.style.display='none';searchEnabled=false;\n } else if(provider==='deepseek'){\n sep.style.display='';btn.style.display='';\n searchEnabled=false;\n btn.className='tool-btn disabled';\n btn.title='DeepSeek API 暂不支持真实联网搜索';\n btn.textContent='🔍 联网(不支持)';\n btn.onclick=()=>alert('DeepSeek API 目前不支持真实联网搜索。\\n如需联网,请切换到 OpenAI 或 Gemini 模型。');\n } else {\n sep.style.display='';btn.style.display='';\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n btn.onclick=toggleSearch;\n btn.title='';\n }\n fetchBalance(provider);\n}\n\n// ── 余额查询 ──\nconst CONSOLE_URLS={\n claude:'https://console.anthropic.com/settings/billing',\n openai:'https://platform.openai.com/account/billing/overview',\n gemini:'https://aistudio.google.com/',\n deepseek:'https://platform.deepseek.com/usage'\n};\n\nasync function fetchBalance(provider){\n const badge=document.getElementById('balanceBadge');\n const key=keys[provider];\n\n // 没有 Key 就隐藏\n if(!key||provider==='local'){badge.className='balance-badge';badge.textContent='';return;}\n\n badge.className='balance-badge show err';\n badge.innerHTML='💰 查询中…';\n\n try{\n if(provider==='deepseek'){\n // DeepSeek 有公开余额接口\n const res=await fetch('https://api.deepseek.com/user/balance',{\n headers:{'Authorization':'Bearer '+key,'Accept':'application/json'}\n });\n const data=await res.json();\n if(!res.ok) throw new Error(data.message||'请求失败');\n const infos=data.balance_infos||[];\n if(!infos.length) throw new Error('无余额信息');\n // 展示所有币种\n const parts=infos.map(b=>{\n const total=parseFloat(b.total_balance||0);\n const currency=b.currency||'';\n return `${currency} ${total.toFixed(2)}`;\n });\n const firstTotal=parseFloat(infos[0].total_balance||0);\n badge.className='balance-badge show '+(firstTotal>1?'ok':'warn');\n badge.innerHTML='💰 '+parts.join(' · ')+` <a href=\"${CONSOLE_URLS.deepseek}\" target=\"_blank\">↗</a>`;\n\n } else {\n // 其他平台不提供 CORS 可用的余额接口,直接给跳转链接\n const names={claude:'Anthropic 控制台',openai:'OpenAI 控制台',gemini:'Google AI Studio'};\n badge.className='balance-badge show err';\n badge.innerHTML=`💰 <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">在 ${names[provider]||'官网'} 查看余额 ↗</a>`;\n }\n } catch(e){\n badge.className='balance-badge show err';\n const names={claude:'Anthropic',openai:'OpenAI',gemini:'Gemini',deepseek:'DeepSeek'};\n badge.innerHTML=`💰 查询失败 · <a href=\"${CONSOLE_URLS[provider]}\" target=\"_blank\">${names[provider]||''}官网 ↗</a>`;\n }\n}\n\nfunction toggleSearch(){\n searchEnabled=!searchEnabled;\n const btn=document.getElementById('searchToggleBtn');\n btn.className='tool-btn'+(searchEnabled?' on':'');\n btn.textContent=searchEnabled?'🔍 联网中':'🔍 联网';\n}\n\nfunction openKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{\n document.getElementById('key-'+p).value=keys[p]||'';\n document.getElementById('st-'+p).textContent=keys[p]?'✓ 已配置':'未配置';\n document.getElementById('st-'+p).className='key-status'+(keys[p]?' ok':'');\n });\n document.getElementById('paramTemp').value=params.temperature;\n document.getElementById('tempVal').textContent=params.temperature;\n document.getElementById('paramMaxTokens').value=params.maxTokens;\n document.getElementById('keyModal').style.display='flex';\n}\n\nfunction switchTab(p){\n document.querySelectorAll('.ptab').forEach((t,i)=>t.className='ptab'+(['claude','openai','gemini','deepseek'][i]===p?' active':''));\n document.querySelectorAll('.key-section').forEach(s=>s.className='key-section');\n document.getElementById('tab-'+p).className='key-section show';\n}\n\nfunction saveKeys(){\n ['claude','openai','gemini','deepseek'].forEach(p=>{const v=document.getElementById('key-'+p).value.trim();if(v){keys[p]=v;localStorage.setItem('key_'+p,v);}});\n params.temperature=parseFloat(document.getElementById('paramTemp').value);\n params.maxTokens=parseInt(document.getElementById('paramMaxTokens').value)||2000;\n localStorage.setItem('p_temp',params.temperature);\n localStorage.setItem('p_maxtokens',params.maxTokens);\n document.getElementById('keyModal').style.display='none';\n // 保存后立即刷新余额显示\n const{provider}=getProviderModel();\n fetchBalance(provider);\n}\n\nfunction checkKey(){const{provider}=getProviderModel();if(provider===\"local\")return true;if(!keys[provider]){openKeys();switchTab(provider);return false;}return true;}\n\n// ── 输入 ──\nfunction handleInput(el){\n el.style.height='auto';\n el.style.height=Math.min(el.scrollHeight,140)+'px';\n updateSendBtn();\n}\n\nfunction updateSendBtn(){\n document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');\n}\n\nfunction handleKey(e){\n if(e.key==='Enter'&&!e.shiftKey){\n e.preventDefault();\n send();\n }\n}\n\n// 新增:处理粘贴事件\nfunction handlePaste(e) {\n const items = e.clipboardData?.items;\n if (!items) return;\n \n let hasImage = false;\n for (let i = 0; i < items.length; i++) {\n const item = items[i];\n if (item.type.startsWith('image/')) {\n hasImage = true;\n e.preventDefault();\n \n const file = item.getAsFile();\n if (file) {\n showPasteHint();\n handlePastedImage(file);\n }\n break;\n }\n }\n}\n\n// 新增:显示粘贴提示\nfunction showPasteHint() {\n const hint = document.getElementById('pasteHint');\n const inputWrap = document.querySelector('.input-wrap');\n \n hint.classList.add('show');\n inputWrap.classList.add('paste-active');\n \n setTimeout(() => {\n hint.classList.remove('show');\n inputWrap.classList.remove('paste-active');\n }, 1500);\n}\n\n// 新增:处理粘贴的图片\nasync function handlePastedImage(file) {\n try {\n const reader = new FileReader();\n reader.onload = (e) => {\n const timestamp = new Date().toLocaleTimeString();\n pendingItems.push({\n kind: 'image',\n type: file.type,\n data: e.target.result.split(',')[1],\n preview: e.target.result,\n name: `粘贴图片_${timestamp}`\n });\n renderAttachments();\n updateSendBtn();\n };\n reader.readAsDataURL(file);\n } catch (error) {\n console.error('处理粘贴图片失败:', error);\n alert('处理粘贴图片失败,请重试');\n }\n}\nfunction updateSendBtn(){document.getElementById('sendBtn').className='send-btn'+((document.getElementById('input').value.trim()||pendingItems.length)?' active':'');}\nfunction handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}\nfunction suggest(text){document.getElementById('input').value=text;handleInput(document.getElementById('input'));send();}\nfunction setProc(m){document.getElementById('procMsg').textContent=m||'';}\n\n// ── 文件处理 ──\nasync function handleFiles(files,kind){\n for(const f of Array.from(files)){\n setProc('正在处理 '+f.name+'...');\n try{\n if(kind==='image') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:f.type,data:e.target.result.split(',')[1],preview:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='pdf') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,type:'application/pdf',data:e.target.result.split(',')[1],name:f.name});res();};r.onerror=rej;r.readAsDataURL(f);});\n else if(kind==='word') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{mammoth.extractRawText({arrayBuffer:e.target.result}).then(x=>{pendingItems.push({kind,text:x.value,name:f.name});res();}).catch(rej);};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='excel') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'});let t='';wb.SheetNames.forEach(n=>{t+=`【${n}】\\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}\\n\\n`;});pendingItems.push({kind,text:t.trim(),name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='ppt') await new Promise((res,rej)=>{const r=new FileReader();r.onload=async e=>{try{const zip=await JSZip.loadAsync(e.target.result);const sf=Object.keys(zip.files).filter(x=>x.match(/ppt\\/slides\\/slide\\d+\\.xml$/)).sort();let t='';for(let i=0;i<sf.length;i++){const xml=await zip.files[sf[i]].async('string');const plain=xml.replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();if(plain)t+=`【第${i+1}页】\\n${plain}\\n\\n`;}pendingItems.push({kind,text:t.trim()||'未能提取文字',name:f.name});res();}catch(err){rej(err);}};r.onerror=rej;r.readAsArrayBuffer(f);});\n else if(kind==='text') await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>{pendingItems.push({kind,text:e.target.result,name:f.name});res();};r.onerror=rej;r.readAsText(f,'utf-8');});\n }catch(e){alert('处理失败:'+f.name);}\n }\n setProc('');renderAttachments();updateSendBtn();\n ['imgInput','pdfInput','wordInput','excelInput','pptInput','txtInput'].forEach(id=>document.getElementById(id).value='');\n}\n\nfunction removeItem(i){pendingItems.splice(i,1);renderAttachments();updateSendBtn();}\nfunction renderAttachments(){\n document.getElementById('attachments').innerHTML=pendingItems.map((item,i)=>{\n if(item.kind==='image') return `<div class=\"img-thumb\"><img src=\"${item.preview}\"><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n return `<div class=\"attach-chip\">${ICONS[item.kind]} <span class=\"fname\">${item.name}</span><button class=\"xbtn\" onclick=\"removeItem(${i})\">✕</button></div>`;\n }).join('');\n}\n\n// ── 对话渲染 ──\nfunction newChat(){\n history=[];currentSession=null;pendingItems=[];\n renderAttachments();\n document.getElementById('topbarTitle').textContent='新对话';\n document.getElementById('input').value='';\n document.getElementById('input').style.height='auto';\n updateSendBtn();appendWelcome();renderChatList();\n}\n\nfunction appendWelcome(){\n document.getElementById('messages').innerHTML=`<div class=\"welcome\" id=\"welcome\">\n <div class=\"welcome-title\">你好,今天需要什么帮助?</div>\n <div class=\"welcome-sub\">支持 Claude · OpenAI · Gemini · DeepSeek<br>图片 · PDF · Word · Excel · PPT · 历史自动保存</div>\n <div class=\"suggestions\">\n <button class=\"sug-btn\" onclick=\"suggest('帮我写一封给客户的道歉邮件,语气专业但诚恳')\">✉️ 帮我写一封客户邮件</button>\n <button class=\"sug-btn\" onclick=\"suggest('用简单易懂的话解释一下什么是人工智能')\">💡 解释一个复杂概念</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我列一个这周的工作计划,包括优先级排序')\">📋 整理工作计划</button>\n <button class=\"sug-btn\" onclick=\"suggest('帮我把这段话翻译成英文:今天天气不错,适合外出')\">🌐 中英文翻译</button>\n </div></div>`;\n}\n\nfunction renderMsg(role,text){\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;bubble.textContent=text;\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n}\n\nfunction renderMarkdown(text){\n const mermaidBlocks=[];\n text=text.replace(/```mermaid\\n([\\s\\S]+?)\\n```/g,(_,c)=>{mermaidBlocks.push(c);return '%%MERMAID_'+(mermaidBlocks.length-1)+'%%';});\n const svgBlocks=[];\n text=text.replace(/```svg\\n([\\s\\S]+?)\\n```/g,(_,c)=>{svgBlocks.push(c);return '%%SVG_'+(svgBlocks.length-1)+'%%';});\n const blocks=[];\n let t=text;\n // 保护 \\[...\\](块级公式)\n t=t.replace(/\\\\\\[([\\s\\S]+?)\\\\\\]/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 \\(...\\)(行内公式)\n t=t.replace(/\\\\\\((.+?)\\\\\\)/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 保护 $$...$$(块级公式)\n t=t.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g,(_,m)=>{blocks.push({type:'block',src:m});return `%%LATEX_BLOCK_${blocks.length-1}%%`;});\n // 保护 $...$(行内公式,排除货币符号等误判)\n t=t.replace(/\\$([^$\\n]{1,200}?)\\$/g,(_,m)=>{blocks.push({type:'inline',src:m});return `%%LATEX_INLINE_${blocks.length-1}%%`;});\n // 渲染 markdown\n let html=marked.parse(t,{breaks:true,gfm:true});\n // 还原 LaTeX\n html=html.replace(/%%LATEX_BLOCK_(\\d+)%%/g,(_,i)=>{\n try{return '<div class=\"katex-block\">'+katex.renderToString(blocks[i].src,{displayMode:true,throwOnError:false})+'</div>';}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%LATEX_INLINE_(\\d+)%%/g,(_,i)=>{\n try{return katex.renderToString(blocks[i].src,{displayMode:false,throwOnError:false});}\n catch{return '<code>'+blocks[i].src+'</code>';}\n });\n html=html.replace(/%%MERMAID_(\\d+)%%/g,(_,i)=>{\n const id='mg'+Date.now()+i;\n return '<div class=\"mermaid-wrap\"><div class=\"mermaid\" id=\"'+id+'\">'+mermaidBlocks[i]+'</div></div>';\n });\n html=html.replace(/%%SVG_(\\d+)%%/g,(_,i)=>'<div class=\"mermaid-wrap\">'+svgBlocks[i]+'</div>');\n html=html.replace(/<pre><code class=\"language-(\\w+)\">(([\\s\\S]*?))<\\/code><\\/pre>/g,(_,lang,code)=>{\n const raw=code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'\"');\n const extMap={python:'py',javascript:'js',js:'js',html:'html',css:'css',java:'java',cpp:'cpp',c:'c',typescript:'ts',bash:'sh',shell:'sh',sql:'sql',json:'json'};\n const ext=extMap[lang]||lang||'txt';\n const enc=encodeURIComponent(raw);\n const iframeBtn = lang==='html' ? '<button class=\"code-tool-btn\" onclick=\"runHtml(this,decodeURIComponent(\\''+enc+'\\'))\">运行</button>' : '';\n return '<div class=\"code-wrap\"><pre><code class=\"language-'+lang+'\">'+code+'</code></pre><div class=\"code-toolbar\"><button class=\"code-tool-btn\" onclick=\"navigator.clipboard.writeText(decodeURIComponent(\\''+enc+'\\'))\">复制</button><button class=\"code-tool-btn\" onclick=\"dlCode(decodeURIComponent(\\''+enc+'\\'),\\'file.'+ext+'\\')\">下载</button>'+iframeBtn+'</div><iframe class=\"html-preview\" sandbox=\"allow-scripts\"></iframe></div>';\n });\n return html;\n}\nfunction dlCode(code,filename){\n const a=document.createElement('a');\n a.href=URL.createObjectURL(new Blob([code],{type:'text/plain'}));\n a.download=filename;a.click();\n}\nfunction runHtml(btn,code){\n const wrap=btn.closest('.code-wrap');\n const iframe=wrap.querySelector('.html-preview');\n if(iframe.classList.contains('show')){\n iframe.classList.remove('show');\n iframe.srcdoc='';\n btn.textContent='运行';\n } else {\n iframe.srcdoc=code;\n iframe.classList.add('show');\n iframe.style.height='400px';\n btn.textContent='关闭';\n }\n}\n\nfunction addMsg(role, text, items, reasoning){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap '+role;\n const av=document.createElement('div');av.className='msg-avatar '+role;av.textContent=role==='ai'?'AI':'我';\n const bubble=document.createElement('div');bubble.className='bubble '+role;\n \n if(items&&items.length){\n items.forEach(item=>{\n if(item.kind==='image'){const img=document.createElement('img');img.src=item.preview;bubble.appendChild(img);}\n else{const c=document.createElement('span');c.style.cssText='display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:rgba(255,255,255,0.15);border-radius:5px;font-size:11px;margin:0 4px 4px 0';c.textContent=ICONS[item.kind]+' '+item.name;bubble.appendChild(c);}\n });\n }\n\n if(text || reasoning){\n if(role==='ai'){\n if(reasoning) {\n const rBox = document.createElement('details');\n rBox.className = 'reasoning-box';\n rBox.innerHTML = `\n <summary class=\"reasoning-summary\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 18l6-6-6-6\"/></svg>\n 已深度思考\n </summary>\n <div class=\"reasoning-content\">${reasoning}</div>`;\n bubble.appendChild(rBox);\n }\n if(text){\n const d=document.createElement('div');\n d.innerHTML=renderMarkdown(text);\n bubble.appendChild(d);\n }\n }else{\n if(text){\n const p=document.createElement('p');\n p.style.marginTop=items&&items.length?'6px':'0';\n p.textContent=text;\n bubble.appendChild(p);\n }\n }\n }\n\n if(role==='ai'){\n // 创建一个容器把两个按钮装起来\n const actionBox = document.createElement('div');\n actionBox.className = 'msg-actions';\n\n const copyBtn=document.createElement('button');\n copyBtn.className='action-btn';copyBtn.textContent='复制';\n copyBtn.onclick=()=>{\n navigator.clipboard.writeText(text).then(()=>{\n copyBtn.textContent='已复制 ✓';copyBtn.classList.add('copied');\n setTimeout(()=>{copyBtn.textContent='复制';copyBtn.classList.remove('copied');},2000);\n });\n };\n\n // 新增:重新生成按钮\n const regenBtn=document.createElement('button');\n regenBtn.className='action-btn';regenBtn.innerHTML='⟳ 重新生成';\n regenBtn.onclick=()=>regenerateMsg(wrap); // 绑定重新生成事件\n\n actionBox.appendChild(copyBtn);\n actionBox.appendChild(regenBtn);\n\n wrap.appendChild(av);wrap.appendChild(bubble);wrap.appendChild(actionBox);\n }else{\n wrap.appendChild(av);wrap.appendChild(bubble);\n }\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction addTyping(){\n const w=document.getElementById('welcome');if(w)w.remove();\n const wrap=document.createElement('div');wrap.className='msg-wrap ai';wrap.id='typing-wrap';\n const av=document.createElement('div');av.className='msg-avatar ai';av.textContent='AI';\n const bubble=document.createElement('div');bubble.className='bubble ai';\n bubble.innerHTML='<div class=\"typing\"><div class=\"dot\"></div><div class=\"dot\"></div><div class=\"dot\"></div></div>';\n wrap.appendChild(av);wrap.appendChild(bubble);\n document.getElementById('messages').appendChild(wrap);\n document.getElementById('messages').scrollTop=99999;\n}\nfunction removeTyping(){const t=document.getElementById('typing-wrap');if(t)t.remove();}\n\nfunction updateList(firstMsg){\n if(!currentSession)currentSession={id:'s_'+Date.now(),title:firstMsg.slice(0,16)+(firstMsg.length>16?'...':'')};\n document.getElementById('topbarTitle').textContent=currentSession.title;\n renderChatList();\n}\n\n// ── 统一消息格式转换 ──\n// history 中每条消息结构:{role, content, _files:[{kind,type,data,text,name}]}\n// _files 只在当前会话内存中存在,不写入 localStorage\n\nfunction toClaudeMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image',source:{type:'base64',media_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:f.data}});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toOpenAIMessages(messages){\n return messages.map(m=>{\n if(m._files&&m._files.length){\n const parts=[];\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({type:'image_url',image_url:{url:`data:${f.type};base64,${f.data}`}});\n else if(f.kind==='pdf') parts.push({type:'text',text:`【PDF文件:${f.name}(OpenAI 暂不支持直接读取 PDF,以下为文件名提示)】`});\n else parts.push({type:'text',text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n if(m.content) parts.push({type:'text',text:m.content});\n return {role:m.role,content:parts};\n }\n return {role:m.role,content:m.content};\n });\n}\n\nfunction toGeminiContents(messages){\n return messages.map(m=>{\n const parts=[];\n if(m._files&&m._files.length){\n m._files.forEach(f=>{\n if(f.kind==='image') parts.push({inline_data:{mime_type:f.type,data:f.data}});\n else if(f.kind==='pdf') parts.push({inline_data:{mime_type:'application/pdf',data:f.data}});\n else parts.push({text:`【文件:${f.name}】\\n${f.text||''}`});\n });\n }\n if(m.content) parts.push({text:m.content});\n return {role:m.role==='assistant'?'model':'user',parts};\n });\n}\n\nfunction toDeepSeekMessages(messages){\n // DeepSeek 不支持图片/PDF,文字文件以文本形式传入\n return messages.map(m=>{\n if(m._files&&m._files.length){\n let extra='';\n m._files.forEach(f=>{\n if(f.kind==='image') extra+=`\\n【图片文件:${f.name}(DeepSeek 不支持图片输入)】`;\n else if(f.kind==='pdf') extra+=`\\n【PDF文件:${f.name}(DeepSeek 不支持 PDF 输入)】`;\n else extra+=`\\n【文件:${f.name}】\\n${f.text||''}`;\n });\n return {role:m.role,content:(m.content||'')+extra};\n }\n return {role:m.role,content:m.content};\n });\n}\n\n// ── API 调用 ──\n// ── 修改 3:统一四个 API 的返回值格式,并抓取 DeepSeek 的 reasoning_content ──\n\nasync function callClaude(model,messages){\n const msgs=toClaudeMessages(messages);\n // Claude 强制要求该参数,8192 是目前的极限安全值\n const res=await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':keys.claude,'anthropic-version':'2023-06-01','anthropic-dangerous-direct-browser-access':'true'},body:JSON.stringify({model, max_tokens: 8192, temperature:params.temperature,system:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。',messages:msgs})});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.content?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callOpenAI(model,messages){\n // OpenAI 不传 max_tokens,默认允许输出到模型极限\n const body={model, temperature:params.temperature, messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toOpenAIMessages(messages)]};\n if(searchEnabled) body.tools=[{type:'web_search_preview'}];\n const res=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.openai},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.choices?.[0]?.message?.content||'', reasoning: '' };\n}\n\nasync function callGemini(model,messages){\n // Gemini 不传 maxOutputTokens,默认允许输出到模型极限\n const body={contents:toGeminiContents(messages),generationConfig:{temperature:params.temperature}};\n if(searchEnabled) body.tools=[{google_search:{}}];\n const res=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${keys.gemini}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { content: data.candidates?.[0]?.content?.parts?.[0]?.text||'', reasoning: '' };\n}\n\nasync function callDeepSeek(model,messages){\n // DeepSeek 最大支持 8192,强制指定最高值防止其默认截断\n const body={model, max_tokens: 8192, temperature:params.temperature,messages:[{role:'system',content:'你是一个专业、友善的AI助手,用简洁清晰的中文回答问题。你当前使用的模型是:${model}。'},...toDeepSeekMessages(messages)]};\n const res=await fetch('https://api.deepseek.com/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+keys.deepseek},body:JSON.stringify(body)});\n const data=await res.json();\n if(data.error)throw new Error(data.error.message);\n return { \n content: data.choices?.[0]?.message?.content||'', \n reasoning: data.choices?.[0]?.message?.reasoning_content||'' \n };\n}\n\n async function callLocal(messages){\n // 确保传递完整的消息历史,并正确映射角色名\n const formattedMessages = messages.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user', // 确保角色名正确\n content: typeof m.content === 'string' ? m.content : ''\n }));\n \n const res = await fetch('http://localhost:8765', {\n method: 'POST',\n headers: {'Content-Type':'application/json'},\n body: JSON.stringify({\n messages: formattedMessages // 传递完整的历史对话\n })\n });\n \n if (!res.ok) {\n throw new Error(`本地服务器响应错误: ${res.status}`);\n }\n \n const data = await res.json();\n return { \n content: data.answer || '抱歉,本地服务器没有返回有效回答。', \n reasoning: '' \n };\n}\n\n\n// ── 修改 4:更新 send 函数,接收双参数 ──\n\n// 新增 1:提取出通用的 API 请求核心逻辑\nasync function fetchReply() {\n const{provider,model}=getProviderModel();\n try{\n let res = { content: '', reasoning: '' };\n if(provider==='claude') res=await callClaude(model,history);\n else if(provider==='openai') res=await callOpenAI(model,history);\n else if(provider==='gemini') res=await callGemini(model,history);\n else if(provider==='deepseek') res=await callDeepSeek(model,history);\n else if(provider==='local') res=await callLocal(history);\n \n removeTyping();\n addMsg('ai', res.content, null, res.reasoning);\n setTimeout(renderMermaid, 200);\n history.push({role:'assistant', content:res.content, reasoning:res.reasoning}); \n saveCurrentSession();\n renderChatList();\n syncToTeam(); // 静默推送本次花费到团队\n } catch(e) {\n removeTyping();\n // 调用我们刚写的翻译函数\n const friendlyReason = translateError(e.message);\n \n // 把通俗原因和原始英文报错一起显示出来\n const errorText = `**出错了!** ❌\\n\\n**大概原因**:${friendlyReason}\\n\\n> *🔧 原始报错信息(供排查):*\\n> *${e.message}*`;\n \n addMsg('ai', errorText);\n }\n isLoading = false;\n}\n\n// 修改 2:现在的 send 变得非常干净\nasync function send(){\n if(isLoading)return;\n if(!checkKey())return;\n const input=document.getElementById('input');\n const text=input.value.trim();\n if(!text&&!pendingItems.length)return;\n input.value='';input.style.height='auto';\n document.getElementById('sendBtn').className='send-btn';\n isLoading=true;\n \n const items=[...pendingItems];pendingItems=[];renderAttachments();\n addMsg('user',text,items);\n updateList(text||(items[0]?items[0].name:'文件'));\n \n const entry={role:'user',content:text||'请分析上传的文件'};\n if(items.length) entry._files=items.map(f=>({kind:f.kind,type:f.type||'',data:f.data||'',text:f.text||'',name:f.name,preview:f.preview||''}));\n history.push(entry);\n \n addTyping();\n await fetchReply();\n}\n\n// 新增 3:处理重新生成点击事件\nasync function regenerateMsg(wrapElement) {\n if(isLoading) return;\n \n const container = document.getElementById('messages');\n const allWraps = Array.from(container.querySelectorAll('.msg-wrap:not(#typing-wrap)'));\n const index = allWraps.indexOf(wrapElement);\n \n if(index === -1) return;\n\n // 核心逻辑:截断历史记录。保留到当前这条 AI 回复之前的内容\n history = history.slice(0, index);\n\n // 从页面上移除当前这条 AI 回复,以及它后面可能跟着的所有上下文对话\n for(let i = index; i < allWraps.length; i++) {\n allWraps[i].remove();\n }\n\n // 重新发起请求\n isLoading = true;\n addTyping();\n await fetchReply(); \n}\n\n// ── 错误信息翻译机 ──\nfunction translateError(errMsg) {\n const text = (errMsg || '').toLowerCase();\n \n // 1. 余额不足 / 欠费\n if (text.includes('insufficient_quota') || text.includes('balance') || text.includes('credit') || text.includes('payment')) {\n return 'API 余额不足或欠费了,请去对应的官网检查账户额度。💸';\n }\n // 2. 密钥错误 / 封号 / 无权限\n if (text.includes('unauthorized') || text.includes('invalid_api_key') || text.includes('invalid api key') || text.includes('authentication') || text.includes('401')) {\n return 'API Key 无效或未授权。请检查左侧设置里的 Key 是否填错,或者多了一处空格。🔑';\n }\n // 3. 并发过高 / 官方服务器炸了\n if (text.includes('overloaded') || text.includes('rate limit') || text.includes('429') || text.includes('too many requests') || text.includes('resource_exhausted')) {\n return '当前模型官方服务器太拥挤,或者你发得太快了被限流。请喝口水稍等半分钟再试。⏳';\n }\n // 4. 网络不通 / 跨域拦截\n if (text.includes('failed to fetch') || text.includes('network error') || text.includes('connection')) {\n return '网络连接失败!可能是需要科学上网,或者你当前的代理/梯子不稳定。🌐';\n }\n // 5. 模型不存在 / 没权限用这个模型\n if (text.includes('model_not_found') || (text.includes('model') && text.includes('not found'))) {\n return '找不到该模型,或者你的 API Key 没有使用当前选中模型的权限。🛑';\n }\n // 6. 上下文超限 (Token 太多)\n if (text.includes('context_length_exceeded') || text.includes('maximum context length')) {\n return '发的内容太长了!超出了该模型能处理的最大字数限制,请开个“新对话”或精简内容。📏';\n }\n \n // 如果没有匹配到常见错误,就原样返回英文\n return '未知错误,建议翻译一下下面的原始信息。';\n}\n\n// ══════════════════════════════════════════\n// 💰 花费分析 + 👥 团队同步模块\n// ══════════════════════════════════════════\n\nconst CLAUDE_PRICES = {\n 'claude-sonnet-4-20250514': { in: 3.00, out: 15.00, name: 'Sonnet 4.6' },\n 'claude-opus-4-20250514': { in: 15.00, out: 75.00, name: 'Opus 4.6' },\n 'claude-haiku-4-5-20251001':{ in: 0.80, out: 4.00, name: 'Haiku 4.5' },\n};\nconst DEFAULT_PRICE = { in: 3.00, out: 15.00, name: 'Sonnet 4.6' };\nconst SYSTEM_CHARS = 80;\n\n// ── 用户身份 ──\nfunction getMyUserId(){\n let id = localStorage.getItem('team_uid');\n if(!id){ id = 'u_' + Math.random().toString(36).slice(2,10); localStorage.setItem('team_uid', id); }\n return id;\n}\nfunction getMyUserName(){ return localStorage.getItem('team_username') || '匿名用户'; }\n\n// ── JSONBin 配置 ──\nfunction getSyncCfg(){\n return {\n binId: localStorage.getItem('sync_binid') || '',\n masterKey: localStorage.getItem('sync_masterkey') || '',\n };\n}\nfunction hasSyncCfg(){ const c=getSyncCfg(); return !!(c.binId && c.masterKey); }\n\n// ── Token 估算 ──\nfunction charsToTokens(n){ return Math.ceil(n / 3); }\n\n// ── 计算本地单个 session 花费 ──\nfunction calcSessionCost(session){\n const price = CLAUDE_PRICES[session.model] || DEFAULT_PRICE;\n const msgs = (session.messages||[]).filter(m=>m.role==='user'||m.role==='assistant');\n if(!msgs.length) return { cost:0, inputTok:0, outputTok:0, turns:0, price };\n let totalIn=0, totalOut=0, turns=0;\n for(let i=0;i<msgs.length;i++){\n if(msgs[i].role!=='user') continue;\n turns++;\n let inputChars = SYSTEM_CHARS;\n for(let j=0;j<=i;j++) inputChars += (msgs[j].content||'').length;\n totalIn += charsToTokens(inputChars);\n if(i+1<msgs.length && msgs[i+1].role==='assistant'){\n const out = (msgs[i+1].content||'').length + (msgs[i+1].reasoning||'').length;\n totalOut += charsToTokens(out);\n }\n }\n const cost = (totalIn/1e6)*price.in + (totalOut/1e6)*price.out;\n return { cost, inputTok:totalIn, outputTok:totalOut, turns, price };\n}\n\n// ── 汇总本地所有 Claude sessions ──\nfunction calcMyLocalStats(){\n const sessions = loadSessions().filter(s => s.provider==='claude' || CLAUDE_PRICES[s.model]);\n let totalCost=0, totalIn=0, totalOut=0, totalTurns=0;\n const detail = sessions.map(s=>{\n const r = calcSessionCost(s);\n totalCost += r.cost;\n totalIn += r.inputTok;\n totalOut += r.outputTok;\n totalTurns += r.turns;\n return { ...s, ...r };\n }).sort((a,b)=>b.cost-a.cost);\n return { totalCost, totalIn, totalOut, totalTurns, sessions:detail };\n}\n\n// ── JSONBin API ──\nasync function binRead(){\n const { binId } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}/latest`, {\n headers: { 'X-Master-Key': getSyncCfg().masterKey }\n });\n if(!res.ok) throw new Error('读取失败 HTTP '+res.status);\n const data = await res.json();\n return data.record || {};\n}\n\nasync function binWrite(record){\n const { binId, masterKey } = getSyncCfg();\n const res = await fetch(`https://api.jsonbin.io/v3/b/${binId}`, {\n method: 'PUT',\n headers: { 'Content-Type':'application/json', 'X-Master-Key': masterKey },\n body: JSON.stringify(record)\n });\n if(!res.ok) throw new Error('写入失败 HTTP '+res.status);\n return res.json();\n}\n\n// ── 推送当前用户数据到 JSONBin ──\nasync function syncToTeam(silent=true){\n if(!hasSyncCfg()) return;\n const badge = document.getElementById('syncStatusBadge');\n if(badge){ badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…'; }\n try {\n const stats = calcMyLocalStats();\n const uid = getMyUserId();\n const record = await binRead();\n if(!record.users) record.users = {};\n record.users[uid] = {\n name: getMyUserName(),\n totalCost: stats.totalCost,\n inputTok: stats.totalIn,\n outputTok: stats.totalOut,\n sessions: stats.sessions.length,\n turns: stats.totalTurns,\n updatedAt: Date.now()\n };\n await binWrite(record);\n if(badge){ badge.className='sync-status ok'; badge.textContent='✓ 已同步'; setTimeout(()=>{ badge.style.display='none'; }, 3000); }\n } catch(e) {\n if(badge){ badge.className='sync-status err'; badge.textContent='✗ 同步失败'; }\n if(!silent) throw e;\n }\n}\n\n// ── 设置页读取 JSONBin 配置填入表单 ──\nconst _origOpenKeys = openKeys;\nopenKeys = function(){\n _origOpenKeys();\n document.getElementById('sync-username').value = getMyUserName();\n document.getElementById('sync-binid').value = getSyncCfg().binId;\n document.getElementById('sync-masterkey').value= getSyncCfg().masterKey;\n};\n\nasync function testAndSaveSync(){\n const name = document.getElementById('sync-username').value.trim();\n const binId = document.getElementById('sync-binid').value.trim();\n const mk = document.getElementById('sync-masterkey').value.trim();\n const st = document.getElementById('sync-test-status');\n if(!name){ st.textContent='⚠ 请先填写昵称'; return; }\n if(!binId || !mk){ st.textContent='⚠ 请填写 Bin ID 和 Master Key'; return; }\n st.textContent = '连接测试中…';\n try {\n localStorage.setItem('team_username', name);\n localStorage.setItem('sync_binid', binId);\n localStorage.setItem('sync_masterkey', mk);\n await syncToTeam(false);\n st.style.color = '#2d7a3a';\n st.textContent = '✓ 连接成功,已同步你的数据!';\n } catch(e) {\n st.style.color = '#c0392b';\n st.textContent = '✗ 失败:' + e.message;\n }\n}\n\n// ── 花费分析弹窗 ──\nlet costView = 'team'; // 'team' | 'me'\nfunction setView(v){ costView=v; document.getElementById('btnViewTeam').className='ptab'+(v==='team'?' active':''); document.getElementById('btnViewMe').className='ptab'+(v==='me'?' active':''); renderCostDetails(); }\n\nfunction fmtTok(n){ if(n>=1e6)return(n/1e6).toFixed(2)+'M'; if(n>=1e3)return(n/1e3).toFixed(1)+'K'; return n+''; }\nfunction fmtTime(ts){ if(!ts)return''; const d=new Date(ts); return`${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }\nconst AVATAR_COLORS = ['#c96a3b','#4285f4','#19c37d','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db'];\nfunction avatarColor(uid){ let h=0; for(const c of uid) h=(h*31+c.charCodeAt(0))&0xffffffff; return AVATAR_COLORS[Math.abs(h)%AVATAR_COLORS.length]; }\n\nfunction openCostModal(){\n document.getElementById('costModal').style.display='flex';\n refreshCostModal();\n}\nfunction closeCostModal(){ document.getElementById('costModal').style.display='none'; }\n\nasync function refreshCostModal(){\n const badge = document.getElementById('syncStatusBadge');\n const syncCfg = hasSyncCfg();\n document.getElementById('viewToggleWrap').style.display = syncCfg ? '' : 'none';\n document.getElementById('teamModeLabel').innerHTML = syncCfg\n ? '<span style=\"color:var(--accent);font-size:12px\">👥 团队同步已开启</span>'\n : '<span style=\"color:var(--muted);font-size:12px\">⚠ 仅统计本机 Claude 对话。在设置里配置 JSONBin 可查看全员花费。</span>';\n\n if(syncCfg){\n badge.style.display='inline-flex'; badge.className='sync-status ing'; badge.textContent='⟳ 同步中…';\n try {\n await syncToTeam(false);\n const record = await binRead();\n badge.className='sync-status ok'; badge.textContent='✓ 数据已更新';\n setTimeout(()=>{ badge.style.display='none'; },3000);\n renderCostDetails(record.users || {});\n } catch(e){\n badge.className='sync-status err'; badge.textContent='✗ '+e.message;\n renderCostDetails();\n }\n } else {\n badge.style.display='none';\n renderCostDetails();\n }\n}\n\nfunction renderCostDetails(teamUsers){\n const budget = parseFloat(document.getElementById('budgetInput').value)||20;\n const myStats = calcMyLocalStats();\n const showTeam = hasSyncCfg() && teamUsers && costView==='team';\n\n // ── 计算展示数据 ──\n let totalCost, totalIn, totalOut;\n if(showTeam){\n const users = Object.values(teamUsers);\n totalCost = users.reduce((s,u)=>s+u.totalCost,0);\n totalIn = users.reduce((s,u)=>s+u.inputTok,0);\n totalOut = users.reduce((s,u)=>s+u.outputTok,0);\n } else {\n totalCost = myStats.totalCost;\n totalIn = myStats.totalIn;\n totalOut = myStats.totalOut;\n }\n const remaining = Math.max(0, budget - totalCost);\n const pct = Math.min(100, (totalCost/budget)*100);\n\n // ── 总览卡片 ──\n document.getElementById('costSummary').innerHTML = `\n <div class=\"cost-summary-grid\">\n <div class=\"cost-card accent\">\n <div class=\"cv\">$${totalCost.toFixed(4)}</div>\n <div class=\"cl\">${showTeam?'全员总花费':'我的总花费'}</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalIn)}</div>\n <div class=\"cl\">输入 Tokens</div>\n </div>\n <div class=\"cost-card\">\n <div class=\"cv\">${fmtTok(totalOut)}</div>\n <div class=\"cl\">输出 Tokens</div>\n </div>\n </div>`;\n\n // ── 进度条 ──\n document.getElementById('costProgressWrap').style.display='';\n document.getElementById('costProgressLabel').textContent=`已消耗 $${totalCost.toFixed(4)}`;\n document.getElementById('costProgressRight').textContent=`剩余约 $${remaining.toFixed(2)}`;\n const bar = document.getElementById('costProgressBar');\n bar.style.width=pct+'%';\n bar.style.background = pct>80?'#c0392b':pct>50?'#e67e22':'var(--accent)';\n\n // ── 内容区 ──\n const main = document.getElementById('costMainContent');\n if(showTeam){\n // 全员视图\n const myUid = getMyUserId();\n const users = Object.entries(teamUsers).sort((a,b)=>b[1].totalCost-a[1].totalCost);\n if(!users.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无团队数据</div>'; return; }\n const maxCost = users[0][1].totalCost || 0.0001;\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">成员花费排行</div>\n <div style=\"display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto\">\n ${users.map(([uid,u])=>{\n const pct2 = (u.totalCost/maxCost*100).toFixed(1);\n const isMe = uid===myUid;\n const col = avatarColor(uid);\n return `<div class=\"team-row\" style=\"${isMe?'border-color:var(--accent);background:var(--accent-light)':''}\">\n <div class=\"team-avatar\" style=\"background:${col}20;color:${col}\">${(u.name||'?')[0].toUpperCase()}</div>\n <div style=\"flex:1;min-width:0\">\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span class=\"team-name\">${u.name||'未知'}${isMe?' <span style=\"font-size:10px;color:var(--accent)\">(我)</span>':''}</span>\n <span style=\"font-size:10px;color:var(--muted)\">${u.sessions||0}个对话 · ${u.turns||0}轮</span>\n </div>\n <div class=\"team-bar-wrap\"><div class=\"team-bar\" style=\"width:${pct2}%;background:${col}\"></div></div>\n <div class=\"team-meta\">输入 ${fmtTok(u.inputTok||0)} · 输出 ${fmtTok(u.outputTok||0)} · 最后更新 ${fmtTime(u.updatedAt)}</div>\n </div>\n <div class=\"team-cost\" style=\"color:${col}\">$${(u.totalCost||0).toFixed(4)}</div>\n </div>`;\n }).join('')}\n </div>`;\n } else {\n // 我的明细视图\n const rows = myStats.sessions;\n if(!rows.length){ main.innerHTML='<div style=\"text-align:center;padding:24px;color:var(--muted);font-size:13px\">暂无 Claude 对话记录</div>'; return; }\n main.innerHTML = `\n <div style=\"font-size:12px;color:var(--muted);margin-bottom:8px;font-weight:500;letter-spacing:0.4px;text-transform:uppercase\">我的对话明细(按花费排序)</div>\n <div style=\"max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:6px\">\n ${rows.map(r=>`\n <div class=\"cost-row\">\n <div class=\"cost-row-title\" title=\"${r.title}\">${r.title||'无标题'}</div>\n <span class=\"cost-row-model\">${r.price.name}</span>\n <div class=\"cost-row-meta\">${r.turns}轮 · ${fmtTok(r.inputTok+r.outputTok)} tok</div>\n <div class=\"cost-row-price\">$${r.cost.toFixed(5)}</div>\n </div>`).join('')}\n </div>`;\n }\n}\n\nmermaid.initialize({startOnLoad:false,theme:'neutral'});\nfunction renderMermaid(){\n document.querySelectorAll('.mermaid:not([data-processed])').forEach(el=>{\n mermaid.render('mg'+Math.random().toString(36).slice(2),el.textContent).then(({svg})=>{\n el.innerHTML=svg;el.setAttribute('data-processed','true');\n }).catch(()=>{});\n });\n}\ninit();\n</script>\n</body>\n</html>\n"}}}} |