Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/200011113zdy-collab_neuise-shg-team-ai.json

1 line
2.5 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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\">🟢&lt;50%&nbsp;·&nbsp;🟡50~80%&nbsp;·&nbsp;🔴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&&currentSession.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 += `&nbsp;&nbsp;|&nbsp;&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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\">🟢&lt;50%&nbsp;·&nbsp;🟡50~80%&nbsp;·&nbsp;🔴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&&currentSession.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 += `&nbsp;&nbsp;|&nbsp;&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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\">🟢&lt;50%&nbsp;·&nbsp;🟡50~80%&nbsp;·&nbsp;🔴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&&currentSession.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 += `&nbsp;&nbsp;|&nbsp;&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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\">🟢&lt;50%&nbsp;·&nbsp;🟡50~80%&nbsp;·&nbsp;🔴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&&currentSession.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 += `&nbsp;&nbsp;|&nbsp;&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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\">🟢&lt;50%&nbsp;·&nbsp;🟡50~80%&nbsp;·&nbsp;🔴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&&currentSession.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 += `&nbsp;&nbsp;|&nbsp;&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'\"').replace(/&#39;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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&&currentSession.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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/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 === &#39;assistant&#39; ? &#39;assistant&#39; : &#39;user&#39;, // 确保角色名正确\n content: typeof m.content === &#39;string&#39; ? m.content : &#39;&#39;\n }));\n \n const res = await fetch(&#39;http://localhost:8765&#39;, {\n method: &#39;POST&#39;,\n headers: {&#39;Content-Type&#39;:&#39;application/json&#39;},\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 || &#39;抱歉,本地服务器没有返回有效回答。&#39;, \n reasoning: &#39;&#39; \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"}}}}