1 line
662 KiB
JSON
1 line
662 KiB
JSON
{"repo": "caizihanf-ctrl/aichatmath", "files": {"index.html": {"ee4c375fa281b0ccb69a050a6759ec266f8a8255": {"t": 1785746531.519204, "n": 83689, "h": "0c4de792b0132312", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f6fb;\n --panel-color: #2c3e50;\n --panel-muted: #34495e;\n --primary-color: #3d7bd9;\n --primary-dark: #2764ba;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n --text-muted: #64748b;\n }\n\n * {\n box-sizing: border-box;\n touch-action: manipulation;\n }\n\n body,\n html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .settings-header {\n background: var(--panel-color);\n color: white;\n padding: 10px 15px;\n font-size: 0.92rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .settings-title {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .settings-panel {\n background: var(--panel-muted);\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n max-height: 54vh;\n overflow-y: auto;\n }\n\n .settings-panel.show {\n display: flex;\n }\n\n .settings-row {\n display: flex;\n gap: 10px;\n width: 100%;\n flex-wrap: wrap;\n }\n\n .settings-group {\n display: flex;\n flex-direction: column;\n gap: 6px;\n flex: 1;\n min-width: 220px;\n }\n\n .settings-group label {\n color: #dbeafe;\n font-size: 0.78rem;\n font-weight: 700;\n }\n\n .settings-panel input,\n .settings-panel textarea {\n background: var(--panel-color);\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n font: inherit;\n font-size: 0.9rem;\n outline: none;\n }\n\n .settings-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n min-width: 16px;\n padding: 0;\n accent-color: var(--primary-color);\n }\n\n .checkbox-label {\n color: #e2e8f0;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 0.84rem;\n font-weight: 700;\n min-height: 38px;\n }\n\n .settings-panel textarea {\n min-height: 76px;\n resize: vertical;\n line-height: 1.45;\n }\n\n .settings-panel input::placeholder,\n .settings-panel textarea::placeholder {\n color: #b7c9d8;\n }\n\n .setting-note {\n color: #cbd5e1;\n font-size: 0.75rem;\n line-height: 1.35;\n }\n\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 220px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: 0;\n width: 100%;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n min-width: 42px;\n }\n\n .model-list-btn:hover {\n background: #3498db;\n }\n\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: var(--panel-color);\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.28);\n margin-top: 2px;\n }\n\n .model-dropdown.show {\n display: block;\n }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:hover {\n background: #3c5870;\n color: #dff2ff;\n }\n\n .model-option.loading {\n text-align: center;\n color: #bdc3c7;\n cursor: default;\n }\n\n .model-option.error {\n color: #fecaca;\n font-size: 0.8rem;\n cursor: default;\n }\n\n .action-btn {\n padding: 8px 13px;\n min-height: 38px;\n color: white;\n border: 0;\n border-radius: 4px;\n cursor: pointer;\n font-weight: 700;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n justify-content: center;\n }\n\n .save-btn {\n background: #238457;\n }\n\n .save-btn:hover {\n background: #2aa96d;\n }\n\n .export-btn {\n background: #ce6f18;\n }\n\n .export-btn:hover {\n background: #b85f0f;\n }\n\n .autosave-btn {\n background: #7f3a98;\n }\n\n .autosave-btn:hover {\n background: #9145ad;\n }\n\n .autosave-btn.active {\n background: #238457;\n }\n\n .import-btn {\n background: #118270;\n }\n\n .import-btn:hover {\n background: #159c88;\n }\n\n .sync-btn {\n background: #3366a8;\n }\n\n .sync-btn:hover {\n background: #3e78c4;\n }\n\n .danger-btn {\n background: #a33a3a;\n }\n\n .danger-btn:hover {\n background: #bf4646;\n }\n\n .status-indicator {\n font-size: 0.8rem;\n color: #bdc3c7;\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n }\n\n .status-indicator.recording {\n color: #86efac;\n }\n\n .app-main {\n flex: 1;\n min-height: 0;\n display: flex;\n overflow: hidden;\n }\n\n .conversation-sidebar {\n width: 280px;\n min-width: 240px;\n max-width: 34vw;\n background: #ffffff;\n border-right: 1px solid var(--border-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .conversation-sidebar-header {\n padding: 12px;\n border-bottom: 1px solid #e5e7eb;\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n .conversation-sidebar-title {\n color: #1f2937;\n font-size: 0.95rem;\n font-weight: 800;\n }\n\n .conversation-actions {\n display: flex;\n gap: 8px;\n }\n\n .side-btn {\n flex: 1;\n border: 1px solid #cbd5e1;\n background: #f8fafc;\n color: #334155;\n border-radius: 6px;\n min-height: 34px;\n cursor: pointer;\n font-weight: 700;\n }\n\n .side-btn:hover {\n background: #eef2f7;\n }\n\n .conversation-list {\n flex: 1;\n overflow-y: auto;\n padding: 8px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .conversation-item {\n border: 1px solid #e2e8f0;\n border-radius: 8px;\n background: #ffffff;\n padding: 9px;\n display: grid;\n grid-template-columns: 1fr auto;\n gap: 6px;\n cursor: pointer;\n }\n\n .conversation-item:hover {\n background: #f8fafc;\n }\n\n .conversation-item.active {\n border-color: var(--primary-color);\n background: #eef6ff;\n }\n\n .conversation-item-title {\n color: #111827;\n font-size: 0.9rem;\n font-weight: 750;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .conversation-item-meta {\n color: var(--text-muted);\n font-size: 0.74rem;\n grid-column: 1 / -1;\n }\n\n .conversation-delete {\n width: 24px;\n height: 24px;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: #64748b;\n cursor: pointer;\n font-size: 1rem;\n line-height: 1;\n }\n\n .conversation-delete:hover {\n background: #fee2e2;\n color: #b91c1c;\n }\n\n .conversation-empty {\n color: var(--text-muted);\n font-size: 0.84rem;\n line-height: 1.5;\n padding: 12px;\n }\n\n #chat-container {\n flex: 1;\n min-width: 0;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: min(92%, 900px);\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n overflow-wrap: anywhere;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);\n }\n\n .message.user {\n align-self: flex-end;\n background: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background: white;\n color: #263238;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n .message.system {\n align-self: center;\n background: #fff7ed;\n color: #9a3412;\n border: 1px solid #fed7aa;\n max-width: min(92%, 720px);\n }\n\n .message.ai pre {\n background: #2d2d2d;\n color: #f8f8f2;\n padding: 10px;\n border-radius: 5px;\n overflow-x: auto;\n }\n\n .message.ai p {\n margin: 0 0 8px 0;\n }\n\n .message.ai p:last-child {\n margin: 0;\n }\n\n .message-images {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n gap: 8px;\n margin-top: 10px;\n }\n\n .message-image {\n width: 100%;\n max-height: 220px;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n background: rgba(255, 255, 255, 0.1);\n }\n\n .message.ai .message-image {\n border-color: var(--border-color);\n }\n\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative;\n }\n\n .math-preview-area {\n background: #fffdf5;\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none;\n }\n\n .math-preview-area.show {\n display: block;\n }\n\n .math-preview-area p {\n margin: 0;\n }\n\n .attachment-tray {\n display: none;\n gap: 8px;\n padding: 8px 10px;\n border-bottom: 1px solid #e5e7eb;\n overflow-x: auto;\n background: #f8fafc;\n }\n\n .attachment-tray.show {\n display: flex;\n }\n\n .attachment-thumb {\n position: relative;\n width: 70px;\n height: 70px;\n flex: 0 0 auto;\n }\n\n .attachment-thumb img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid #cbd5e1;\n display: block;\n }\n\n .attachment-thumb button {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 22px;\n height: 22px;\n border: 0;\n border-radius: 50%;\n background: #1f2937;\n color: white;\n cursor: pointer;\n line-height: 1;\n }\n\n .math-toolbar-container {\n background: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar {\n display: none;\n }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background: #e2e8f0;\n transform: translateY(1px);\n }\n\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n #attach-btn {\n width: 44px;\n height: 44px;\n border: 1px solid var(--border-color);\n background: #f8fafc;\n color: #334155;\n border-radius: 8px;\n cursor: pointer;\n flex-shrink: 0;\n font-weight: 800;\n font-size: 1.1rem;\n }\n\n #attach-btn:hover {\n background: #eef2f7;\n }\n\n textarea#user-input {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n color: #111827;\n }\n\n textarea#user-input:focus {\n border-color: var(--primary-color);\n }\n\n button#send-btn {\n background: var(--primary-color);\n color: white;\n border: 0;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: 700;\n cursor: pointer;\n flex-shrink: 0;\n }\n\n button#send-btn:hover {\n background: var(--primary-dark);\n }\n\n button#send-btn:disabled {\n background: #94a3b8;\n cursor: not-allowed;\n }\n\n @media (max-width: 640px) {\n .app-main {\n flex-direction: column;\n }\n\n .conversation-sidebar {\n width: 100%;\n max-width: none;\n min-width: 0;\n max-height: 34vh;\n border-right: 0;\n border-bottom: 1px solid var(--border-color);\n }\n\n .conversation-sidebar-header {\n padding: 10px;\n }\n\n .settings-group {\n min-width: 100%;\n }\n\n .settings-row {\n gap: 8px;\n }\n\n .message {\n max-width: 96%;\n }\n\n button#send-btn {\n padding: 0 14px;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"settings-header\" onclick=\"toggleSettings()\">\n <div class=\"settings-title\">\n <span>设置与同步</span>\n <span id=\"autosave-status\" class=\"status-indicator\"></span>\n </div>\n <span id=\"toggle-icon\">▼</span>\n </div>\n\n <div class=\"settings-panel\" id=\"settings-panel\">\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-url\">默认 API 地址</label>\n <input type=\"text\" id=\"api-url\" placeholder=\"https://api.openai.com/v1\">\n </div>\n <div class=\"settings-group\">\n <label for=\"api-key\">默认 API Key</label>\n <input type=\"password\" id=\"api-key\" placeholder=\"sk-...\">\n </div>\n <div class=\"settings-group\">\n <label for=\"model-name\">默认模型</label>\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"gpt-4o, deepseek-chat\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-list\">多 API 轮询列表</label>\n <textarea id=\"api-list\" placeholder=\"每行一个:API地址 | API Key | 模型(可选) https://api.openai.com/v1 | sk-xxx | gpt-4o https://api.deepseek.com/v1 | sk-yyy | deepseek-chat\"></textarea>\n <div class=\"setting-note\">发送时按轮询顺序选择接口;当前接口失败会自动尝试下一行。留空则使用上面的默认 API。</div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"nutstore-url\">坚果云 WebDAV 中转地址</label>\n <input type=\"text\" id=\"nutstore-url\" placeholder=\"https://zihancai.top/dav/\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-user\">坚果云账号</label>\n <input type=\"text\" id=\"nutstore-user\" placeholder=\"你的坚果云登录邮箱\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-pass\">坚果云应用密码</label>\n <input type=\"password\" id=\"nutstore-pass\" placeholder=\"坚果云第三方应用密码\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-path\">同步文件路径</label>\n <input type=\"text\" id=\"nutstore-path\" placeholder=\"/MathAIChat/chat.json\">\n </div>\n </div>\n\n <div class=\"setting-note\">\n 当前默认通过 zihancai.top 的 nginx WebDAV 反代与坚果云同步;坚果云账号和应用密码仍填写原来的坚果云凭据。\n </div>\n\n <div class=\"settings-row\">\n <label class=\"checkbox-label\">\n <input type=\"checkbox\" id=\"nutstore-auto\">\n 启用坚果云自动同步\n </label>\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn sync-btn\" onclick=\"useNutstoreProxy()\">使用中转服务器</button>\n <button id=\"btn-autosave\" class=\"action-btn autosave-btn\" onclick=\"setupAutoSave()\">启用本地自动保存</button>\n <input type=\"file\" id=\"import-file\" accept=\".json,application/json\" style=\"display:none\" onchange=\"handleFileImport(this)\">\n <button class=\"action-btn import-btn\" onclick=\"document.getElementById('import-file').click()\">加载对话</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n <button class=\"action-btn sync-btn\" onclick=\"testNutstoreConnection()\">检测坚果云连接</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncFromNutstore()\">从坚果云拉取</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncToNutstore()\">同步到坚果云</button>\n <button class=\"action-btn danger-btn\" onclick=\"clearConversation()\">清空当前对话</button>\n </div>\n </div>\n\n <div class=\"app-main\">\n <aside class=\"conversation-sidebar\">\n <div class=\"conversation-sidebar-header\">\n <div class=\"conversation-sidebar-title\">已保存对话</div>\n <div class=\"conversation-actions\">\n <button class=\"side-btn\" onclick=\"newConversation()\">新建</button>\n <button class=\"side-btn\" onclick=\"saveCurrentConversation()\">保存当前</button>\n </div>\n </div>\n <div id=\"conversation-list\" class=\"conversation-list\"></div>\n </aside>\n\n <div id=\"chat-container\"></div>\n </div>\n\n <div class=\"bottom-section\">\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n <div id=\"attachment-tray\" class=\"attachment-tray\"></div>\n\n <div class=\"math-toolbar-container\">\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <input type=\"file\" id=\"image-input\" accept=\"image/*\" multiple style=\"display:none\" onchange=\"handleImageUpload(this)\">\n <button id=\"attach-btn\" onclick=\"document.getElementById('image-input').click()\" title=\"上传图片\">+</button>\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n </div>\n\n <script>\n const STORAGE_KEYS = {\n apiUrl: 'api_url',\n apiKey: 'api_key',\n modelName: 'model_name',\n apiList: 'api_list',\n conversation: 'math_chat_conversation_v2',\n conversations: 'math_chat_saved_conversations_v1',\n activeConversationId: 'math_chat_active_conversation_id',\n apiIndex: 'math_chat_api_index',\n nutstoreUrl: 'nutstore_url',\n nutstoreUser: 'nutstore_user',\n nutstorePass: 'nutstore_pass',\n nutstorePath: 'nutstore_path',\n nutstoreAuto: 'nutstore_auto_sync'\n };\n\n const defaultUrl = 'https://api.openai.com/v1';\n const defaultModel = 'gpt-4o';\n const directNutstoreUrl = 'https://dav.jianguoyun.com/dav/';\n const legacyProxyNutstoreUrl = 'http://47.237.92.156/dav/';\n const proxyNutstoreHost = 'zihancai.top';\n const proxyNutstorePath = '/dav/';\n const proxyNutstoreUrl = getProxyNutstoreUrl();\n const defaultNutstoreUrl = proxyNutstoreUrl;\n const defaultNutstorePath = '/MathAIChat/chat.json';\n const maxImageBytes = 8 * 1024 * 1024;\n\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1 },\n { label: 'x', insert: 'x', offset: 0 },\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n const chatContainer = document.getElementById('chat-container');\n const conversationList = document.getElementById('conversation-list');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n const attachmentTray = document.getElementById('attachment-tray');\n\n let messageHistory = [];\n let currentConversationId = localStorage.getItem(STORAGE_KEYS.activeConversationId) || '';\n let pendingImages = [];\n let fileHandle = null;\n let isAutoSaveActive = false;\n let renderPromise = Promise.resolve();\n let apiRoundRobinIndex = Number(localStorage.getItem(STORAGE_KEYS.apiIndex) || 0);\n let autoSyncTimer = null;\n\n window.addEventListener('load', initApp);\n\n function initApp()\n {\n document.getElementById('api-url').value = localStorage.getItem(STORAGE_KEYS.apiUrl) || '';\n document.getElementById('api-key').value = localStorage.getItem(STORAGE_KEYS.apiKey) || '';\n document.getElementById('model-name').value = localStorage.getItem(STORAGE_KEYS.modelName) || defaultModel;\n document.getElementById('api-list').value = localStorage.getItem(STORAGE_KEYS.apiList) || '';\n const savedNutstoreUrl = localStorage.getItem(STORAGE_KEYS.nutstoreUrl);\n document.getElementById('nutstore-url').value = (!savedNutstoreUrl || isDirectNutstoreUrl(savedNutstoreUrl) || isLegacyProxyNutstoreUrl(savedNutstoreUrl))\n ? defaultNutstoreUrl\n : savedNutstoreUrl;\n document.getElementById('nutstore-user').value = localStorage.getItem(STORAGE_KEYS.nutstoreUser) || '';\n document.getElementById('nutstore-pass').value = localStorage.getItem(STORAGE_KEYS.nutstorePass) || '';\n document.getElementById('nutstore-path').value = localStorage.getItem(STORAGE_KEYS.nutstorePath) || defaultNutstorePath;\n document.getElementById('nutstore-auto').checked = localStorage.getItem(STORAGE_KEYS.nutstoreAuto) === 'true';\n\n if (window.marked)\n {\n marked.setOptions({\n highlight: (code, lang) =>\n {\n if (!window.hljs) return code;\n return hljs.highlight(code, { language: hljs.getLanguage(lang) ? lang : 'plaintext' }).value;\n }\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n setupGlobalClickHandlers();\n setupMcpBridge();\n loadConversationFromCache();\n }\n\n function setupGlobalClickHandlers()\n {\n document.addEventListener('click', (event) =>\n {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (dropdown && !dropdown.contains(event.target) && event.target !== btn)\n {\n dropdown.classList.remove('show');\n }\n });\n }\n\n function renderToolbar(containerId, symbols)\n {\n const container = document.getElementById(containerId);\n if (!container) return;\n container.innerHTML = '';\n symbols.forEach((sym) =>\n {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (event) => event.preventDefault();\n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n function insertSymbol(text, offsetBack)\n {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n function toggleSettings()\n {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n panel.classList.toggle('show');\n icon.textContent = panel.classList.contains('show') ? '▲' : '▼';\n }\n\n function saveSettings()\n {\n localStorage.setItem(STORAGE_KEYS.apiUrl, document.getElementById('api-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiKey, document.getElementById('api-key').value.trim());\n localStorage.setItem(STORAGE_KEYS.modelName, document.getElementById('model-name').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiList, document.getElementById('api-list').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUrl, document.getElementById('nutstore-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUser, document.getElementById('nutstore-user').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePass, document.getElementById('nutstore-pass').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePath, document.getElementById('nutstore-path').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreAuto, document.getElementById('nutstore-auto').checked ? 'true' : 'false');\n setStatus('配置已保存');\n }\n\n function useNutstoreProxy()\n {\n document.getElementById('nutstore-url').value = proxyNutstoreUrl;\n saveSettings();\n setStatus('已切换到中转服务器', 'recording');\n }\n\n function setStatus(text, type = '')\n {\n const status = document.getElementById('autosave-status');\n status.textContent = text ? `● ${text}` : '';\n status.className = `status-indicator ${type}`;\n }\n\n async function setupAutoSave()\n {\n if (!('showDirectoryPicker' in window))\n {\n alert('您的浏览器不支持文件系统访问 API。请使用 Chrome/Edge 桌面版。');\n return;\n }\n\n try\n {\n const dirHandle = await window.showDirectoryPicker();\n const fileName = `chat_${formatTimestamp(new Date())}.json`;\n fileHandle = await dirHandle.getFileHandle(fileName, { create: true });\n isAutoSaveActive = true;\n const btn = document.getElementById('btn-autosave');\n btn.textContent = `正在保存:${fileName}`;\n btn.classList.add('active');\n setStatus('正在记录', 'recording');\n await writeToFile();\n } catch (err)\n {\n if (err.name !== 'AbortError')\n {\n console.error(err);\n alert('设置自动保存失败: ' + err.message);\n }\n }\n }\n\n async function writeToFile()\n {\n if (!fileHandle || !isAutoSaveActive) return;\n try\n {\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(buildConversationPayload(), null, 2));\n await writable.close();\n } catch (err)\n {\n console.error('自动保存写入失败:', err);\n setStatus('保存失败');\n }\n }\n\n function buildConversationPayload()\n {\n return {\n version: 2,\n app: 'Math AI Chat',\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n exportedAt: new Date().toISOString(),\n messages: messageHistory\n };\n }\n\n function persistConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n writeToFile();\n scheduleAutoSync();\n }\n\n function loadConversationFromCache()\n {\n migrateLegacyConversation();\n renderConversationList();\n const saved = getSavedConversations();\n const active = saved.find((item) => item.id === currentConversationId) || saved[0];\n if (active)\n {\n currentConversationId = active.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = active.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n renderConversationList();\n return;\n }\n\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n renderGreeting();\n renderConversationList();\n return;\n }\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (messages.length)\n {\n restoreConversation(messages, { silent: true });\n return;\n }\n } catch (err)\n {\n console.warn('本地缓存对话加载失败:', err);\n }\n\n renderGreeting();\n renderConversationList();\n }\n\n function getSavedConversations()\n {\n try\n {\n const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.conversations) || '[]');\n return Array.isArray(parsed) ? parsed.filter((item) => item && item.id && Array.isArray(item.messages)) : [];\n } catch (err)\n {\n console.warn('保存对话列表读取失败:', err);\n return [];\n }\n }\n\n function setSavedConversations(conversations)\n {\n localStorage.setItem(STORAGE_KEYS.conversations, JSON.stringify(conversations));\n }\n\n function saveConversationToLibrary()\n {\n if (!currentConversationId)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n\n const conversations = getSavedConversations();\n const now = new Date().toISOString();\n const existing = conversations.find((item) => item.id === currentConversationId);\n const record = {\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n createdAt: existing?.createdAt || now,\n updatedAt: now,\n messages: messageHistory.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }))\n };\n\n const next = [record, ...conversations.filter((item) => item.id !== currentConversationId)]\n .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))\n .slice(0, 80);\n setSavedConversations(next);\n renderConversationList();\n }\n\n function migrateLegacyConversation()\n {\n const saved = getSavedConversations();\n if (saved.length) return;\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw) return;\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (!messages.length) return;\n currentConversationId = currentConversationId || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n setSavedConversations([{\n id: currentConversationId,\n title: getConversationTitle(messages),\n createdAt: new Date().toISOString(),\n updatedAt: new Date().toISOString(),\n messages\n }]);\n } catch (err)\n {\n console.warn('旧对话迁移失败:', err);\n }\n }\n\n function renderConversationList()\n {\n if (!conversationList) return;\n const conversations = getSavedConversations();\n conversationList.innerHTML = '';\n\n if (!conversations.length)\n {\n const empty = document.createElement('div');\n empty.className = 'conversation-empty';\n empty.textContent = '还没有保存的对话。发送消息、导入文件或点击“保存当前”后会出现在这里。';\n conversationList.appendChild(empty);\n return;\n }\n\n conversations.forEach((conversation) =>\n {\n const item = document.createElement('div');\n item.className = `conversation-item ${conversation.id === currentConversationId ? 'active' : ''}`;\n item.onclick = () => loadSavedConversation(conversation.id);\n\n const title = document.createElement('div');\n title.className = 'conversation-item-title';\n title.textContent = conversation.title || '未命名对话';\n\n const deleteBtn = document.createElement('button');\n deleteBtn.type = 'button';\n deleteBtn.className = 'conversation-delete';\n deleteBtn.textContent = '×';\n deleteBtn.title = '删除对话';\n deleteBtn.onclick = (event) =>\n {\n event.stopPropagation();\n deleteSavedConversation(conversation.id);\n };\n\n const meta = document.createElement('div');\n meta.className = 'conversation-item-meta';\n meta.textContent = `${conversation.messages.length} 条消息 · ${formatShortTime(conversation.updatedAt)}`;\n\n item.appendChild(title);\n item.appendChild(deleteBtn);\n item.appendChild(meta);\n conversationList.appendChild(item);\n });\n }\n\n function loadSavedConversation(id)\n {\n const conversation = getSavedConversations().find((item) => item.id === id);\n if (!conversation) return;\n currentConversationId = conversation.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = conversation.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n clearPendingImages();\n renderConversation();\n renderConversationList();\n setStatus('已切换对话', 'recording');\n }\n\n function deleteSavedConversation(id)\n {\n if (!confirm('确定要删除这条保存的对话吗?')) return;\n const conversations = getSavedConversations().filter((item) => item.id !== id);\n setSavedConversations(conversations);\n if (id === currentConversationId)\n {\n const next = conversations[0];\n if (next)\n {\n loadSavedConversation(next.id);\n } else\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n renderGreeting();\n renderConversationList();\n }\n } else\n {\n renderConversationList();\n }\n setStatus('已删除对话');\n }\n\n function newConversation()\n {\n if (messageHistory.length) saveConversationToLibrary();\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n clearPendingImages();\n renderGreeting();\n renderConversationList();\n setStatus('新对话');\n userInput.focus();\n }\n\n function saveCurrentConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n setStatus('当前对话已保存', 'recording');\n }\n\n function createConversationId()\n {\n return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n }\n\n function getConversationTitle(messages)\n {\n const firstUser = messages.find((msg) => msg.role === 'user');\n const text = stringifyContent(firstUser?.content || '').replace(/\\s+/g, ' ').trim();\n if (text) return text.slice(0, 32);\n return `新对话 ${formatShortTime(new Date().toISOString())}`;\n }\n\n function renderGreeting()\n {\n chatContainer.innerHTML = '';\n const div = document.createElement('div');\n div.className = 'message ai';\n div.innerHTML = '你好!我是你的数学助手。<br>你可以输入数学问题、上传图片,也可以从设置中导入或同步对话。';\n chatContainer.appendChild(div);\n }\n\n function handleFileImport(input)\n {\n const file = input.files[0];\n if (!file) return;\n\n const reader = new FileReader();\n reader.onload = (event) =>\n {\n try\n {\n const payload = JSON.parse(event.target.result);\n const loadedHistory = parseConversationPayload(payload);\n if (!loadedHistory.length)\n {\n throw new Error('文件中没有可加载的对话消息');\n }\n restoreConversation(loadedHistory);\n alert(`成功加载 ${loadedHistory.length} 条消息`);\n } catch (err)\n {\n alert('加载失败: ' + err.message);\n } finally\n {\n input.value = '';\n }\n };\n reader.onerror = () =>\n {\n alert('读取文件失败');\n input.value = '';\n };\n reader.readAsText(file, 'utf-8');\n }\n\n function parseConversationPayload(payload)\n {\n const source = Array.isArray(payload)\n ? payload\n : (payload?.messages || payload?.messageHistory || payload?.history || []);\n\n if (!Array.isArray(source))\n {\n throw new Error('文件格式错误:未找到 messages 数组');\n }\n\n return source\n .map(normalizeMessage)\n .filter(Boolean);\n }\n\n function normalizeMessage(msg)\n {\n if (!msg || typeof msg !== 'object') return null;\n const roleMap = { ai: 'assistant', bot: 'assistant', assistant: 'assistant', user: 'user', system: 'system' };\n const role = roleMap[msg.role] || 'user';\n const normalized = normalizeContent(msg.content ?? msg.text ?? '');\n if (typeof normalized === 'string' && normalized.length === 0) return null;\n if (Array.isArray(normalized) && normalized.length === 0) return null;\n return { role, content: normalized };\n }\n\n function normalizeContent(content)\n {\n if (typeof content === 'string') return content;\n if (!Array.isArray(content))\n {\n return typeof content === 'undefined' || content === null ? '' : String(content);\n }\n\n const parts = [];\n content.forEach((part) =>\n {\n if (!part) return;\n if (typeof part === 'string')\n {\n parts.push({ type: 'text', text: part });\n return;\n }\n if (part.type === 'text')\n {\n parts.push({ type: 'text', text: String(part.text || '') });\n return;\n }\n if (part.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n return;\n }\n if (part.type === 'image' || part.dataUrl)\n {\n const url = part.dataUrl || part.url || part.src;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n }\n });\n return parts;\n }\n\n function restoreConversation(messages, options = {})\n {\n if (!options.keepCurrentId)\n {\n currentConversationId = options.id || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n messageHistory = messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n persistConversation();\n if (!options.silent) setStatus('对话已加载', 'recording');\n }\n\n function cloneContent(content)\n {\n if (typeof content === 'undefined') return '';\n return JSON.parse(JSON.stringify(content));\n }\n\n function renderConversation()\n {\n chatContainer.innerHTML = '';\n if (!messageHistory.length)\n {\n renderGreeting();\n return;\n }\n messageHistory.forEach((msg) => appendMessage(msg.role, msg.content));\n typesetElement(chatContainer);\n }\n\n function exportChat()\n {\n if (!messageHistory.length)\n {\n alert('暂无对话记录可导出');\n return;\n }\n\n const dataStr = JSON.stringify(buildConversationPayload(), null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `math_chat_export_${formatTimestamp(new Date())}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n function clearConversation()\n {\n if (!confirm('确定要清空当前对话吗?')) return;\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n saveConversationToLibrary();\n clearPendingImages();\n renderGreeting();\n setStatus('已清空');\n }\n\n function getApiConfigs()\n {\n const apiList = document.getElementById('api-list').value.trim();\n const globalModel = document.getElementById('model-name').value.trim() || defaultModel;\n const configs = [];\n\n if (apiList)\n {\n apiList.split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line && !line.startsWith('#'))\n .forEach((line) =>\n {\n const parts = line.includes('|')\n ? line.split('|').map((part) => part.trim())\n : line.split(',').map((part) => part.trim());\n const [url, key, model] = parts;\n if (url && key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: model || globalModel\n });\n }\n });\n }\n\n if (!configs.length)\n {\n const url = document.getElementById('api-url').value.trim() || defaultUrl;\n const key = document.getElementById('api-key').value.trim();\n if (key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: globalModel\n });\n }\n }\n\n return configs;\n }\n\n function normalizeChatUrl(url)\n {\n let apiUrl = (url || defaultUrl).trim();\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n return apiUrl;\n }\n\n async function fetchAndShowModels(event)\n {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const configs = getApiConfigs();\n const config = configs[apiRoundRobinIndex % Math.max(configs.length, 1)] || configs[0];\n\n if (dropdown.classList.contains('show'))\n {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!config)\n {\n alert('请先填写 API Key 或多 API 轮询列表');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try\n {\n const modelsUrl = config.url.replace(/\\/chat\\/completions$/, '/models');\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${config.key}`,\n 'Content-Type': 'application/json'\n }\n });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => String(a.id).localeCompare(String(b.id)));\n dropdown.innerHTML = '';\n\n if (!models.length)\n {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach((model) =>\n {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () =>\n {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n } catch (error)\n {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${escapeHtml(error.message)}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function ()\n {\n this.style.height = 'auto';\n this.style.height = `${this.scrollHeight}px`;\n if (this.value === '') this.style.height = '44px';\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function (event)\n {\n if (event.key === 'Enter' && !event.shiftKey)\n {\n event.preventDefault();\n sendMessage();\n }\n });\n\n const updatePreview = debounce(function ()\n {\n const text = userInput.value;\n if (!text.trim())\n {\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n return;\n }\n previewArea.classList.add('show');\n previewArea.innerHTML = sanitizeHtml(renderMarkdown(text));\n typesetElement(previewArea);\n }, 250);\n\n async function handleImageUpload(input)\n {\n const files = Array.from(input.files || []);\n if (!files.length) return;\n\n for (const file of files)\n {\n if (!file.type.startsWith('image/'))\n {\n alert(`${file.name} 不是图片文件`);\n continue;\n }\n if (file.size > maxImageBytes)\n {\n alert(`${file.name} 超过 8MB,已跳过`);\n continue;\n }\n const dataUrl = await readFileAsDataUrl(file);\n pendingImages.push({\n name: file.name,\n type: file.type,\n size: file.size,\n dataUrl\n });\n }\n input.value = '';\n renderAttachmentTray();\n }\n\n function readFileAsDataUrl(file)\n {\n return new Promise((resolve, reject) =>\n {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(reader.error || new Error('图片读取失败'));\n reader.readAsDataURL(file);\n });\n }\n\n function renderAttachmentTray()\n {\n attachmentTray.innerHTML = '';\n attachmentTray.classList.toggle('show', pendingImages.length > 0);\n pendingImages.forEach((image, index) =>\n {\n const item = document.createElement('div');\n item.className = 'attachment-thumb';\n const img = document.createElement('img');\n img.src = image.dataUrl;\n img.alt = image.name || `图片 ${index + 1}`;\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.textContent = '×';\n removeBtn.title = '移除图片';\n removeBtn.onclick = () =>\n {\n pendingImages.splice(index, 1);\n renderAttachmentTray();\n };\n item.appendChild(img);\n item.appendChild(removeBtn);\n attachmentTray.appendChild(item);\n });\n }\n\n function clearPendingImages()\n {\n pendingImages = [];\n renderAttachmentTray();\n }\n\n function buildUserContent(text, images)\n {\n if (!images.length) return text;\n const parts = [];\n if (text) parts.push({ type: 'text', text });\n images.forEach((image) =>\n {\n parts.push({\n type: 'image_url',\n image_url: { url: image.dataUrl }\n });\n });\n return parts;\n }\n\n async function sendMessage()\n {\n const text = userInput.value.trim();\n const images = pendingImages.slice();\n const apiConfigs = getApiConfigs();\n\n if (!text && !images.length) return;\n if (!apiConfigs.length)\n {\n alert('请点击顶部设置填写 API Key,或添加多 API 轮询列表');\n if (!document.getElementById('settings-panel').classList.contains('show')) toggleSettings();\n return;\n }\n\n const userContent = buildUserContent(text, images);\n messageHistory.push({ role: 'user', content: userContent });\n appendMessage('user', userContent);\n persistConversation();\n\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n clearPendingImages();\n\n const aiMessageDiv = appendMessage('assistant', '<span class=\"typing\">思考中...</span>', { rawHtml: true });\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = '';\n const errors = [];\n const startIndex = apiRoundRobinIndex % apiConfigs.length;\n\n try\n {\n for (let attempt = 0; attempt < apiConfigs.length; attempt++)\n {\n const index = (startIndex + attempt) % apiConfigs.length;\n const config = apiConfigs[index];\n try\n {\n setStatus(`API ${index + 1}/${apiConfigs.length}`);\n fullText = await requestChatCompletion(config, aiMessageDiv);\n messageHistory.push({ role: 'assistant', content: fullText });\n apiRoundRobinIndex = (index + 1) % apiConfigs.length;\n localStorage.setItem(STORAGE_KEYS.apiIndex, String(apiRoundRobinIndex));\n persistConversation();\n aiMessageDiv.innerHTML = sanitizeHtml(renderMarkdown(fullText));\n typesetElement(aiMessageDiv);\n setStatus(`已使用 API ${index + 1}`, 'recording');\n return;\n } catch (error)\n {\n errors.push(`API ${index + 1}: ${error.message}`);\n }\n }\n throw new Error(errors.join(';'));\n } catch (error)\n {\n aiMessageDiv.innerHTML = `<span style=\"color:#b91c1c\">Error: ${escapeHtml(error.message)}</span>`;\n setStatus('请求失败');\n } finally\n {\n sendBtn.disabled = false;\n }\n }\n\n async function requestChatCompletion(config, targetDiv)\n {\n const response = await fetch(config.url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.key}`\n },\n body: JSON.stringify({\n model: config.model || defaultModel,\n messages: messageHistory,\n stream: true\n })\n });\n\n if (!response.ok)\n {\n throw new Error(await readApiError(response));\n }\n\n if (!response.body)\n {\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let buffer = '';\n let fullText = '';\n\n while (true)\n {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() || '';\n\n for (const rawLine of lines)\n {\n const line = rawLine.trim();\n if (!line || !line.startsWith('data:')) continue;\n const jsonStr = line.slice(5).trim();\n if (jsonStr === '[DONE]') return fullText;\n try\n {\n const data = JSON.parse(jsonStr);\n const content = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || '';\n if (content)\n {\n fullText += content;\n targetDiv.textContent = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n }\n } catch (err)\n {\n console.warn('跳过无法解析的流式片段:', jsonStr);\n }\n }\n }\n\n return fullText;\n }\n\n async function readApiError(response)\n {\n const fallback = `HTTP ${response.status}`;\n try\n {\n const data = await response.json();\n return data.error?.message || data.message || fallback;\n } catch (err)\n {\n return fallback;\n }\n }\n\n function appendMessage(role, content, options = {})\n {\n const div = document.createElement('div');\n const cssRole = role === 'assistant' ? 'ai' : role;\n div.className = `message ${cssRole}`;\n if (options.rawHtml)\n {\n div.innerHTML = content;\n } else\n {\n div.innerHTML = renderMessageContent(role, content);\n }\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n typesetElement(div);\n return div;\n }\n\n function renderMessageContent(role, content)\n {\n if (role === 'assistant')\n {\n return sanitizeHtml(renderMarkdown(String(content || '')));\n }\n\n if (role === 'system')\n {\n return escapeHtml(stringifyContent(content)).replace(/\\n/g, '<br>');\n }\n\n const { text, images } = splitMessageContent(content);\n const textHtml = escapeHtml(text).replace(/\\n/g, '<br>');\n const imagesHtml = images.length\n ? `<div class=\"message-images\">${images.map((url, index) => `<img class=\"message-image\" src=\"${escapeAttribute(url)}\" alt=\"上传图片 ${index + 1}\">`).join('')}</div>`\n : '';\n return `${textHtml}${imagesHtml}`;\n }\n\n function splitMessageContent(content)\n {\n if (typeof content === 'string') return { text: content, images: [] };\n const result = { text: '', images: [] };\n if (!Array.isArray(content)) return { text: String(content || ''), images: [] };\n content.forEach((part) =>\n {\n if (part?.type === 'text') result.text += part.text || '';\n if (part?.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) result.images.push(url);\n }\n });\n return result;\n }\n\n function stringifyContent(content)\n {\n if (typeof content === 'string') return content;\n const { text } = splitMessageContent(content);\n return text || JSON.stringify(content);\n }\n\n function renderMarkdown(text)\n {\n if (!window.marked) return escapeHtml(text).replace(/\\n/g, '<br>');\n return marked.parse(text || '');\n }\n\n function sanitizeHtml(html)\n {\n const template = document.createElement('template');\n template.innerHTML = html;\n template.content.querySelectorAll('script, iframe, object, embed').forEach((node) => node.remove());\n template.content.querySelectorAll('*').forEach((node) =>\n {\n Array.from(node.attributes).forEach((attr) =>\n {\n const name = attr.name.toLowerCase();\n const value = attr.value.trim().toLowerCase();\n if (name.startsWith('on') || value.startsWith('javascript:'))\n {\n node.removeAttribute(attr.name);\n }\n });\n });\n return template.innerHTML;\n }\n\n function typesetElement(element)\n {\n if (!window.MathJax?.typesetPromise) return;\n renderPromise = renderPromise\n .then(() => MathJax.typesetPromise([element]))\n .then(() => { chatContainer.scrollTop = chatContainer.scrollHeight; })\n .catch((err) => console.warn('MathJax render error:', err));\n }\n\n async function syncToNutstore()\n {\n try\n {\n saveSettings();\n await putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2));\n setStatus('已同步到坚果云', 'recording');\n alert('已同步到坚果云');\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云同步失败');\n alert('同步到坚果云失败: ' + formatNutstoreError(err));\n }\n }\n\n async function syncFromNutstore()\n {\n try\n {\n saveSettings();\n const text = await getNutstoreFile();\n const payload = JSON.parse(text);\n const messages = parseConversationPayload(payload);\n if (!messages.length) throw new Error('远端文件没有可加载的消息');\n restoreConversation(messages);\n setStatus('已从坚果云拉取', 'recording');\n alert(`已从坚果云拉取 ${messages.length} 条消息`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云拉取失败');\n alert('从坚果云拉取失败: ' + formatNutstoreError(err));\n }\n }\n\n async function testNutstoreConnection()\n {\n try\n {\n saveSettings();\n const result = await checkNutstoreConnection();\n setStatus('坚果云连接正常', 'recording');\n alert(`坚果云连接正常:${result}`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云连接失败');\n alert('坚果云连接失败: ' + formatNutstoreError(err));\n }\n }\n\n function scheduleAutoSync()\n {\n if (!document.getElementById('nutstore-auto').checked) return;\n const config = getNutstoreConfig();\n if (!config.url || !config.user || !config.pass || !config.path) return;\n clearTimeout(autoSyncTimer);\n autoSyncTimer = setTimeout(() =>\n {\n putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2))\n .then(() => setStatus('已自动同步', 'recording'))\n .catch((err) => console.warn('自动同步失败:', err));\n }, 1200);\n }\n\n function getNutstoreConfig()\n {\n return {\n url: normalizeNutstoreBaseUrl(document.getElementById('nutstore-url').value.trim() || defaultNutstoreUrl),\n user: document.getElementById('nutstore-user').value.trim(),\n pass: document.getElementById('nutstore-pass').value.trim(),\n path: document.getElementById('nutstore-path').value.trim() || defaultNutstorePath\n };\n }\n\n function normalizeNutstoreBaseUrl(url)\n {\n const trimmed = String(url || '').trim();\n if (!trimmed) return defaultNutstoreUrl;\n if (/^https?:\\/\\//i.test(trimmed)) return trimmed;\n return `http://${trimmed}`;\n }\n\n function getProxyNutstoreUrl()\n {\n const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';\n return `${protocol}//${proxyNutstoreHost}${proxyNutstorePath}`;\n }\n\n function isDirectNutstoreUrl(url)\n {\n return normalizeNutstoreBaseUrl(url).replace(/\\/+$/, '') === directNutstoreUrl.replace(/\\/+$/, '');\n }\n\n function isLegacyProxyNutstoreUrl(url)\n {\n return normalizeNutstoreBaseUrl(url).replace(/\\/+$/, '') === legacyProxyNutstoreUrl.replace(/\\/+$/, '');\n }\n\n function buildNutstoreUrl(config, pathOverride)\n {\n const base = config.url.replace(/\\/+$/, '');\n const path = (pathOverride || config.path).replace(/^\\/+/, '');\n const encodedPath = path.split('/').map(encodeURIComponent).join('/');\n return `${base}/${encodedPath}`;\n }\n\n function getNutstoreHeaders(config)\n {\n if (!config.user || !config.pass)\n {\n throw new Error('请填写坚果云账号和应用密码');\n }\n return {\n Authorization: `Basic ${btoa(unescape(encodeURIComponent(`${config.user}:${config.pass}`)))}`\n };\n }\n\n async function ensureNutstoreFolders(config, headers)\n {\n const parts = config.path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n let current = '';\n for (const part of parts)\n {\n current += `/${part}`;\n const response = await nutstoreFetch(buildNutstoreUrl(config, current), {\n method: 'MKCOL',\n headers\n }, '创建远端目录');\n if (![200, 201, 204, 405, 409].includes(response.status))\n {\n throw await createNutstoreHttpError(response, '创建远端目录失败');\n }\n }\n }\n\n async function putNutstoreFile(content)\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n await ensureNutstoreFolders(config, headers);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'PUT',\n headers: {\n ...headers,\n 'Content-Type': 'application/json; charset=utf-8'\n },\n body: content\n }, '上传坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '上传坚果云文件失败');\n }\n }\n\n async function getNutstoreFile()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'GET',\n headers\n }, '读取坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '读取坚果云文件失败');\n }\n return response.text();\n }\n\n async function checkNutstoreConnection()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const folderPath = getNutstoreParentPath(config.path);\n const response = await nutstoreFetch(buildNutstoreUrl(config, folderPath), {\n method: 'PROPFIND',\n headers: {\n ...headers,\n Depth: '0',\n 'Content-Type': 'application/xml; charset=utf-8'\n },\n body: '<?xml version=\"1.0\" encoding=\"utf-8\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:resourcetype/></d:prop></d:propfind>'\n }, '检测坚果云连接');\n\n if ([200, 207, 301, 302].includes(response.status))\n {\n return `HTTP ${response.status}`;\n }\n if (response.status === 404)\n {\n return '账号可连接,同步目录尚不存在,推送时会尝试创建';\n }\n throw await createNutstoreHttpError(response, '检测坚果云连接失败');\n }\n\n function getNutstoreParentPath(path)\n {\n const parts = path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n return parts.length ? `/${parts.join('/')}` : '/';\n }\n\n async function nutstoreFetch(url, options, action)\n {\n if (window.location.protocol === 'https:' && /^http:\\/\\//i.test(url))\n {\n const error = new Error(`${action}失败:当前页面是 HTTPS,但中转服务器地址是 HTTP。`);\n error.isMixedContent = true;\n throw error;\n }\n\n try\n {\n return await fetch(url, options);\n } catch (err)\n {\n const error = new Error(`${action}失败:浏览器无法访问坚果云 WebDAV。`);\n error.cause = err;\n error.isNetworkBlocked = true;\n throw error;\n }\n }\n\n async function createNutstoreHttpError(response, message)\n {\n let detail = '';\n try\n {\n detail = (await response.text()).replace(/\\s+/g, ' ').trim().slice(0, 160);\n } catch (err)\n {\n detail = '';\n }\n\n const error = new Error(`${message}: HTTP ${response.status}${detail ? ` ${detail}` : ''}`);\n error.status = response.status;\n return error;\n }\n\n function formatNutstoreError(err)\n {\n if (err?.isMixedContent)\n {\n return `${err.message} 浏览器会拦截 HTTPS 页面访问 HTTP 接口。请给 zihancai.top 配置 HTTPS,或把页面也部署在同一个 HTTP 源上测试。`;\n }\n\n if (err?.isNetworkBlocked || err?.cause instanceof TypeError || /failed to fetch/i.test(err?.message || ''))\n {\n return '浏览器无法连接 zihancai.top 的 WebDAV 中转服务。请确认 nginx 反代正在运行、域名解析正确、已允许 CORS/OPTIONS、地址为 /dav/,并且防火墙放行了对应端口。';\n }\n\n if (err?.status === 401 || err?.status === 403)\n {\n return `${err.message}。请检查坚果云账号和“第三方应用密码”,不是登录密码。`;\n }\n\n if (err?.status === 404)\n {\n return `${err.message}。远端文件不存在,可以先使用“同步到坚果云”创建。`;\n }\n\n return err?.message || String(err);\n }\n\n function setupMcpBridge()\n {\n window.MathChatMCP = {\n version: '1.0.0',\n tools: [\n 'chat.export',\n 'chat.import',\n 'chat.list',\n 'chat.load',\n 'chat.delete',\n 'chat.new',\n 'chat.clear',\n 'nutstore.test',\n 'nutstore.pull',\n 'nutstore.push',\n 'config.get',\n 'config.setNutstore',\n 'config.useNutstoreProxy'\n ],\n async call(method, params = {})\n {\n switch (method)\n {\n case 'chat.export':\n return buildConversationPayload();\n case 'chat.import':\n restoreConversation(parseConversationPayload(params.payload || params.messages || []));\n return { ok: true, count: messageHistory.length };\n case 'chat.list':\n return getSavedConversations().map((item) => ({\n id: item.id,\n title: item.title,\n createdAt: item.createdAt,\n updatedAt: item.updatedAt,\n count: item.messages.length,\n active: item.id === currentConversationId\n }));\n case 'chat.load':\n loadSavedConversation(params.id);\n return { ok: true, count: messageHistory.length };\n case 'chat.delete':\n deleteSavedConversation(params.id);\n return { ok: true };\n case 'chat.new':\n newConversation();\n return { ok: true, id: currentConversationId };\n case 'chat.clear':\n messageHistory = [];\n persistConversation();\n renderGreeting();\n return { ok: true };\n case 'nutstore.test':\n return { ok: true, result: await checkNutstoreConnection() };\n case 'nutstore.pull':\n await syncFromNutstore();\n return { ok: true, count: messageHistory.length };\n case 'nutstore.push':\n await syncToNutstore();\n return { ok: true, count: messageHistory.length };\n case 'config.get':\n return {\n apiCount: getApiConfigs().length,\n nutstore: { ...getNutstoreConfig(), pass: getNutstoreConfig().pass ? '***' : '' }\n };\n case 'config.setNutstore':\n if (params.url) document.getElementById('nutstore-url').value = params.url;\n if (params.user) document.getElementById('nutstore-user').value = params.user;\n if (params.pass) document.getElementById('nutstore-pass').value = params.pass;\n if (params.path) document.getElementById('nutstore-path').value = params.path;\n saveSettings();\n return { ok: true };\n case 'config.useNutstoreProxy':\n useNutstoreProxy();\n return { ok: true, url: proxyNutstoreUrl };\n default:\n throw new Error(`Unknown MCP method: ${method}`);\n }\n },\n async handle(request)\n {\n const id = request?.id ?? null;\n try\n {\n const result = await this.call(request.method, request.params || {});\n return { jsonrpc: '2.0', id, result };\n } catch (err)\n {\n return { jsonrpc: '2.0', id, error: { code: -32000, message: err.message } };\n }\n }\n };\n }\n\n function debounce(func, wait)\n {\n let timeout;\n return function (...args)\n {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n function escapeHtml(text)\n {\n return String(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '`');\n }\n\n function formatShortTime(value)\n {\n const date = value ? new Date(value) : new Date();\n if (Number.isNaN(date.getTime())) return '未知时间';\n const now = new Date();\n const sameYear = date.getFullYear() === now.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hour = String(date.getHours()).padStart(2, '0');\n const minute = String(date.getMinutes()).padStart(2, '0');\n return sameYear\n ? `${month}-${day} ${hour}:${minute}`\n : `${date.getFullYear()}-${month}-${day}`;\n }\n\n function formatTimestamp(date)\n {\n return date.toISOString().slice(0, 19).replace(/[-T:]/g, '');\n }\n </script>\n</body>\n\n</html>\n"}, "c33f4661c3968ff33eff5628620fd24ebafd09af": {"t": 1785765384.4989014, "n": 83557, "h": "d6a3bb2be8d627b5", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f6fb;\n --panel-color: #2c3e50;\n --panel-muted: #34495e;\n --primary-color: #3d7bd9;\n --primary-dark: #2764ba;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n --text-muted: #64748b;\n }\n\n * {\n box-sizing: border-box;\n touch-action: manipulation;\n }\n\n body,\n html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .settings-header {\n background: var(--panel-color);\n color: white;\n padding: 10px 15px;\n font-size: 0.92rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .settings-title {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .settings-panel {\n background: var(--panel-muted);\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n max-height: 54vh;\n overflow-y: auto;\n }\n\n .settings-panel.show {\n display: flex;\n }\n\n .settings-row {\n display: flex;\n gap: 10px;\n width: 100%;\n flex-wrap: wrap;\n }\n\n .settings-group {\n display: flex;\n flex-direction: column;\n gap: 6px;\n flex: 1;\n min-width: 220px;\n }\n\n .settings-group label {\n color: #dbeafe;\n font-size: 0.78rem;\n font-weight: 700;\n }\n\n .settings-panel input,\n .settings-panel textarea {\n background: var(--panel-color);\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n font: inherit;\n font-size: 0.9rem;\n outline: none;\n }\n\n .settings-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n min-width: 16px;\n padding: 0;\n accent-color: var(--primary-color);\n }\n\n .checkbox-label {\n color: #e2e8f0;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 0.84rem;\n font-weight: 700;\n min-height: 38px;\n }\n\n .settings-panel textarea {\n min-height: 76px;\n resize: vertical;\n line-height: 1.45;\n }\n\n .settings-panel input::placeholder,\n .settings-panel textarea::placeholder {\n color: #b7c9d8;\n }\n\n .setting-note {\n color: #cbd5e1;\n font-size: 0.75rem;\n line-height: 1.35;\n }\n\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 220px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: 0;\n width: 100%;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n min-width: 42px;\n }\n\n .model-list-btn:hover {\n background: #3498db;\n }\n\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: var(--panel-color);\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.28);\n margin-top: 2px;\n }\n\n .model-dropdown.show {\n display: block;\n }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:hover {\n background: #3c5870;\n color: #dff2ff;\n }\n\n .model-option.loading {\n text-align: center;\n color: #bdc3c7;\n cursor: default;\n }\n\n .model-option.error {\n color: #fecaca;\n font-size: 0.8rem;\n cursor: default;\n }\n\n .action-btn {\n padding: 8px 13px;\n min-height: 38px;\n color: white;\n border: 0;\n border-radius: 4px;\n cursor: pointer;\n font-weight: 700;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n justify-content: center;\n }\n\n .save-btn {\n background: #238457;\n }\n\n .save-btn:hover {\n background: #2aa96d;\n }\n\n .export-btn {\n background: #ce6f18;\n }\n\n .export-btn:hover {\n background: #b85f0f;\n }\n\n .autosave-btn {\n background: #7f3a98;\n }\n\n .autosave-btn:hover {\n background: #9145ad;\n }\n\n .autosave-btn.active {\n background: #238457;\n }\n\n .import-btn {\n background: #118270;\n }\n\n .import-btn:hover {\n background: #159c88;\n }\n\n .sync-btn {\n background: #3366a8;\n }\n\n .sync-btn:hover {\n background: #3e78c4;\n }\n\n .danger-btn {\n background: #a33a3a;\n }\n\n .danger-btn:hover {\n background: #bf4646;\n }\n\n .status-indicator {\n font-size: 0.8rem;\n color: #bdc3c7;\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n }\n\n .status-indicator.recording {\n color: #86efac;\n }\n\n .app-main {\n flex: 1;\n min-height: 0;\n display: flex;\n overflow: hidden;\n }\n\n .conversation-sidebar {\n width: 280px;\n min-width: 240px;\n max-width: 34vw;\n background: #ffffff;\n border-right: 1px solid var(--border-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .conversation-sidebar-header {\n padding: 12px;\n border-bottom: 1px solid #e5e7eb;\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n .conversation-sidebar-title {\n color: #1f2937;\n font-size: 0.95rem;\n font-weight: 800;\n }\n\n .conversation-actions {\n display: flex;\n gap: 8px;\n }\n\n .side-btn {\n flex: 1;\n border: 1px solid #cbd5e1;\n background: #f8fafc;\n color: #334155;\n border-radius: 6px;\n min-height: 34px;\n cursor: pointer;\n font-weight: 700;\n }\n\n .side-btn:hover {\n background: #eef2f7;\n }\n\n .conversation-list {\n flex: 1;\n overflow-y: auto;\n padding: 8px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .conversation-item {\n border: 1px solid #e2e8f0;\n border-radius: 8px;\n background: #ffffff;\n padding: 9px;\n display: grid;\n grid-template-columns: 1fr auto;\n gap: 6px;\n cursor: pointer;\n }\n\n .conversation-item:hover {\n background: #f8fafc;\n }\n\n .conversation-item.active {\n border-color: var(--primary-color);\n background: #eef6ff;\n }\n\n .conversation-item-title {\n color: #111827;\n font-size: 0.9rem;\n font-weight: 750;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .conversation-item-meta {\n color: var(--text-muted);\n font-size: 0.74rem;\n grid-column: 1 / -1;\n }\n\n .conversation-delete {\n width: 24px;\n height: 24px;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: #64748b;\n cursor: pointer;\n font-size: 1rem;\n line-height: 1;\n }\n\n .conversation-delete:hover {\n background: #fee2e2;\n color: #b91c1c;\n }\n\n .conversation-empty {\n color: var(--text-muted);\n font-size: 0.84rem;\n line-height: 1.5;\n padding: 12px;\n }\n\n #chat-container {\n flex: 1;\n min-width: 0;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: min(92%, 900px);\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n overflow-wrap: anywhere;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);\n }\n\n .message.user {\n align-self: flex-end;\n background: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background: white;\n color: #263238;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n .message.system {\n align-self: center;\n background: #fff7ed;\n color: #9a3412;\n border: 1px solid #fed7aa;\n max-width: min(92%, 720px);\n }\n\n .message.ai pre {\n background: #2d2d2d;\n color: #f8f8f2;\n padding: 10px;\n border-radius: 5px;\n overflow-x: auto;\n }\n\n .message.ai p {\n margin: 0 0 8px 0;\n }\n\n .message.ai p:last-child {\n margin: 0;\n }\n\n .message-images {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n gap: 8px;\n margin-top: 10px;\n }\n\n .message-image {\n width: 100%;\n max-height: 220px;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n background: rgba(255, 255, 255, 0.1);\n }\n\n .message.ai .message-image {\n border-color: var(--border-color);\n }\n\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative;\n }\n\n .math-preview-area {\n background: #fffdf5;\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none;\n }\n\n .math-preview-area.show {\n display: block;\n }\n\n .math-preview-area p {\n margin: 0;\n }\n\n .attachment-tray {\n display: none;\n gap: 8px;\n padding: 8px 10px;\n border-bottom: 1px solid #e5e7eb;\n overflow-x: auto;\n background: #f8fafc;\n }\n\n .attachment-tray.show {\n display: flex;\n }\n\n .attachment-thumb {\n position: relative;\n width: 70px;\n height: 70px;\n flex: 0 0 auto;\n }\n\n .attachment-thumb img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid #cbd5e1;\n display: block;\n }\n\n .attachment-thumb button {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 22px;\n height: 22px;\n border: 0;\n border-radius: 50%;\n background: #1f2937;\n color: white;\n cursor: pointer;\n line-height: 1;\n }\n\n .math-toolbar-container {\n background: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar {\n display: none;\n }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background: #e2e8f0;\n transform: translateY(1px);\n }\n\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n #attach-btn {\n width: 44px;\n height: 44px;\n border: 1px solid var(--border-color);\n background: #f8fafc;\n color: #334155;\n border-radius: 8px;\n cursor: pointer;\n flex-shrink: 0;\n font-weight: 800;\n font-size: 1.1rem;\n }\n\n #attach-btn:hover {\n background: #eef2f7;\n }\n\n textarea#user-input {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n color: #111827;\n }\n\n textarea#user-input:focus {\n border-color: var(--primary-color);\n }\n\n button#send-btn {\n background: var(--primary-color);\n color: white;\n border: 0;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: 700;\n cursor: pointer;\n flex-shrink: 0;\n }\n\n button#send-btn:hover {\n background: var(--primary-dark);\n }\n\n button#send-btn:disabled {\n background: #94a3b8;\n cursor: not-allowed;\n }\n\n @media (max-width: 640px) {\n .app-main {\n flex-direction: column;\n }\n\n .conversation-sidebar {\n width: 100%;\n max-width: none;\n min-width: 0;\n max-height: 34vh;\n border-right: 0;\n border-bottom: 1px solid var(--border-color);\n }\n\n .conversation-sidebar-header {\n padding: 10px;\n }\n\n .settings-group {\n min-width: 100%;\n }\n\n .settings-row {\n gap: 8px;\n }\n\n .message {\n max-width: 96%;\n }\n\n button#send-btn {\n padding: 0 14px;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"settings-header\" onclick=\"toggleSettings()\">\n <div class=\"settings-title\">\n <span>设置与同步</span>\n <span id=\"autosave-status\" class=\"status-indicator\"></span>\n </div>\n <span id=\"toggle-icon\">▼</span>\n </div>\n\n <div class=\"settings-panel\" id=\"settings-panel\">\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-url\">默认 API 地址</label>\n <input type=\"text\" id=\"api-url\" placeholder=\"https://api.openai.com/v1\">\n </div>\n <div class=\"settings-group\">\n <label for=\"api-key\">默认 API Key</label>\n <input type=\"password\" id=\"api-key\" placeholder=\"sk-...\">\n </div>\n <div class=\"settings-group\">\n <label for=\"model-name\">默认模型</label>\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"gpt-4o, deepseek-chat\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-list\">多 API 轮询列表</label>\n <textarea id=\"api-list\" placeholder=\"每行一个:API地址 | API Key | 模型(可选) https://api.openai.com/v1 | sk-xxx | gpt-4o https://api.deepseek.com/v1 | sk-yyy | deepseek-chat\"></textarea>\n <div class=\"setting-note\">发送时按轮询顺序选择接口;当前接口失败会自动尝试下一行。留空则使用上面的默认 API。</div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"nutstore-url\">坚果云 WebDAV 中转地址</label>\n <input type=\"text\" id=\"nutstore-url\" placeholder=\"https://zihancai.top/dav/\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-user\">坚果云账号</label>\n <input type=\"text\" id=\"nutstore-user\" placeholder=\"你的坚果云登录邮箱\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-pass\">坚果云应用密码</label>\n <input type=\"password\" id=\"nutstore-pass\" placeholder=\"坚果云第三方应用密码\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-path\">同步文件路径</label>\n <input type=\"text\" id=\"nutstore-path\" placeholder=\"/MathAIChat/chat.json\">\n </div>\n </div>\n\n <div class=\"setting-note\">\n 当前默认通过 zihancai.top 的 nginx WebDAV 反代与坚果云同步;坚果云账号和应用密码仍填写原来的坚果云凭据。\n </div>\n\n <div class=\"settings-row\">\n <label class=\"checkbox-label\">\n <input type=\"checkbox\" id=\"nutstore-auto\">\n 启用坚果云自动同步\n </label>\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn sync-btn\" onclick=\"useNutstoreProxy()\">使用中转服务器</button>\n <button id=\"btn-autosave\" class=\"action-btn autosave-btn\" onclick=\"setupAutoSave()\">启用本地自动保存</button>\n <input type=\"file\" id=\"import-file\" accept=\".json,application/json\" style=\"display:none\" onchange=\"handleFileImport(this)\">\n <button class=\"action-btn import-btn\" onclick=\"document.getElementById('import-file').click()\">加载对话</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n <button class=\"action-btn sync-btn\" onclick=\"testNutstoreConnection()\">检测坚果云连接</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncFromNutstore()\">从坚果云拉取</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncToNutstore()\">同步到坚果云</button>\n <button class=\"action-btn danger-btn\" onclick=\"clearConversation()\">清空当前对话</button>\n </div>\n </div>\n\n <div class=\"app-main\">\n <aside class=\"conversation-sidebar\">\n <div class=\"conversation-sidebar-header\">\n <div class=\"conversation-sidebar-title\">已保存对话</div>\n <div class=\"conversation-actions\">\n <button class=\"side-btn\" onclick=\"newConversation()\">新建</button>\n <button class=\"side-btn\" onclick=\"saveCurrentConversation()\">保存当前</button>\n </div>\n </div>\n <div id=\"conversation-list\" class=\"conversation-list\"></div>\n </aside>\n\n <div id=\"chat-container\"></div>\n </div>\n\n <div class=\"bottom-section\">\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n <div id=\"attachment-tray\" class=\"attachment-tray\"></div>\n\n <div class=\"math-toolbar-container\">\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <input type=\"file\" id=\"image-input\" accept=\"image/*\" multiple style=\"display:none\" onchange=\"handleImageUpload(this)\">\n <button id=\"attach-btn\" onclick=\"document.getElementById('image-input').click()\" title=\"上传图片\">+</button>\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n </div>\n\n <script>\n const STORAGE_KEYS = {\n apiUrl: 'api_url',\n apiKey: 'api_key',\n modelName: 'model_name',\n apiList: 'api_list',\n conversation: 'math_chat_conversation_v2',\n conversations: 'math_chat_saved_conversations_v1',\n activeConversationId: 'math_chat_active_conversation_id',\n apiIndex: 'math_chat_api_index',\n nutstoreUrl: 'nutstore_url',\n nutstoreUser: 'nutstore_user',\n nutstorePass: 'nutstore_pass',\n nutstorePath: 'nutstore_path',\n nutstoreAuto: 'nutstore_auto_sync'\n };\n\n const defaultUrl = 'https://api.openai.com/v1';\n const defaultModel = 'gpt-4o';\n const directNutstoreUrl = 'https://dav.jianguoyun.com/dav/';\n const legacyProxyNutstoreUrl = 'http://47.237.92.156/dav/';\n const proxyNutstoreHost = 'zihancai.top';\n const proxyNutstorePath = '/dav/';\n const proxyNutstoreUrl = getProxyNutstoreUrl();\n const defaultNutstoreUrl = proxyNutstoreUrl;\n const defaultNutstorePath = '/MathAIChat/chat.json';\n const maxImageBytes = 8 * 1024 * 1024;\n\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1 },\n { label: 'x', insert: 'x', offset: 0 },\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n const chatContainer = document.getElementById('chat-container');\n const conversationList = document.getElementById('conversation-list');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n const attachmentTray = document.getElementById('attachment-tray');\n\n let messageHistory = [];\n let currentConversationId = localStorage.getItem(STORAGE_KEYS.activeConversationId) || '';\n let pendingImages = [];\n let fileHandle = null;\n let isAutoSaveActive = false;\n let renderPromise = Promise.resolve();\n let apiRoundRobinIndex = Number(localStorage.getItem(STORAGE_KEYS.apiIndex) || 0);\n let autoSyncTimer = null;\n\n window.addEventListener('load', initApp);\n\n function initApp()\n {\n document.getElementById('api-url').value = localStorage.getItem(STORAGE_KEYS.apiUrl) || '';\n document.getElementById('api-key').value = localStorage.getItem(STORAGE_KEYS.apiKey) || '';\n document.getElementById('model-name').value = localStorage.getItem(STORAGE_KEYS.modelName) || defaultModel;\n document.getElementById('api-list').value = localStorage.getItem(STORAGE_KEYS.apiList) || '';\n const savedNutstoreUrl = localStorage.getItem(STORAGE_KEYS.nutstoreUrl);\n document.getElementById('nutstore-url').value = (!savedNutstoreUrl || isDirectNutstoreUrl(savedNutstoreUrl) || isLegacyProxyNutstoreUrl(savedNutstoreUrl))\n ? defaultNutstoreUrl\n : savedNutstoreUrl;\n document.getElementById('nutstore-user').value = localStorage.getItem(STORAGE_KEYS.nutstoreUser) || '';\n document.getElementById('nutstore-pass').value = localStorage.getItem(STORAGE_KEYS.nutstorePass) || '';\n document.getElementById('nutstore-path').value = localStorage.getItem(STORAGE_KEYS.nutstorePath) || defaultNutstorePath;\n document.getElementById('nutstore-auto').checked = localStorage.getItem(STORAGE_KEYS.nutstoreAuto) === 'true';\n\n if (window.marked)\n {\n marked.setOptions({\n highlight: (code, lang) =>\n {\n if (!window.hljs) return code;\n return hljs.highlight(code, { language: hljs.getLanguage(lang) ? lang : 'plaintext' }).value;\n }\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n setupGlobalClickHandlers();\n setupMcpBridge();\n loadConversationFromCache();\n }\n\n function setupGlobalClickHandlers()\n {\n document.addEventListener('click', (event) =>\n {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (dropdown && !dropdown.contains(event.target) && event.target !== btn)\n {\n dropdown.classList.remove('show');\n }\n });\n }\n\n function renderToolbar(containerId, symbols)\n {\n const container = document.getElementById(containerId);\n if (!container) return;\n container.innerHTML = '';\n symbols.forEach((sym) =>\n {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (event) => event.preventDefault();\n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n function insertSymbol(text, offsetBack)\n {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n function toggleSettings()\n {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n panel.classList.toggle('show');\n icon.textContent = panel.classList.contains('show') ? '▲' : '▼';\n }\n\n function saveSettings()\n {\n localStorage.setItem(STORAGE_KEYS.apiUrl, document.getElementById('api-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiKey, document.getElementById('api-key').value.trim());\n localStorage.setItem(STORAGE_KEYS.modelName, document.getElementById('model-name').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiList, document.getElementById('api-list').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUrl, document.getElementById('nutstore-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUser, document.getElementById('nutstore-user').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePass, document.getElementById('nutstore-pass').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePath, document.getElementById('nutstore-path').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreAuto, document.getElementById('nutstore-auto').checked ? 'true' : 'false');\n setStatus('配置已保存');\n }\n\n function useNutstoreProxy()\n {\n document.getElementById('nutstore-url').value = proxyNutstoreUrl;\n saveSettings();\n setStatus('已切换到中转服务器', 'recording');\n }\n\n function setStatus(text, type = '')\n {\n const status = document.getElementById('autosave-status');\n status.textContent = text ? `● ${text}` : '';\n status.className = `status-indicator ${type}`;\n }\n\n async function setupAutoSave()\n {\n if (!('showDirectoryPicker' in window))\n {\n alert('您的浏览器不支持文件系统访问 API。请使用 Chrome/Edge 桌面版。');\n return;\n }\n\n try\n {\n const dirHandle = await window.showDirectoryPicker();\n const fileName = `chat_${formatTimestamp(new Date())}.json`;\n fileHandle = await dirHandle.getFileHandle(fileName, { create: true });\n isAutoSaveActive = true;\n const btn = document.getElementById('btn-autosave');\n btn.textContent = `正在保存:${fileName}`;\n btn.classList.add('active');\n setStatus('正在记录', 'recording');\n await writeToFile();\n } catch (err)\n {\n if (err.name !== 'AbortError')\n {\n console.error(err);\n alert('设置自动保存失败: ' + err.message);\n }\n }\n }\n\n async function writeToFile()\n {\n if (!fileHandle || !isAutoSaveActive) return;\n try\n {\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(buildConversationPayload(), null, 2));\n await writable.close();\n } catch (err)\n {\n console.error('自动保存写入失败:', err);\n setStatus('保存失败');\n }\n }\n\n function buildConversationPayload()\n {\n return {\n version: 2,\n app: 'Math AI Chat',\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n exportedAt: new Date().toISOString(),\n messages: messageHistory\n };\n }\n\n function persistConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n writeToFile();\n scheduleAutoSync();\n }\n\n function loadConversationFromCache()\n {\n migrateLegacyConversation();\n renderConversationList();\n const saved = getSavedConversations();\n const active = saved.find((item) => item.id === currentConversationId) || saved[0];\n if (active)\n {\n currentConversationId = active.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = active.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n renderConversationList();\n return;\n }\n\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n renderGreeting();\n renderConversationList();\n return;\n }\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (messages.length)\n {\n restoreConversation(messages, { silent: true });\n return;\n }\n } catch (err)\n {\n console.warn('本地缓存对话加载失败:', err);\n }\n\n renderGreeting();\n renderConversationList();\n }\n\n function getSavedConversations()\n {\n try\n {\n const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.conversations) || '[]');\n return Array.isArray(parsed) ? parsed.filter((item) => item && item.id && Array.isArray(item.messages)) : [];\n } catch (err)\n {\n console.warn('保存对话列表读取失败:', err);\n return [];\n }\n }\n\n function setSavedConversations(conversations)\n {\n localStorage.setItem(STORAGE_KEYS.conversations, JSON.stringify(conversations));\n }\n\n function saveConversationToLibrary()\n {\n if (!currentConversationId)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n\n const conversations = getSavedConversations();\n const now = new Date().toISOString();\n const existing = conversations.find((item) => item.id === currentConversationId);\n const record = {\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n createdAt: existing?.createdAt || now,\n updatedAt: now,\n messages: messageHistory.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }))\n };\n\n const next = [record, ...conversations.filter((item) => item.id !== currentConversationId)]\n .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))\n .slice(0, 80);\n setSavedConversations(next);\n renderConversationList();\n }\n\n function migrateLegacyConversation()\n {\n const saved = getSavedConversations();\n if (saved.length) return;\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw) return;\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (!messages.length) return;\n currentConversationId = currentConversationId || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n setSavedConversations([{\n id: currentConversationId,\n title: getConversationTitle(messages),\n createdAt: new Date().toISOString(),\n updatedAt: new Date().toISOString(),\n messages\n }]);\n } catch (err)\n {\n console.warn('旧对话迁移失败:', err);\n }\n }\n\n function renderConversationList()\n {\n if (!conversationList) return;\n const conversations = getSavedConversations();\n conversationList.innerHTML = '';\n\n if (!conversations.length)\n {\n const empty = document.createElement('div');\n empty.className = 'conversation-empty';\n empty.textContent = '还没有保存的对话。发送消息、导入文件或点击“保存当前”后会出现在这里。';\n conversationList.appendChild(empty);\n return;\n }\n\n conversations.forEach((conversation) =>\n {\n const item = document.createElement('div');\n item.className = `conversation-item ${conversation.id === currentConversationId ? 'active' : ''}`;\n item.onclick = () => loadSavedConversation(conversation.id);\n\n const title = document.createElement('div');\n title.className = 'conversation-item-title';\n title.textContent = conversation.title || '未命名对话';\n\n const deleteBtn = document.createElement('button');\n deleteBtn.type = 'button';\n deleteBtn.className = 'conversation-delete';\n deleteBtn.textContent = '×';\n deleteBtn.title = '删除对话';\n deleteBtn.onclick = (event) =>\n {\n event.stopPropagation();\n deleteSavedConversation(conversation.id);\n };\n\n const meta = document.createElement('div');\n meta.className = 'conversation-item-meta';\n meta.textContent = `${conversation.messages.length} 条消息 · ${formatShortTime(conversation.updatedAt)}`;\n\n item.appendChild(title);\n item.appendChild(deleteBtn);\n item.appendChild(meta);\n conversationList.appendChild(item);\n });\n }\n\n function loadSavedConversation(id)\n {\n const conversation = getSavedConversations().find((item) => item.id === id);\n if (!conversation) return;\n currentConversationId = conversation.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = conversation.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n clearPendingImages();\n renderConversation();\n renderConversationList();\n setStatus('已切换对话', 'recording');\n }\n\n function deleteSavedConversation(id)\n {\n if (!confirm('确定要删除这条保存的对话吗?')) return;\n const conversations = getSavedConversations().filter((item) => item.id !== id);\n setSavedConversations(conversations);\n if (id === currentConversationId)\n {\n const next = conversations[0];\n if (next)\n {\n loadSavedConversation(next.id);\n } else\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n renderGreeting();\n renderConversationList();\n }\n } else\n {\n renderConversationList();\n }\n setStatus('已删除对话');\n }\n\n function newConversation()\n {\n if (messageHistory.length) saveConversationToLibrary();\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n clearPendingImages();\n renderGreeting();\n renderConversationList();\n setStatus('新对话');\n userInput.focus();\n }\n\n function saveCurrentConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n setStatus('当前对话已保存', 'recording');\n }\n\n function createConversationId()\n {\n return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n }\n\n function getConversationTitle(messages)\n {\n const firstUser = messages.find((msg) => msg.role === 'user');\n const text = stringifyContent(firstUser?.content || '').replace(/\\s+/g, ' ').trim();\n if (text) return text.slice(0, 32);\n return `新对话 ${formatShortTime(new Date().toISOString())}`;\n }\n\n function renderGreeting()\n {\n chatContainer.innerHTML = '';\n const div = document.createElement('div');\n div.className = 'message ai';\n div.innerHTML = '你好!我是你的数学助手。<br>你可以输入数学问题、上传图片,也可以从设置中导入或同步对话。';\n chatContainer.appendChild(div);\n }\n\n function handleFileImport(input)\n {\n const file = input.files[0];\n if (!file) return;\n\n const reader = new FileReader();\n reader.onload = (event) =>\n {\n try\n {\n const payload = JSON.parse(event.target.result);\n const loadedHistory = parseConversationPayload(payload);\n if (!loadedHistory.length)\n {\n throw new Error('文件中没有可加载的对话消息');\n }\n restoreConversation(loadedHistory);\n alert(`成功加载 ${loadedHistory.length} 条消息`);\n } catch (err)\n {\n alert('加载失败: ' + err.message);\n } finally\n {\n input.value = '';\n }\n };\n reader.onerror = () =>\n {\n alert('读取文件失败');\n input.value = '';\n };\n reader.readAsText(file, 'utf-8');\n }\n\n function parseConversationPayload(payload)\n {\n const source = Array.isArray(payload)\n ? payload\n : (payload?.messages || payload?.messageHistory || payload?.history || []);\n\n if (!Array.isArray(source))\n {\n throw new Error('文件格式错误:未找到 messages 数组');\n }\n\n return source\n .map(normalizeMessage)\n .filter(Boolean);\n }\n\n function normalizeMessage(msg)\n {\n if (!msg || typeof msg !== 'object') return null;\n const roleMap = { ai: 'assistant', bot: 'assistant', assistant: 'assistant', user: 'user', system: 'system' };\n const role = roleMap[msg.role] || 'user';\n const normalized = normalizeContent(msg.content ?? msg.text ?? '');\n if (typeof normalized === 'string' && normalized.length === 0) return null;\n if (Array.isArray(normalized) && normalized.length === 0) return null;\n return { role, content: normalized };\n }\n\n function normalizeContent(content)\n {\n if (typeof content === 'string') return content;\n if (!Array.isArray(content))\n {\n return typeof content === 'undefined' || content === null ? '' : String(content);\n }\n\n const parts = [];\n content.forEach((part) =>\n {\n if (!part) return;\n if (typeof part === 'string')\n {\n parts.push({ type: 'text', text: part });\n return;\n }\n if (part.type === 'text')\n {\n parts.push({ type: 'text', text: String(part.text || '') });\n return;\n }\n if (part.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n return;\n }\n if (part.type === 'image' || part.dataUrl)\n {\n const url = part.dataUrl || part.url || part.src;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n }\n });\n return parts;\n }\n\n function restoreConversation(messages, options = {})\n {\n if (!options.keepCurrentId)\n {\n currentConversationId = options.id || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n messageHistory = messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n persistConversation();\n if (!options.silent) setStatus('对话已加载', 'recording');\n }\n\n function cloneContent(content)\n {\n if (typeof content === 'undefined') return '';\n return JSON.parse(JSON.stringify(content));\n }\n\n function renderConversation()\n {\n chatContainer.innerHTML = '';\n if (!messageHistory.length)\n {\n renderGreeting();\n return;\n }\n messageHistory.forEach((msg) => appendMessage(msg.role, msg.content));\n typesetElement(chatContainer);\n }\n\n function exportChat()\n {\n if (!messageHistory.length)\n {\n alert('暂无对话记录可导出');\n return;\n }\n\n const dataStr = JSON.stringify(buildConversationPayload(), null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `math_chat_export_${formatTimestamp(new Date())}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n function clearConversation()\n {\n if (!confirm('确定要清空当前对话吗?')) return;\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n saveConversationToLibrary();\n clearPendingImages();\n renderGreeting();\n setStatus('已清空');\n }\n\n function getApiConfigs()\n {\n const apiList = document.getElementById('api-list').value.trim();\n const globalModel = document.getElementById('model-name').value.trim() || defaultModel;\n const configs = [];\n\n if (apiList)\n {\n apiList.split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line && !line.startsWith('#'))\n .forEach((line) =>\n {\n const parts = line.includes('|')\n ? line.split('|').map((part) => part.trim())\n : line.split(',').map((part) => part.trim());\n const [url, key, model] = parts;\n if (url && key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: model || globalModel\n });\n }\n });\n }\n\n if (!configs.length)\n {\n const url = document.getElementById('api-url').value.trim() || defaultUrl;\n const key = document.getElementById('api-key').value.trim();\n if (key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: globalModel\n });\n }\n }\n\n return configs;\n }\n\n function normalizeChatUrl(url)\n {\n let apiUrl = (url || defaultUrl).trim();\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n return apiUrl;\n }\n\n async function fetchAndShowModels(event)\n {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const configs = getApiConfigs();\n const config = configs[apiRoundRobinIndex % Math.max(configs.length, 1)] || configs[0];\n\n if (dropdown.classList.contains('show'))\n {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!config)\n {\n alert('请先填写 API Key 或多 API 轮询列表');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try\n {\n const modelsUrl = config.url.replace(/\\/chat\\/completions$/, '/models');\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${config.key}`,\n 'Content-Type': 'application/json'\n }\n });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => String(a.id).localeCompare(String(b.id)));\n dropdown.innerHTML = '';\n\n if (!models.length)\n {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach((model) =>\n {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () =>\n {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n } catch (error)\n {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${escapeHtml(error.message)}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function ()\n {\n this.style.height = 'auto';\n this.style.height = `${this.scrollHeight}px`;\n if (this.value === '') this.style.height = '44px';\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function (event)\n {\n if (event.key === 'Enter' && !event.shiftKey)\n {\n event.preventDefault();\n sendMessage();\n }\n });\n\n const updatePreview = debounce(function ()\n {\n const text = userInput.value;\n if (!text.trim())\n {\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n return;\n }\n previewArea.classList.add('show');\n previewArea.innerHTML = sanitizeHtml(renderMarkdown(text));\n typesetElement(previewArea);\n }, 250);\n\n async function handleImageUpload(input)\n {\n const files = Array.from(input.files || []);\n if (!files.length) return;\n\n for (const file of files)\n {\n if (!file.type.startsWith('image/'))\n {\n alert(`${file.name} 不是图片文件`);\n continue;\n }\n if (file.size > maxImageBytes)\n {\n alert(`${file.name} 超过 8MB,已跳过`);\n continue;\n }\n const dataUrl = await readFileAsDataUrl(file);\n pendingImages.push({\n name: file.name,\n type: file.type,\n size: file.size,\n dataUrl\n });\n }\n input.value = '';\n renderAttachmentTray();\n }\n\n function readFileAsDataUrl(file)\n {\n return new Promise((resolve, reject) =>\n {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(reader.error || new Error('图片读取失败'));\n reader.readAsDataURL(file);\n });\n }\n\n function renderAttachmentTray()\n {\n attachmentTray.innerHTML = '';\n attachmentTray.classList.toggle('show', pendingImages.length > 0);\n pendingImages.forEach((image, index) =>\n {\n const item = document.createElement('div');\n item.className = 'attachment-thumb';\n const img = document.createElement('img');\n img.src = image.dataUrl;\n img.alt = image.name || `图片 ${index + 1}`;\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.textContent = '×';\n removeBtn.title = '移除图片';\n removeBtn.onclick = () =>\n {\n pendingImages.splice(index, 1);\n renderAttachmentTray();\n };\n item.appendChild(img);\n item.appendChild(removeBtn);\n attachmentTray.appendChild(item);\n });\n }\n\n function clearPendingImages()\n {\n pendingImages = [];\n renderAttachmentTray();\n }\n\n function buildUserContent(text, images)\n {\n if (!images.length) return text;\n const parts = [];\n if (text) parts.push({ type: 'text', text });\n images.forEach((image) =>\n {\n parts.push({\n type: 'image_url',\n image_url: { url: image.dataUrl }\n });\n });\n return parts;\n }\n\n async function sendMessage()\n {\n const text = userInput.value.trim();\n const images = pendingImages.slice();\n const apiConfigs = getApiConfigs();\n\n if (!text && !images.length) return;\n if (!apiConfigs.length)\n {\n alert('请点击顶部设置填写 API Key,或添加多 API 轮询列表');\n if (!document.getElementById('settings-panel').classList.contains('show')) toggleSettings();\n return;\n }\n\n const userContent = buildUserContent(text, images);\n messageHistory.push({ role: 'user', content: userContent });\n appendMessage('user', userContent);\n persistConversation();\n\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n clearPendingImages();\n\n const aiMessageDiv = appendMessage('assistant', '<span class=\"typing\">思考中...</span>', { rawHtml: true });\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = '';\n const errors = [];\n const startIndex = apiRoundRobinIndex % apiConfigs.length;\n\n try\n {\n for (let attempt = 0; attempt < apiConfigs.length; attempt++)\n {\n const index = (startIndex + attempt) % apiConfigs.length;\n const config = apiConfigs[index];\n try\n {\n setStatus(`API ${index + 1}/${apiConfigs.length}`);\n fullText = await requestChatCompletion(config, aiMessageDiv);\n messageHistory.push({ role: 'assistant', content: fullText });\n apiRoundRobinIndex = (index + 1) % apiConfigs.length;\n localStorage.setItem(STORAGE_KEYS.apiIndex, String(apiRoundRobinIndex));\n persistConversation();\n aiMessageDiv.innerHTML = sanitizeHtml(renderMarkdown(fullText));\n typesetElement(aiMessageDiv);\n setStatus(`已使用 API ${index + 1}`, 'recording');\n return;\n } catch (error)\n {\n errors.push(`API ${index + 1}: ${error.message}`);\n }\n }\n throw new Error(errors.join(';'));\n } catch (error)\n {\n aiMessageDiv.innerHTML = `<span style=\"color:#b91c1c\">Error: ${escapeHtml(error.message)}</span>`;\n setStatus('请求失败');\n } finally\n {\n sendBtn.disabled = false;\n }\n }\n\n async function requestChatCompletion(config, targetDiv)\n {\n const response = await fetch(config.url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.key}`\n },\n body: JSON.stringify({\n model: config.model || defaultModel,\n messages: messageHistory,\n stream: true\n })\n });\n\n if (!response.ok)\n {\n throw new Error(await readApiError(response));\n }\n\n if (!response.body)\n {\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let buffer = '';\n let fullText = '';\n\n while (true)\n {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() || '';\n\n for (const rawLine of lines)\n {\n const line = rawLine.trim();\n if (!line || !line.startsWith('data:')) continue;\n const jsonStr = line.slice(5).trim();\n if (jsonStr === '[DONE]') return fullText;\n try\n {\n const data = JSON.parse(jsonStr);\n const content = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || '';\n if (content)\n {\n fullText += content;\n targetDiv.textContent = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n }\n } catch (err)\n {\n console.warn('跳过无法解析的流式片段:', jsonStr);\n }\n }\n }\n\n return fullText;\n }\n\n async function readApiError(response)\n {\n const fallback = `HTTP ${response.status}`;\n try\n {\n const data = await response.json();\n return data.error?.message || data.message || fallback;\n } catch (err)\n {\n return fallback;\n }\n }\n\n function appendMessage(role, content, options = {})\n {\n const div = document.createElement('div');\n const cssRole = role === 'assistant' ? 'ai' : role;\n div.className = `message ${cssRole}`;\n if (options.rawHtml)\n {\n div.innerHTML = content;\n } else\n {\n div.innerHTML = renderMessageContent(role, content);\n }\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n typesetElement(div);\n return div;\n }\n\n function renderMessageContent(role, content)\n {\n if (role === 'assistant')\n {\n return sanitizeHtml(renderMarkdown(String(content || '')));\n }\n\n if (role === 'system')\n {\n return escapeHtml(stringifyContent(content)).replace(/\\n/g, '<br>');\n }\n\n const { text, images } = splitMessageContent(content);\n const textHtml = escapeHtml(text).replace(/\\n/g, '<br>');\n const imagesHtml = images.length\n ? `<div class=\"message-images\">${images.map((url, index) => `<img class=\"message-image\" src=\"${escapeAttribute(url)}\" alt=\"上传图片 ${index + 1}\">`).join('')}</div>`\n : '';\n return `${textHtml}${imagesHtml}`;\n }\n\n function splitMessageContent(content)\n {\n if (typeof content === 'string') return { text: content, images: [] };\n const result = { text: '', images: [] };\n if (!Array.isArray(content)) return { text: String(content || ''), images: [] };\n content.forEach((part) =>\n {\n if (part?.type === 'text') result.text += part.text || '';\n if (part?.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) result.images.push(url);\n }\n });\n return result;\n }\n\n function stringifyContent(content)\n {\n if (typeof content === 'string') return content;\n const { text } = splitMessageContent(content);\n return text || JSON.stringify(content);\n }\n\n function renderMarkdown(text)\n {\n if (!window.marked) return escapeHtml(text).replace(/\\n/g, '<br>');\n return marked.parse(text || '');\n }\n\n function sanitizeHtml(html)\n {\n const template = document.createElement('template');\n template.innerHTML = html;\n template.content.querySelectorAll('script, iframe, object, embed').forEach((node) => node.remove());\n template.content.querySelectorAll('*').forEach((node) =>\n {\n Array.from(node.attributes).forEach((attr) =>\n {\n const name = attr.name.toLowerCase();\n const value = attr.value.trim().toLowerCase();\n if (name.startsWith('on') || value.startsWith('javascript:'))\n {\n node.removeAttribute(attr.name);\n }\n });\n });\n return template.innerHTML;\n }\n\n function typesetElement(element)\n {\n if (!window.MathJax?.typesetPromise) return;\n renderPromise = renderPromise\n .then(() => MathJax.typesetPromise([element]))\n .then(() => { chatContainer.scrollTop = chatContainer.scrollHeight; })\n .catch((err) => console.warn('MathJax render error:', err));\n }\n\n async function syncToNutstore()\n {\n try\n {\n saveSettings();\n await putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2));\n setStatus('已同步到坚果云', 'recording');\n alert('已同步到坚果云');\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云同步失败');\n alert('同步到坚果云失败: ' + formatNutstoreError(err));\n }\n }\n\n async function syncFromNutstore()\n {\n try\n {\n saveSettings();\n const text = await getNutstoreFile();\n const payload = JSON.parse(text);\n const messages = parseConversationPayload(payload);\n if (!messages.length) throw new Error('远端文件没有可加载的消息');\n restoreConversation(messages);\n setStatus('已从坚果云拉取', 'recording');\n alert(`已从坚果云拉取 ${messages.length} 条消息`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云拉取失败');\n alert('从坚果云拉取失败: ' + formatNutstoreError(err));\n }\n }\n\n async function testNutstoreConnection()\n {\n try\n {\n saveSettings();\n const result = await checkNutstoreConnection();\n setStatus('坚果云连接正常', 'recording');\n alert(`坚果云连接正常:${result}`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云连接失败');\n alert('坚果云连接失败: ' + formatNutstoreError(err));\n }\n }\n\n function scheduleAutoSync()\n {\n if (!document.getElementById('nutstore-auto').checked) return;\n const config = getNutstoreConfig();\n if (!config.url || !config.user || !config.pass || !config.path) return;\n clearTimeout(autoSyncTimer);\n autoSyncTimer = setTimeout(() =>\n {\n putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2))\n .then(() => setStatus('已自动同步', 'recording'))\n .catch((err) => console.warn('自动同步失败:', err));\n }, 1200);\n }\n\n function getNutstoreConfig()\n {\n return {\n url: normalizeNutstoreBaseUrl(document.getElementById('nutstore-url').value.trim() || defaultNutstoreUrl),\n user: document.getElementById('nutstore-user').value.trim(),\n pass: document.getElementById('nutstore-pass').value.trim(),\n path: document.getElementById('nutstore-path').value.trim() || defaultNutstorePath\n };\n }\n\n function normalizeNutstoreBaseUrl(url)\n {\n const trimmed = String(url || '').trim();\n if (!trimmed) return defaultNutstoreUrl;\n if (/^https?:\\/\\//i.test(trimmed)) return trimmed;\n return `http://${trimmed}`;\n }\n\n function getProxyNutstoreUrl()\n {\n const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';\n return `${protocol}//${proxyNutstoreHost}${proxyNutstorePath}`;\n }\n\n function isDirectNutstoreUrl(url)\n {\n return normalizeNutstoreBaseUrl(url).replace(/\\/+$/, '') === directNutstoreUrl.replace(/\\/+$/, '');\n }\n\n function isLegacyProxyNutstoreUrl(url)\n {\n return normalizeNutstoreBaseUrl(url).replace(/\\/+$/, '') === legacyProxyNutstoreUrl.replace(/\\/+$/, '');\n }\n\n function buildNutstoreUrl(config, pathOverride)\n {\n const base = config.url.replace(/\\/+$/, '');\n const path = (pathOverride || config.path).replace(/^\\/+/, '');\n const encodedPath = path.split('/').map(encodeURIComponent).join('/');\n return `${base}/${encodedPath}`;\n }\n\n function getNutstoreHeaders(config)\n {\n if (!config.user || !config.pass)\n {\n throw new Error('请填写坚果云账号和应用密码');\n }\n return {\n Authorization: `Basic ${btoa(unescape(encodeURIComponent(`${config.user}:${config.pass}`)))}`\n };\n }\n\n async function ensureNutstoreFolders(config, headers)\n {\n const parts = config.path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n let current = '';\n for (const part of parts)\n {\n current += `/${part}`;\n const response = await nutstoreFetch(buildNutstoreUrl(config, current), {\n method: 'MKCOL',\n headers\n }, '创建远端目录');\n if (![200, 201, 204, 405, 409].includes(response.status))\n {\n throw await createNutstoreHttpError(response, '创建远端目录失败');\n }\n }\n }\n\n async function putNutstoreFile(content)\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n await ensureNutstoreFolders(config, headers);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'PUT',\n headers: {\n ...headers,\n 'Content-Type': 'application/json; charset=utf-8'\n },\n body: content\n }, '上传坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '上传坚果云文件失败');\n }\n }\n\n async function getNutstoreFile()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'GET',\n headers\n }, '读取坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '读取坚果云文件失败');\n }\n return response.text();\n }\n\n async function checkNutstoreConnection()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const folderPath = getNutstoreParentPath(config.path);\n const response = await nutstoreFetch(buildNutstoreUrl(config, folderPath), {\n method: 'PROPFIND',\n headers: {\n ...headers,\n Depth: '0',\n 'Content-Type': 'application/xml; charset=utf-8'\n },\n body: '<?xml version=\"1.0\" encoding=\"utf-8\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:resourcetype/></d:prop></d:propfind>'\n }, '检测坚果云连接');\n\n if ([200, 207, 301, 302].includes(response.status))\n {\n return `HTTP ${response.status}`;\n }\n if (response.status === 404)\n {\n return '账号可连接,同步目录尚不存在,推送时会尝试创建';\n }\n throw await createNutstoreHttpError(response, '检测坚果云连接失败');\n }\n\n function getNutstoreParentPath(path)\n {\n const parts = path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n return parts.length ? `/${parts.join('/')}` : '/';\n }\n\n async function nutstoreFetch(url, options, action)\n {\n if (window.location.protocol === 'https:' && /^http:\\/\\//i.test(url))\n {\n const error = new Error(`${action}失败:当前页面是 HTTPS,但中转服务器地址是 HTTP。`);\n error.isMixedContent = true;\n throw error;\n }\n\n try\n {\n return await fetch(url, options);\n } catch (err)\n {\n const error = new Error(`${action}失败:浏览器无法访问坚果云 WebDAV。`);\n error.cause = err;\n error.isNetworkBlocked = true;\n throw error;\n }\n }\n\n async function createNutstoreHttpError(response, message)\n {\n let detail = '';\n try\n {\n detail = (await response.text()).replace(/\\s+/g, ' ').trim().slice(0, 160);\n } catch (err)\n {\n detail = '';\n }\n\n const error = new Error(`${message}: HTTP ${response.status}${detail ? ` ${detail}` : ''}`);\n error.status = response.status;\n return error;\n }\n\n function formatNutstoreError(err)\n {\n if (err?.isMixedContent)\n {\n return `${err.message} 浏览器会拦截 HTTPS 页面访问 HTTP 接口。请给 zihancai.top 配置 HTTPS,或把页面也部署在同一个 HTTP 源上测试。`;\n }\n\n if (err?.isNetworkBlocked || err?.cause instanceof TypeError || /failed to fetch/i.test(err?.message || ''))\n {\n return '浏览器无法连接 zihancai.top 的 WebDAV 中转服务。请确认 nginx 反代正在运行、域名解析正确、已允许 CORS/OPTIONS、地址为 /dav/,并且防火墙放行了对应端口。';\n }\n\n if (err?.status === 401 || err?.status === 403)\n {\n return `${err.message}。请检查坚果云账号和“第三方应用密码”,不是登录密码。`;\n }\n\n if (err?.status === 404)\n {\n return `${err.message}。远端文件不存在,可以先使用“同步到坚果云”创建。`;\n }\n\n return err?.message || String(err);\n }\n\n function setupMcpBridge()\n {\n window.MathChatMCP = {\n version: '1.0.0',\n tools: [\n 'chat.export',\n 'chat.import',\n 'chat.list',\n 'chat.load',\n 'chat.delete',\n 'chat.new',\n 'chat.clear',\n 'nutstore.test',\n 'nutstore.pull',\n 'nutstore.push',\n 'config.get',\n 'config.setNutstore',\n 'config.useNutstoreProxy'\n ],\n async call(method, params = {})\n {\n switch (method)\n {\n case 'chat.export':\n return buildConversationPayload();\n case 'chat.import':\n restoreConversation(parseConversationPayload(params.payload || params.messages || []));\n return { ok: true, count: messageHistory.length };\n case 'chat.list':\n return getSavedConversations().map((item) => ({\n id: item.id,\n title: item.title,\n createdAt: item.createdAt,\n updatedAt: item.updatedAt,\n count: item.messages.length,\n active: item.id === currentConversationId\n }));\n case 'chat.load':\n loadSavedConversation(params.id);\n return { ok: true, count: messageHistory.length };\n case 'chat.delete':\n deleteSavedConversation(params.id);\n return { ok: true };\n case 'chat.new':\n newConversation();\n return { ok: true, id: currentConversationId };\n case 'chat.clear':\n messageHistory = [];\n persistConversation();\n renderGreeting();\n return { ok: true };\n case 'nutstore.test':\n return { ok: true, result: await checkNutstoreConnection() };\n case 'nutstore.pull':\n await syncFromNutstore();\n return { ok: true, count: messageHistory.length };\n case 'nutstore.push':\n await syncToNutstore();\n return { ok: true, count: messageHistory.length };\n case 'config.get':\n return {\n apiCount: getApiConfigs().length,\n nutstore: { ...getNutstoreConfig(), pass: getNutstoreConfig().pass ? '***' : '' }\n };\n case 'config.setNutstore':\n if (params.url) document.getElementById('nutstore-url').value = params.url;\n if (params.user) document.getElementById('nutstore-user').value = params.user;\n if (params.pass) document.getElementById('nutstore-pass').value = params.pass;\n if (params.path) document.getElementById('nutstore-path').value = params.path;\n saveSettings();\n return { ok: true };\n case 'config.useNutstoreProxy':\n useNutstoreProxy();\n return { ok: true, url: proxyNutstoreUrl };\n default:\n throw new Error(`Unknown MCP method: ${method}`);\n }\n },\n async handle(request)\n {\n const id = request?.id ?? null;\n try\n {\n const result = await this.call(request.method, request.params || {});\n return { jsonrpc: '2.0', id, result };\n } catch (err)\n {\n return { jsonrpc: '2.0', id, error: { code: -32000, message: err.message } };\n }\n }\n };\n }\n\n function debounce(func, wait)\n {\n let timeout;\n return function (...args)\n {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n function escapeHtml(text)\n {\n return String(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '`');\n }\n\n function formatShortTime(value)\n {\n const date = value ? new Date(value) : new Date();\n if (Number.isNaN(date.getTime())) return '未知时间';\n const now = new Date();\n const sameYear = date.getFullYear() === now.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hour = String(date.getHours()).padStart(2, '0');\n const minute = String(date.getMinutes()).padStart(2, '0');\n return sameYear\n ? `${month}-${day} ${hour}:${minute}`\n : `${date.getFullYear()}-${month}-${day}`;\n }\n\n\n </script>\n</body>\n\n</html>\n"}, "9c05e939eb16da9adf044a4cc5618e12ed7d3646": {"t": 1785765408.6874933, "n": 81393, "h": "2a250165cd983a72", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f6fb;\n --panel-color: #2c3e50;\n --panel-muted: #34495e;\n --primary-color: #3d7bd9;\n --primary-dark: #2764ba;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n --text-muted: #64748b;\n }\n\n * {\n box-sizing: border-box;\n touch-action: manipulation;\n }\n\n body,\n html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .settings-header {\n background: var(--panel-color);\n color: white;\n padding: 10px 15px;\n font-size: 0.92rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .settings-title {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .settings-panel {\n background: var(--panel-muted);\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n max-height: 54vh;\n overflow-y: auto;\n }\n\n .settings-panel.show {\n display: flex;\n }\n\n .settings-row {\n display: flex;\n gap: 10px;\n width: 100%;\n flex-wrap: wrap;\n }\n\n .settings-group {\n display: flex;\n flex-direction: column;\n gap: 6px;\n flex: 1;\n min-width: 220px;\n }\n\n .settings-group label {\n color: #dbeafe;\n font-size: 0.78rem;\n font-weight: 700;\n }\n\n .settings-panel input,\n .settings-panel textarea {\n background: var(--panel-color);\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n font: inherit;\n font-size: 0.9rem;\n outline: none;\n }\n\n .settings-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n min-width: 16px;\n padding: 0;\n accent-color: var(--primary-color);\n }\n\n .checkbox-label {\n color: #e2e8f0;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 0.84rem;\n font-weight: 700;\n min-height: 38px;\n }\n\n .settings-panel textarea {\n min-height: 76px;\n resize: vertical;\n line-height: 1.45;\n }\n\n .settings-panel input::placeholder,\n .settings-panel textarea::placeholder {\n color: #b7c9d8;\n }\n\n .setting-note {\n color: #cbd5e1;\n font-size: 0.75rem;\n line-height: 1.35;\n }\n\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 220px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: 0;\n width: 100%;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n min-width: 42px;\n }\n\n .model-list-btn:hover {\n background: #3498db;\n }\n\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: var(--panel-color);\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.28);\n margin-top: 2px;\n }\n\n .model-dropdown.show {\n display: block;\n }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:hover {\n background: #3c5870;\n color: #dff2ff;\n }\n\n .model-option.loading {\n text-align: center;\n color: #bdc3c7;\n cursor: default;\n }\n\n .model-option.error {\n color: #fecaca;\n font-size: 0.8rem;\n cursor: default;\n }\n\n .action-btn {\n padding: 8px 13px;\n min-height: 38px;\n color: white;\n border: 0;\n border-radius: 4px;\n cursor: pointer;\n font-weight: 700;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n justify-content: center;\n }\n\n .save-btn {\n background: #238457;\n }\n\n .save-btn:hover {\n background: #2aa96d;\n }\n\n .export-btn {\n background: #ce6f18;\n }\n\n .export-btn:hover {\n background: #b85f0f;\n }\n\n .autosave-btn {\n background: #7f3a98;\n }\n\n .autosave-btn:hover {\n background: #9145ad;\n }\n\n .autosave-btn.active {\n background: #238457;\n }\n\n .import-btn {\n background: #118270;\n }\n\n .import-btn:hover {\n background: #159c88;\n }\n\n .sync-btn {\n background: #3366a8;\n }\n\n .sync-btn:hover {\n background: #3e78c4;\n }\n\n .danger-btn {\n background: #a33a3a;\n }\n\n .danger-btn:hover {\n background: #bf4646;\n }\n\n .status-indicator {\n font-size: 0.8rem;\n color: #bdc3c7;\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n }\n\n .status-indicator.recording {\n color: #86efac;\n }\n\n .app-main {\n flex: 1;\n min-height: 0;\n display: flex;\n overflow: hidden;\n }\n\n .conversation-sidebar {\n width: 280px;\n min-width: 240px;\n max-width: 34vw;\n background: #ffffff;\n border-right: 1px solid var(--border-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .conversation-sidebar-header {\n padding: 12px;\n border-bottom: 1px solid #e5e7eb;\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n .conversation-sidebar-title {\n color: #1f2937;\n font-size: 0.95rem;\n font-weight: 800;\n }\n\n .conversation-actions {\n display: flex;\n gap: 8px;\n }\n\n .side-btn {\n flex: 1;\n border: 1px solid #cbd5e1;\n background: #f8fafc;\n color: #334155;\n border-radius: 6px;\n min-height: 34px;\n cursor: pointer;\n font-weight: 700;\n }\n\n .side-btn:hover {\n background: #eef2f7;\n }\n\n .conversation-list {\n flex: 1;\n overflow-y: auto;\n padding: 8px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .conversation-item {\n border: 1px solid #e2e8f0;\n border-radius: 8px;\n background: #ffffff;\n padding: 9px;\n display: grid;\n grid-template-columns: 1fr auto;\n gap: 6px;\n cursor: pointer;\n }\n\n .conversation-item:hover {\n background: #f8fafc;\n }\n\n .conversation-item.active {\n border-color: var(--primary-color);\n background: #eef6ff;\n }\n\n .conversation-item-title {\n color: #111827;\n font-size: 0.9rem;\n font-weight: 750;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .conversation-item-meta {\n color: var(--text-muted);\n font-size: 0.74rem;\n grid-column: 1 / -1;\n }\n\n .conversation-delete {\n width: 24px;\n height: 24px;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: #64748b;\n cursor: pointer;\n font-size: 1rem;\n line-height: 1;\n }\n\n .conversation-delete:hover {\n background: #fee2e2;\n color: #b91c1c;\n }\n\n .conversation-empty {\n color: var(--text-muted);\n font-size: 0.84rem;\n line-height: 1.5;\n padding: 12px;\n }\n\n #chat-container {\n flex: 1;\n min-width: 0;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: min(92%, 900px);\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n overflow-wrap: anywhere;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);\n }\n\n .message.user {\n align-self: flex-end;\n background: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background: white;\n color: #263238;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n .message.system {\n align-self: center;\n background: #fff7ed;\n color: #9a3412;\n border: 1px solid #fed7aa;\n max-width: min(92%, 720px);\n }\n\n .message.ai pre {\n background: #2d2d2d;\n color: #f8f8f2;\n padding: 10px;\n border-radius: 5px;\n overflow-x: auto;\n }\n\n .message.ai p {\n margin: 0 0 8px 0;\n }\n\n .message.ai p:last-child {\n margin: 0;\n }\n\n .message-images {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n gap: 8px;\n margin-top: 10px;\n }\n\n .message-image {\n width: 100%;\n max-height: 220px;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n background: rgba(255, 255, 255, 0.1);\n }\n\n .message.ai .message-image {\n border-color: var(--border-color);\n }\n\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative;\n }\n\n .math-preview-area {\n background: #fffdf5;\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none;\n }\n\n .math-preview-area.show {\n display: block;\n }\n\n .math-preview-area p {\n margin: 0;\n }\n\n .attachment-tray {\n display: none;\n gap: 8px;\n padding: 8px 10px;\n border-bottom: 1px solid #e5e7eb;\n overflow-x: auto;\n background: #f8fafc;\n }\n\n .attachment-tray.show {\n display: flex;\n }\n\n .attachment-thumb {\n position: relative;\n width: 70px;\n height: 70px;\n flex: 0 0 auto;\n }\n\n .attachment-thumb img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid #cbd5e1;\n display: block;\n }\n\n .attachment-thumb button {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 22px;\n height: 22px;\n border: 0;\n border-radius: 50%;\n background: #1f2937;\n color: white;\n cursor: pointer;\n line-height: 1;\n }\n\n .math-toolbar-container {\n background: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar {\n display: none;\n }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background: #e2e8f0;\n transform: translateY(1px);\n }\n\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n #attach-btn {\n width: 44px;\n height: 44px;\n border: 1px solid var(--border-color);\n background: #f8fafc;\n color: #334155;\n border-radius: 8px;\n cursor: pointer;\n flex-shrink: 0;\n font-weight: 800;\n font-size: 1.1rem;\n }\n\n #attach-btn:hover {\n background: #eef2f7;\n }\n\n textarea#user-input {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n color: #111827;\n }\n\n textarea#user-input:focus {\n border-color: var(--primary-color);\n }\n\n button#send-btn {\n background: var(--primary-color);\n color: white;\n border: 0;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: 700;\n cursor: pointer;\n flex-shrink: 0;\n }\n\n button#send-btn:hover {\n background: var(--primary-dark);\n }\n\n button#send-btn:disabled {\n background: #94a3b8;\n cursor: not-allowed;\n }\n\n @media (max-width: 640px) {\n .app-main {\n flex-direction: column;\n }\n\n .conversation-sidebar {\n width: 100%;\n max-width: none;\n min-width: 0;\n max-height: 34vh;\n border-right: 0;\n border-bottom: 1px solid var(--border-color);\n }\n\n .conversation-sidebar-header {\n padding: 10px;\n }\n\n .settings-group {\n min-width: 100%;\n }\n\n .settings-row {\n gap: 8px;\n }\n\n .message {\n max-width: 96%;\n }\n\n button#send-btn {\n padding: 0 14px;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"settings-header\" onclick=\"toggleSettings()\">\n <div class=\"settings-title\">\n <span>设置与同步</span>\n <span id=\"autosave-status\" class=\"status-indicator\"></span>\n </div>\n <span id=\"toggle-icon\">▼</span>\n </div>\n\n <div class=\"settings-panel\" id=\"settings-panel\">\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-url\">默认 API 地址</label>\n <input type=\"text\" id=\"api-url\" placeholder=\"https://api.openai.com/v1\">\n </div>\n <div class=\"settings-group\">\n <label for=\"api-key\">默认 API Key</label>\n <input type=\"password\" id=\"api-key\" placeholder=\"sk-...\">\n </div>\n <div class=\"settings-group\">\n <label for=\"model-name\">默认模型</label>\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"gpt-4o, deepseek-chat\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-list\">多 API 轮询列表</label>\n <textarea id=\"api-list\" placeholder=\"每行一个:API地址 | API Key | 模型(可选) https://api.openai.com/v1 | sk-xxx | gpt-4o https://api.deepseek.com/v1 | sk-yyy | deepseek-chat\"></textarea>\n <div class=\"setting-note\">发送时按轮询顺序选择接口;当前接口失败会自动尝试下一行。留空则使用上面的默认 API。</div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"nutstore-url\">坚果云 WebDAV/MCP 地址</label>\n <input type=\"text\" id=\"nutstore-url\" placeholder=\"https://dav.jianguoyun.com/dav/\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-user\">坚果云账号</label>\n <input type=\"text\" id=\"nutstore-user\" placeholder=\"你的坚果云登录邮箱\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-pass\">坚果云应用密码</label>\n <input type=\"password\" id=\"nutstore-pass\" placeholder=\"坚果云第三方应用密码\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-path\">同步文件路径</label>\n <input type=\"text\" id=\"nutstore-path\" placeholder=\"/MathAIChat/chat.json\">\n </div>\n </div>\n\n <div class=\"setting-note\">\n 已暴露浏览器端 MCP 桥接对象:window.MathChatMCP。坚果云同步使用 WebDAV;如果浏览器因 CORS 拦截,请用同源 MCP/WebDAV 代理转发。\n </div>\n\n <div class=\"settings-row\">\n <label class=\"checkbox-label\">\n <input type=\"checkbox\" id=\"nutstore-auto\">\n 启用坚果云自动同步\n </label>\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button id=\"btn-autosave\" class=\"action-btn autosave-btn\" onclick=\"setupAutoSave()\">启用本地自动保存</button>\n <input type=\"file\" id=\"import-file\" accept=\".json,application/json\" style=\"display:none\" onchange=\"handleFileImport(this)\">\n <button class=\"action-btn import-btn\" onclick=\"document.getElementById('import-file').click()\">加载对话</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n <button class=\"action-btn sync-btn\" onclick=\"testNutstoreConnection()\">检测坚果云连接</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncFromNutstore()\">从坚果云拉取</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncToNutstore()\">同步到坚果云</button>\n <button class=\"action-btn danger-btn\" onclick=\"clearConversation()\">清空当前对话</button>\n </div>\n </div>\n\n <div class=\"app-main\">\n <aside class=\"conversation-sidebar\">\n <div class=\"conversation-sidebar-header\">\n <div class=\"conversation-sidebar-title\">已保存对话</div>\n <div class=\"conversation-actions\">\n <button class=\"side-btn\" onclick=\"newConversation()\">新建</button>\n <button class=\"side-btn\" onclick=\"saveCurrentConversation()\">保存当前</button>\n </div>\n </div>\n <div id=\"conversation-list\" class=\"conversation-list\"></div>\n </aside>\n\n <div id=\"chat-container\"></div>\n </div>\n\n <div class=\"bottom-section\">\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n <div id=\"attachment-tray\" class=\"attachment-tray\"></div>\n\n <div class=\"math-toolbar-container\">\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <input type=\"file\" id=\"image-input\" accept=\"image/*\" multiple style=\"display:none\" onchange=\"handleImageUpload(this)\">\n <button id=\"attach-btn\" onclick=\"document.getElementById('image-input').click()\" title=\"上传图片\">+</button>\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n </div>\n\n <script>\n const STORAGE_KEYS = {\n apiUrl: 'api_url',\n apiKey: 'api_key',\n modelName: 'model_name',\n apiList: 'api_list',\n conversation: 'math_chat_conversation_v2',\n conversations: 'math_chat_saved_conversations_v1',\n activeConversationId: 'math_chat_active_conversation_id',\n apiIndex: 'math_chat_api_index',\n nutstoreUrl: 'nutstore_url',\n nutstoreUser: 'nutstore_user',\n nutstorePass: 'nutstore_pass',\n nutstorePath: 'nutstore_path',\n nutstoreAuto: 'nutstore_auto_sync'\n };\n\n const defaultUrl = 'https://api.openai.com/v1';\n const defaultModel = 'gpt-4o';\n const defaultNutstoreUrl = 'https://dav.jianguoyun.com/dav/';\n const defaultNutstorePath = '/MathAIChat/chat.json';\n const maxImageBytes = 8 * 1024 * 1024;\n\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1 },\n { label: 'x', insert: 'x', offset: 0 },\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n const chatContainer = document.getElementById('chat-container');\n const conversationList = document.getElementById('conversation-list');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n const attachmentTray = document.getElementById('attachment-tray');\n\n let messageHistory = [];\n let currentConversationId = localStorage.getItem(STORAGE_KEYS.activeConversationId) || '';\n let pendingImages = [];\n let fileHandle = null;\n let isAutoSaveActive = false;\n let renderPromise = Promise.resolve();\n let apiRoundRobinIndex = Number(localStorage.getItem(STORAGE_KEYS.apiIndex) || 0);\n let autoSyncTimer = null;\n\n window.addEventListener('load', initApp);\n\n function initApp()\n {\n document.getElementById('api-url').value = localStorage.getItem(STORAGE_KEYS.apiUrl) || '';\n document.getElementById('api-key').value = localStorage.getItem(STORAGE_KEYS.apiKey) || '';\n document.getElementById('model-name').value = localStorage.getItem(STORAGE_KEYS.modelName) || defaultModel;\n document.getElementById('api-list').value = localStorage.getItem(STORAGE_KEYS.apiList) || '';\n document.getElementById('nutstore-url').value = localStorage.getItem(STORAGE_KEYS.nutstoreUrl) || defaultNutstoreUrl;\n document.getElementById('nutstore-user').value = localStorage.getItem(STORAGE_KEYS.nutstoreUser) || '';\n document.getElementById('nutstore-pass').value = localStorage.getItem(STORAGE_KEYS.nutstorePass) || '';\n document.getElementById('nutstore-path').value = localStorage.getItem(STORAGE_KEYS.nutstorePath) || defaultNutstorePath;\n document.getElementById('nutstore-auto').checked = localStorage.getItem(STORAGE_KEYS.nutstoreAuto) === 'true';\n\n if (window.marked)\n {\n marked.setOptions({\n highlight: (code, lang) =>\n {\n if (!window.hljs) return code;\n return hljs.highlight(code, { language: hljs.getLanguage(lang) ? lang : 'plaintext' }).value;\n }\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n setupGlobalClickHandlers();\n setupMcpBridge();\n loadConversationFromCache();\n }\n\n function setupGlobalClickHandlers()\n {\n document.addEventListener('click', (event) =>\n {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (dropdown && !dropdown.contains(event.target) && event.target !== btn)\n {\n dropdown.classList.remove('show');\n }\n });\n }\n\n function renderToolbar(containerId, symbols)\n {\n const container = document.getElementById(containerId);\n if (!container) return;\n container.innerHTML = '';\n symbols.forEach((sym) =>\n {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (event) => event.preventDefault();\n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n function insertSymbol(text, offsetBack)\n {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n function toggleSettings()\n {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n panel.classList.toggle('show');\n icon.textContent = panel.classList.contains('show') ? '▲' : '▼';\n }\n\n function saveSettings()\n {\n localStorage.setItem(STORAGE_KEYS.apiUrl, document.getElementById('api-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiKey, document.getElementById('api-key').value.trim());\n localStorage.setItem(STORAGE_KEYS.modelName, document.getElementById('model-name').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiList, document.getElementById('api-list').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUrl, document.getElementById('nutstore-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUser, document.getElementById('nutstore-user').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePass, document.getElementById('nutstore-pass').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePath, document.getElementById('nutstore-path').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreAuto, document.getElementById('nutstore-auto').checked ? 'true' : 'false');\n setStatus('配置已保存');\n }\n\n function setStatus(text, type = '')\n {\n const status = document.getElementById('autosave-status');\n status.textContent = text ? `● ${text}` : '';\n status.className = `status-indicator ${type}`;\n }\n\n async function setupAutoSave()\n {\n if (!('showDirectoryPicker' in window))\n {\n alert('您的浏览器不支持文件系统访问 API。请使用 Chrome/Edge 桌面版。');\n return;\n }\n\n try\n {\n const dirHandle = await window.showDirectoryPicker();\n const fileName = `chat_${formatTimestamp(new Date())}.json`;\n fileHandle = await dirHandle.getFileHandle(fileName, { create: true });\n isAutoSaveActive = true;\n const btn = document.getElementById('btn-autosave');\n btn.textContent = `正在保存:${fileName}`;\n btn.classList.add('active');\n setStatus('正在记录', 'recording');\n await writeToFile();\n } catch (err)\n {\n if (err.name !== 'AbortError')\n {\n console.error(err);\n alert('设置自动保存失败: ' + err.message);\n }\n }\n }\n\n async function writeToFile()\n {\n if (!fileHandle || !isAutoSaveActive) return;\n try\n {\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(buildConversationPayload(), null, 2));\n await writable.close();\n } catch (err)\n {\n console.error('自动保存写入失败:', err);\n setStatus('保存失败');\n }\n }\n\n function buildConversationPayload()\n {\n return {\n version: 2,\n app: 'Math AI Chat',\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n exportedAt: new Date().toISOString(),\n messages: messageHistory\n };\n }\n\n function persistConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n writeToFile();\n scheduleAutoSync();\n }\n\n function loadConversationFromCache()\n {\n migrateLegacyConversation();\n renderConversationList();\n const saved = getSavedConversations();\n const active = saved.find((item) => item.id === currentConversationId) || saved[0];\n if (active)\n {\n currentConversationId = active.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = active.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n renderConversationList();\n return;\n }\n\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n renderGreeting();\n renderConversationList();\n return;\n }\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (messages.length)\n {\n restoreConversation(messages, { silent: true });\n return;\n }\n } catch (err)\n {\n console.warn('本地缓存对话加载失败:', err);\n }\n\n renderGreeting();\n renderConversationList();\n }\n\n function getSavedConversations()\n {\n try\n {\n const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.conversations) || '[]');\n return Array.isArray(parsed) ? parsed.filter((item) => item && item.id && Array.isArray(item.messages)) : [];\n } catch (err)\n {\n console.warn('保存对话列表读取失败:', err);\n return [];\n }\n }\n\n function setSavedConversations(conversations)\n {\n localStorage.setItem(STORAGE_KEYS.conversations, JSON.stringify(conversations));\n }\n\n function saveConversationToLibrary()\n {\n if (!currentConversationId)\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n\n const conversations = getSavedConversations();\n const now = new Date().toISOString();\n const existing = conversations.find((item) => item.id === currentConversationId);\n const record = {\n id: currentConversationId,\n title: getConversationTitle(messageHistory),\n createdAt: existing?.createdAt || now,\n updatedAt: now,\n messages: messageHistory.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }))\n };\n\n const next = [record, ...conversations.filter((item) => item.id !== currentConversationId)]\n .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))\n .slice(0, 80);\n setSavedConversations(next);\n renderConversationList();\n }\n\n function migrateLegacyConversation()\n {\n const saved = getSavedConversations();\n if (saved.length) return;\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw) return;\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (!messages.length) return;\n currentConversationId = currentConversationId || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n setSavedConversations([{\n id: currentConversationId,\n title: getConversationTitle(messages),\n createdAt: new Date().toISOString(),\n updatedAt: new Date().toISOString(),\n messages\n }]);\n } catch (err)\n {\n console.warn('旧对话迁移失败:', err);\n }\n }\n\n function renderConversationList()\n {\n if (!conversationList) return;\n const conversations = getSavedConversations();\n conversationList.innerHTML = '';\n\n if (!conversations.length)\n {\n const empty = document.createElement('div');\n empty.className = 'conversation-empty';\n empty.textContent = '还没有保存的对话。发送消息、导入文件或点击“保存当前”后会出现在这里。';\n conversationList.appendChild(empty);\n return;\n }\n\n conversations.forEach((conversation) =>\n {\n const item = document.createElement('div');\n item.className = `conversation-item ${conversation.id === currentConversationId ? 'active' : ''}`;\n item.onclick = () => loadSavedConversation(conversation.id);\n\n const title = document.createElement('div');\n title.className = 'conversation-item-title';\n title.textContent = conversation.title || '未命名对话';\n\n const deleteBtn = document.createElement('button');\n deleteBtn.type = 'button';\n deleteBtn.className = 'conversation-delete';\n deleteBtn.textContent = '×';\n deleteBtn.title = '删除对话';\n deleteBtn.onclick = (event) =>\n {\n event.stopPropagation();\n deleteSavedConversation(conversation.id);\n };\n\n const meta = document.createElement('div');\n meta.className = 'conversation-item-meta';\n meta.textContent = `${conversation.messages.length} 条消息 · ${formatShortTime(conversation.updatedAt)}`;\n\n item.appendChild(title);\n item.appendChild(deleteBtn);\n item.appendChild(meta);\n conversationList.appendChild(item);\n });\n }\n\n function loadSavedConversation(id)\n {\n const conversation = getSavedConversations().find((item) => item.id === id);\n if (!conversation) return;\n currentConversationId = conversation.id;\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = conversation.messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n clearPendingImages();\n renderConversation();\n renderConversationList();\n setStatus('已切换对话', 'recording');\n }\n\n function deleteSavedConversation(id)\n {\n if (!confirm('确定要删除这条保存的对话吗?')) return;\n const conversations = getSavedConversations().filter((item) => item.id !== id);\n setSavedConversations(conversations);\n if (id === currentConversationId)\n {\n const next = conversations[0];\n if (next)\n {\n loadSavedConversation(next.id);\n } else\n {\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n renderGreeting();\n renderConversationList();\n }\n } else\n {\n renderConversationList();\n }\n setStatus('已删除对话');\n }\n\n function newConversation()\n {\n if (messageHistory.length) saveConversationToLibrary();\n currentConversationId = createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n clearPendingImages();\n renderGreeting();\n renderConversationList();\n setStatus('新对话');\n userInput.focus();\n }\n\n function saveCurrentConversation()\n {\n saveConversationToLibrary();\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n setStatus('当前对话已保存', 'recording');\n }\n\n function createConversationId()\n {\n return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n }\n\n function getConversationTitle(messages)\n {\n const firstUser = messages.find((msg) => msg.role === 'user');\n const text = stringifyContent(firstUser?.content || '').replace(/\\s+/g, ' ').trim();\n if (text) return text.slice(0, 32);\n return `新对话 ${formatShortTime(new Date().toISOString())}`;\n }\n\n function renderGreeting()\n {\n chatContainer.innerHTML = '';\n const div = document.createElement('div');\n div.className = 'message ai';\n div.innerHTML = '你好!我是你的数学助手。<br>你可以输入数学问题、上传图片,也可以从设置中导入或同步对话。';\n chatContainer.appendChild(div);\n }\n\n function handleFileImport(input)\n {\n const file = input.files[0];\n if (!file) return;\n\n const reader = new FileReader();\n reader.onload = (event) =>\n {\n try\n {\n const payload = JSON.parse(event.target.result);\n const loadedHistory = parseConversationPayload(payload);\n if (!loadedHistory.length)\n {\n throw new Error('文件中没有可加载的对话消息');\n }\n restoreConversation(loadedHistory);\n alert(`成功加载 ${loadedHistory.length} 条消息`);\n } catch (err)\n {\n alert('加载失败: ' + err.message);\n } finally\n {\n input.value = '';\n }\n };\n reader.onerror = () =>\n {\n alert('读取文件失败');\n input.value = '';\n };\n reader.readAsText(file, 'utf-8');\n }\n\n function parseConversationPayload(payload)\n {\n const source = Array.isArray(payload)\n ? payload\n : (payload?.messages || payload?.messageHistory || payload?.history || []);\n\n if (!Array.isArray(source))\n {\n throw new Error('文件格式错误:未找到 messages 数组');\n }\n\n return source\n .map(normalizeMessage)\n .filter(Boolean);\n }\n\n function normalizeMessage(msg)\n {\n if (!msg || typeof msg !== 'object') return null;\n const roleMap = { ai: 'assistant', bot: 'assistant', assistant: 'assistant', user: 'user', system: 'system' };\n const role = roleMap[msg.role] || 'user';\n const normalized = normalizeContent(msg.content ?? msg.text ?? '');\n if (typeof normalized === 'string' && normalized.length === 0) return null;\n if (Array.isArray(normalized) && normalized.length === 0) return null;\n return { role, content: normalized };\n }\n\n function normalizeContent(content)\n {\n if (typeof content === 'string') return content;\n if (!Array.isArray(content))\n {\n return typeof content === 'undefined' || content === null ? '' : String(content);\n }\n\n const parts = [];\n content.forEach((part) =>\n {\n if (!part) return;\n if (typeof part === 'string')\n {\n parts.push({ type: 'text', text: part });\n return;\n }\n if (part.type === 'text')\n {\n parts.push({ type: 'text', text: String(part.text || '') });\n return;\n }\n if (part.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n return;\n }\n if (part.type === 'image' || part.dataUrl)\n {\n const url = part.dataUrl || part.url || part.src;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n }\n });\n return parts;\n }\n\n function restoreConversation(messages, options = {})\n {\n if (!options.keepCurrentId)\n {\n currentConversationId = options.id || createConversationId();\n localStorage.setItem(STORAGE_KEYS.activeConversationId, currentConversationId);\n }\n messageHistory = messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n persistConversation();\n if (!options.silent) setStatus('对话已加载', 'recording');\n }\n\n function cloneContent(content)\n {\n if (typeof content === 'undefined') return '';\n return JSON.parse(JSON.stringify(content));\n }\n\n function renderConversation()\n {\n chatContainer.innerHTML = '';\n if (!messageHistory.length)\n {\n renderGreeting();\n return;\n }\n messageHistory.forEach((msg) => appendMessage(msg.role, msg.content));\n typesetElement(chatContainer);\n }\n\n function exportChat()\n {\n if (!messageHistory.length)\n {\n alert('暂无对话记录可导出');\n return;\n }\n\n const dataStr = JSON.stringify(buildConversationPayload(), null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `math_chat_export_${formatTimestamp(new Date())}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n function clearConversation()\n {\n if (!confirm('确定要清空当前对话吗?')) return;\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n saveConversationToLibrary();\n clearPendingImages();\n renderGreeting();\n setStatus('已清空');\n }\n\n function getApiConfigs()\n {\n const apiList = document.getElementById('api-list').value.trim();\n const globalModel = document.getElementById('model-name').value.trim() || defaultModel;\n const configs = [];\n\n if (apiList)\n {\n apiList.split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line && !line.startsWith('#'))\n .forEach((line) =>\n {\n const parts = line.includes('|')\n ? line.split('|').map((part) => part.trim())\n : line.split(',').map((part) => part.trim());\n const [url, key, model] = parts;\n if (url && key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: model || globalModel\n });\n }\n });\n }\n\n if (!configs.length)\n {\n const url = document.getElementById('api-url').value.trim() || defaultUrl;\n const key = document.getElementById('api-key').value.trim();\n if (key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: globalModel\n });\n }\n }\n\n return configs;\n }\n\n function normalizeChatUrl(url)\n {\n let apiUrl = (url || defaultUrl).trim();\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n return apiUrl;\n }\n\n async function fetchAndShowModels(event)\n {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const configs = getApiConfigs();\n const config = configs[apiRoundRobinIndex % Math.max(configs.length, 1)] || configs[0];\n\n if (dropdown.classList.contains('show'))\n {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!config)\n {\n alert('请先填写 API Key 或多 API 轮询列表');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try\n {\n const modelsUrl = config.url.replace(/\\/chat\\/completions$/, '/models');\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${config.key}`,\n 'Content-Type': 'application/json'\n }\n });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => String(a.id).localeCompare(String(b.id)));\n dropdown.innerHTML = '';\n\n if (!models.length)\n {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach((model) =>\n {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () =>\n {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n } catch (error)\n {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${escapeHtml(error.message)}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function ()\n {\n this.style.height = 'auto';\n this.style.height = `${this.scrollHeight}px`;\n if (this.value === '') this.style.height = '44px';\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function (event)\n {\n if (event.key === 'Enter' && !event.shiftKey)\n {\n event.preventDefault();\n sendMessage();\n }\n });\n\n const updatePreview = debounce(function ()\n {\n const text = userInput.value;\n if (!text.trim())\n {\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n return;\n }\n previewArea.classList.add('show');\n previewArea.innerHTML = sanitizeHtml(renderMarkdown(text));\n typesetElement(previewArea);\n }, 250);\n\n async function handleImageUpload(input)\n {\n const files = Array.from(input.files || []);\n if (!files.length) return;\n\n for (const file of files)\n {\n if (!file.type.startsWith('image/'))\n {\n alert(`${file.name} 不是图片文件`);\n continue;\n }\n if (file.size > maxImageBytes)\n {\n alert(`${file.name} 超过 8MB,已跳过`);\n continue;\n }\n const dataUrl = await readFileAsDataUrl(file);\n pendingImages.push({\n name: file.name,\n type: file.type,\n size: file.size,\n dataUrl\n });\n }\n input.value = '';\n renderAttachmentTray();\n }\n\n function readFileAsDataUrl(file)\n {\n return new Promise((resolve, reject) =>\n {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(reader.error || new Error('图片读取失败'));\n reader.readAsDataURL(file);\n });\n }\n\n function renderAttachmentTray()\n {\n attachmentTray.innerHTML = '';\n attachmentTray.classList.toggle('show', pendingImages.length > 0);\n pendingImages.forEach((image, index) =>\n {\n const item = document.createElement('div');\n item.className = 'attachment-thumb';\n const img = document.createElement('img');\n img.src = image.dataUrl;\n img.alt = image.name || `图片 ${index + 1}`;\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.textContent = '×';\n removeBtn.title = '移除图片';\n removeBtn.onclick = () =>\n {\n pendingImages.splice(index, 1);\n renderAttachmentTray();\n };\n item.appendChild(img);\n item.appendChild(removeBtn);\n attachmentTray.appendChild(item);\n });\n }\n\n function clearPendingImages()\n {\n pendingImages = [];\n renderAttachmentTray();\n }\n\n function buildUserContent(text, images)\n {\n if (!images.length) return text;\n const parts = [];\n if (text) parts.push({ type: 'text', text });\n images.forEach((image) =>\n {\n parts.push({\n type: 'image_url',\n image_url: { url: image.dataUrl }\n });\n });\n return parts;\n }\n\n async function sendMessage()\n {\n const text = userInput.value.trim();\n const images = pendingImages.slice();\n const apiConfigs = getApiConfigs();\n\n if (!text && !images.length) return;\n if (!apiConfigs.length)\n {\n alert('请点击顶部设置填写 API Key,或添加多 API 轮询列表');\n if (!document.getElementById('settings-panel').classList.contains('show')) toggleSettings();\n return;\n }\n\n const userContent = buildUserContent(text, images);\n messageHistory.push({ role: 'user', content: userContent });\n appendMessage('user', userContent);\n persistConversation();\n\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n clearPendingImages();\n\n const aiMessageDiv = appendMessage('assistant', '<span class=\"typing\">思考中...</span>', { rawHtml: true });\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = '';\n const errors = [];\n const startIndex = apiRoundRobinIndex % apiConfigs.length;\n\n try\n {\n for (let attempt = 0; attempt < apiConfigs.length; attempt++)\n {\n const index = (startIndex + attempt) % apiConfigs.length;\n const config = apiConfigs[index];\n try\n {\n setStatus(`API ${index + 1}/${apiConfigs.length}`);\n fullText = await requestChatCompletion(config, aiMessageDiv);\n messageHistory.push({ role: 'assistant', content: fullText });\n apiRoundRobinIndex = (index + 1) % apiConfigs.length;\n localStorage.setItem(STORAGE_KEYS.apiIndex, String(apiRoundRobinIndex));\n persistConversation();\n aiMessageDiv.innerHTML = sanitizeHtml(renderMarkdown(fullText));\n typesetElement(aiMessageDiv);\n setStatus(`已使用 API ${index + 1}`, 'recording');\n return;\n } catch (error)\n {\n errors.push(`API ${index + 1}: ${error.message}`);\n }\n }\n throw new Error(errors.join(';'));\n } catch (error)\n {\n aiMessageDiv.innerHTML = `<span style=\"color:#b91c1c\">Error: ${escapeHtml(error.message)}</span>`;\n setStatus('请求失败');\n } finally\n {\n sendBtn.disabled = false;\n }\n }\n\n async function requestChatCompletion(config, targetDiv)\n {\n const response = await fetch(config.url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.key}`\n },\n body: JSON.stringify({\n model: config.model || defaultModel,\n messages: messageHistory,\n stream: true\n })\n });\n\n if (!response.ok)\n {\n throw new Error(await readApiError(response));\n }\n\n if (!response.body)\n {\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let buffer = '';\n let fullText = '';\n\n while (true)\n {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() || '';\n\n for (const rawLine of lines)\n {\n const line = rawLine.trim();\n if (!line || !line.startsWith('data:')) continue;\n const jsonStr = line.slice(5).trim();\n if (jsonStr === '[DONE]') return fullText;\n try\n {\n const data = JSON.parse(jsonStr);\n const content = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || '';\n if (content)\n {\n fullText += content;\n targetDiv.textContent = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n }\n } catch (err)\n {\n console.warn('跳过无法解析的流式片段:', jsonStr);\n }\n }\n }\n\n return fullText;\n }\n\n async function readApiError(response)\n {\n const fallback = `HTTP ${response.status}`;\n try\n {\n const data = await response.json();\n return data.error?.message || data.message || fallback;\n } catch (err)\n {\n return fallback;\n }\n }\n\n function appendMessage(role, content, options = {})\n {\n const div = document.createElement('div');\n const cssRole = role === 'assistant' ? 'ai' : role;\n div.className = `message ${cssRole}`;\n if (options.rawHtml)\n {\n div.innerHTML = content;\n } else\n {\n div.innerHTML = renderMessageContent(role, content);\n }\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n typesetElement(div);\n return div;\n }\n\n function renderMessageContent(role, content)\n {\n if (role === 'assistant')\n {\n return sanitizeHtml(renderMarkdown(String(content || '')));\n }\n\n if (role === 'system')\n {\n return escapeHtml(stringifyContent(content)).replace(/\\n/g, '<br>');\n }\n\n const { text, images } = splitMessageContent(content);\n const textHtml = escapeHtml(text).replace(/\\n/g, '<br>');\n const imagesHtml = images.length\n ? `<div class=\"message-images\">${images.map((url, index) => `<img class=\"message-image\" src=\"${escapeAttribute(url)}\" alt=\"上传图片 ${index + 1}\">`).join('')}</div>`\n : '';\n return `${textHtml}${imagesHtml}`;\n }\n\n function splitMessageContent(content)\n {\n if (typeof content === 'string') return { text: content, images: [] };\n const result = { text: '', images: [] };\n if (!Array.isArray(content)) return { text: String(content || ''), images: [] };\n content.forEach((part) =>\n {\n if (part?.type === 'text') result.text += part.text || '';\n if (part?.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) result.images.push(url);\n }\n });\n return result;\n }\n\n function stringifyContent(content)\n {\n if (typeof content === 'string') return content;\n const { text } = splitMessageContent(content);\n return text || JSON.stringify(content);\n }\n\n function renderMarkdown(text)\n {\n if (!window.marked) return escapeHtml(text).replace(/\\n/g, '<br>');\n return marked.parse(text || '');\n }\n\n function sanitizeHtml(html)\n {\n const template = document.createElement('template');\n template.innerHTML = html;\n template.content.querySelectorAll('script, iframe, object, embed').forEach((node) => node.remove());\n template.content.querySelectorAll('*').forEach((node) =>\n {\n Array.from(node.attributes).forEach((attr) =>\n {\n const name = attr.name.toLowerCase();\n const value = attr.value.trim().toLowerCase();\n if (name.startsWith('on') || value.startsWith('javascript:'))\n {\n node.removeAttribute(attr.name);\n }\n });\n });\n return template.innerHTML;\n }\n\n function typesetElement(element)\n {\n if (!window.MathJax?.typesetPromise) return;\n renderPromise = renderPromise\n .then(() => MathJax.typesetPromise([element]))\n .then(() => { chatContainer.scrollTop = chatContainer.scrollHeight; })\n .catch((err) => console.warn('MathJax render error:', err));\n }\n\n async function syncToNutstore()\n {\n try\n {\n saveSettings();\n await putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2));\n setStatus('已同步到坚果云', 'recording');\n alert('已同步到坚果云');\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云同步失败');\n alert('同步到坚果云失败: ' + formatNutstoreError(err));\n }\n }\n\n async function syncFromNutstore()\n {\n try\n {\n saveSettings();\n const text = await getNutstoreFile();\n const payload = JSON.parse(text);\n const messages = parseConversationPayload(payload);\n if (!messages.length) throw new Error('远端文件没有可加载的消息');\n restoreConversation(messages);\n setStatus('已从坚果云拉取', 'recording');\n alert(`已从坚果云拉取 ${messages.length} 条消息`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云拉取失败');\n alert('从坚果云拉取失败: ' + formatNutstoreError(err));\n }\n }\n\n async function testNutstoreConnection()\n {\n try\n {\n saveSettings();\n const result = await checkNutstoreConnection();\n setStatus('坚果云连接正常', 'recording');\n alert(`坚果云连接正常:${result}`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云连接失败');\n alert('坚果云连接失败: ' + formatNutstoreError(err));\n }\n }\n\n function scheduleAutoSync()\n {\n if (!document.getElementById('nutstore-auto').checked) return;\n const config = getNutstoreConfig();\n if (!config.url || !config.user || !config.pass || !config.path) return;\n clearTimeout(autoSyncTimer);\n autoSyncTimer = setTimeout(() =>\n {\n putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2))\n .then(() => setStatus('已自动同步', 'recording'))\n .catch((err) => console.warn('自动同步失败:', err));\n }, 1200);\n }\n\n function getNutstoreConfig()\n {\n return {\n url: document.getElementById('nutstore-url').value.trim() || defaultNutstoreUrl,\n user: document.getElementById('nutstore-user').value.trim(),\n pass: document.getElementById('nutstore-pass').value.trim(),\n path: document.getElementById('nutstore-path').value.trim() || defaultNutstorePath\n };\n }\n\n function buildNutstoreUrl(config, pathOverride)\n {\n const base = config.url.replace(/\\/+$/, '');\n const path = (pathOverride || config.path).replace(/^\\/+/, '');\n const encodedPath = path.split('/').map(encodeURIComponent).join('/');\n return `${base}/${encodedPath}`;\n }\n\n function getNutstoreHeaders(config)\n {\n if (!config.user || !config.pass)\n {\n throw new Error('请填写坚果云账号和应用密码');\n }\n return {\n Authorization: `Basic ${btoa(unescape(encodeURIComponent(`${config.user}:${config.pass}`)))}`\n };\n }\n\n async function ensureNutstoreFolders(config, headers)\n {\n const parts = config.path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n let current = '';\n for (const part of parts)\n {\n current += `/${part}`;\n const response = await nutstoreFetch(buildNutstoreUrl(config, current), {\n method: 'MKCOL',\n headers\n }, '创建远端目录');\n if (![200, 201, 204, 405, 409].includes(response.status))\n {\n throw await createNutstoreHttpError(response, '创建远端目录失败');\n }\n }\n }\n\n async function putNutstoreFile(content)\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n await ensureNutstoreFolders(config, headers);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'PUT',\n headers: {\n ...headers,\n 'Content-Type': 'application/json; charset=utf-8'\n },\n body: content\n }, '上传坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '上传坚果云文件失败');\n }\n }\n\n async function getNutstoreFile()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const response = await nutstoreFetch(buildNutstoreUrl(config), {\n method: 'GET',\n headers\n }, '读取坚果云文件');\n if (!response.ok)\n {\n throw await createNutstoreHttpError(response, '读取坚果云文件失败');\n }\n return response.text();\n }\n\n async function checkNutstoreConnection()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const folderPath = getNutstoreParentPath(config.path);\n const response = await nutstoreFetch(buildNutstoreUrl(config, folderPath), {\n method: 'PROPFIND',\n headers: {\n ...headers,\n Depth: '0',\n 'Content-Type': 'application/xml; charset=utf-8'\n },\n body: '<?xml version=\"1.0\" encoding=\"utf-8\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:resourcetype/></d:prop></d:propfind>'\n }, '检测坚果云连接');\n\n if ([200, 207, 301, 302].includes(response.status))\n {\n return `HTTP ${response.status}`;\n }\n if (response.status === 404)\n {\n return '账号可连接,同步目录尚不存在,推送时会尝试创建';\n }\n throw await createNutstoreHttpError(response, '检测坚果云连接失败');\n }\n\n function getNutstoreParentPath(path)\n {\n const parts = path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n return parts.length ? `/${parts.join('/')}` : '/';\n }\n\n async function nutstoreFetch(url, options, action)\n {\n try\n {\n return await fetch(url, options);\n } catch (err)\n {\n const error = new Error(`${action}失败:浏览器无法访问坚果云 WebDAV。`);\n error.cause = err;\n error.isNetworkBlocked = true;\n throw error;\n }\n }\n\n async function createNutstoreHttpError(response, message)\n {\n let detail = '';\n try\n {\n detail = (await response.text()).replace(/\\s+/g, ' ').trim().slice(0, 160);\n } catch (err)\n {\n detail = '';\n }\n\n const error = new Error(`${message}: HTTP ${response.status}${detail ? ` ${detail}` : ''}`);\n error.status = response.status;\n return error;\n }\n\n function formatNutstoreError(err)\n {\n if (err?.isNetworkBlocked || err?.cause instanceof TypeError || /failed to fetch/i.test(err?.message || ''))\n {\n return '浏览器无法直接连接坚果云 WebDAV。常见原因是 CORS 跨域拦截、网络不可达或 WebDAV 地址不正确。请先点“检测坚果云连接”;如果仍失败,需要通过 MCP/本地后端/同源代理转发 WebDAV 请求。';\n }\n\n if (err?.status === 401 || err?.status === 403)\n {\n return `${err.message}。请检查坚果云账号和“第三方应用密码”,不是登录密码。`;\n }\n\n if (err?.status === 404)\n {\n return `${err.message}。远端文件不存在,可以先使用“同步到坚果云”创建。`;\n }\n\n return err?.message || String(err);\n }\n\n function setupMcpBridge()\n {\n window.MathChatMCP = {\n version: '1.0.0',\n tools: [\n 'chat.export',\n 'chat.import',\n 'chat.list',\n 'chat.load',\n 'chat.delete',\n 'chat.new',\n 'chat.clear',\n 'nutstore.test',\n 'nutstore.pull',\n 'nutstore.push',\n 'config.get',\n 'config.setNutstore'\n ],\n async call(method, params = {})\n {\n switch (method)\n {\n case 'chat.export':\n return buildConversationPayload();\n case 'chat.import':\n restoreConversation(parseConversationPayload(params.payload || params.messages || []));\n return { ok: true, count: messageHistory.length };\n case 'chat.list':\n return getSavedConversations().map((item) => ({\n id: item.id,\n title: item.title,\n createdAt: item.createdAt,\n updatedAt: item.updatedAt,\n count: item.messages.length,\n active: item.id === currentConversationId\n }));\n case 'chat.load':\n loadSavedConversation(params.id);\n return { ok: true, count: messageHistory.length };\n case 'chat.delete':\n deleteSavedConversation(params.id);\n return { ok: true };\n case 'chat.new':\n newConversation();\n return { ok: true, id: currentConversationId };\n case 'chat.clear':\n messageHistory = [];\n persistConversation();\n renderGreeting();\n return { ok: true };\n case 'nutstore.test':\n return { ok: true, result: await checkNutstoreConnection() };\n case 'nutstore.pull':\n await syncFromNutstore();\n return { ok: true, count: messageHistory.length };\n case 'nutstore.push':\n await syncToNutstore();\n return { ok: true, count: messageHistory.length };\n case 'config.get':\n return {\n apiCount: getApiConfigs().length,\n nutstore: { ...getNutstoreConfig(), pass: getNutstoreConfig().pass ? '***' : '' }\n };\n case 'config.setNutstore':\n if (params.url) document.getElementById('nutstore-url').value = params.url;\n if (params.user) document.getElementById('nutstore-user').value = params.user;\n if (params.pass) document.getElementById('nutstore-pass').value = params.pass;\n if (params.path) document.getElementById('nutstore-path').value = params.path;\n saveSettings();\n return { ok: true };\n default:\n throw new Error(`Unknown MCP method: ${method}`);\n }\n },\n async handle(request)\n {\n const id = request?.id ?? null;\n try\n {\n const result = await this.call(request.method, request.params || {});\n return { jsonrpc: '2.0', id, result };\n } catch (err)\n {\n return { jsonrpc: '2.0', id, error: { code: -32000, message: err.message } };\n }\n }\n };\n }\n\n function debounce(func, wait)\n {\n let timeout;\n return function (...args)\n {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n function escapeHtml(text)\n {\n return String(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '`');\n }\n\n function formatShortTime(value)\n {\n const date = value ? new Date(value) : new Date();\n if (Number.isNaN(date.getTime())) return '未知时间';\n const now = new Date();\n const sameYear = date.getFullYear() === now.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hour = String(date.getHours()).padStart(2, '0');\n const minute = String(date.getMinutes()).padStart(2, '0');\n return sameYear\n ? `${month}-${day} ${hour}:${minute}`\n : `${date.getFullYear()}-${month}-${day}`;\n }\n\n function formatTimestamp(date)\n {\n return date.toISOString().slice(0, 19).replace(/[-T:]/g, '');\n }\n </script>\n</body>\n\n</html>\n"}, "6ff3cfb60b2896338e6c176f3058d9cbb745a348": {"t": 1785765410.264276, "n": 62464, "h": "642daa7e51b372ab", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f6fb;\n --panel-color: #2c3e50;\n --panel-muted: #34495e;\n --primary-color: #3d7bd9;\n --primary-dark: #2764ba;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n --text-muted: #64748b;\n }\n\n * {\n box-sizing: border-box;\n touch-action: manipulation;\n }\n\n body,\n html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .settings-header {\n background: var(--panel-color);\n color: white;\n padding: 10px 15px;\n font-size: 0.92rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n\n .settings-title {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .settings-panel {\n background: var(--panel-muted);\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n max-height: 54vh;\n overflow-y: auto;\n }\n\n .settings-panel.show {\n display: flex;\n }\n\n .settings-row {\n display: flex;\n gap: 10px;\n width: 100%;\n flex-wrap: wrap;\n }\n\n .settings-group {\n display: flex;\n flex-direction: column;\n gap: 6px;\n flex: 1;\n min-width: 220px;\n }\n\n .settings-group label {\n color: #dbeafe;\n font-size: 0.78rem;\n font-weight: 700;\n }\n\n .settings-panel input,\n .settings-panel textarea {\n background: var(--panel-color);\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n font: inherit;\n font-size: 0.9rem;\n outline: none;\n }\n\n .settings-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n min-width: 16px;\n padding: 0;\n accent-color: var(--primary-color);\n }\n\n .checkbox-label {\n color: #e2e8f0;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 0.84rem;\n font-weight: 700;\n min-height: 38px;\n }\n\n .settings-panel textarea {\n min-height: 76px;\n resize: vertical;\n line-height: 1.45;\n }\n\n .settings-panel input::placeholder,\n .settings-panel textarea::placeholder {\n color: #b7c9d8;\n }\n\n .setting-note {\n color: #cbd5e1;\n font-size: 0.75rem;\n line-height: 1.35;\n }\n\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 220px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: 0;\n width: 100%;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n min-width: 42px;\n }\n\n .model-list-btn:hover {\n background: #3498db;\n }\n\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: var(--panel-color);\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0, 0, 0, 0.28);\n margin-top: 2px;\n }\n\n .model-dropdown.show {\n display: block;\n }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:hover {\n background: #3c5870;\n color: #dff2ff;\n }\n\n .model-option.loading {\n text-align: center;\n color: #bdc3c7;\n cursor: default;\n }\n\n .model-option.error {\n color: #fecaca;\n font-size: 0.8rem;\n cursor: default;\n }\n\n .action-btn {\n padding: 8px 13px;\n min-height: 38px;\n color: white;\n border: 0;\n border-radius: 4px;\n cursor: pointer;\n font-weight: 700;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n justify-content: center;\n }\n\n .save-btn {\n background: #238457;\n }\n\n .save-btn:hover {\n background: #2aa96d;\n }\n\n .export-btn {\n background: #ce6f18;\n }\n\n .export-btn:hover {\n background: #b85f0f;\n }\n\n .autosave-btn {\n background: #7f3a98;\n }\n\n .autosave-btn:hover {\n background: #9145ad;\n }\n\n .autosave-btn.active {\n background: #238457;\n }\n\n .import-btn {\n background: #118270;\n }\n\n .import-btn:hover {\n background: #159c88;\n }\n\n .sync-btn {\n background: #3366a8;\n }\n\n .sync-btn:hover {\n background: #3e78c4;\n }\n\n .danger-btn {\n background: #a33a3a;\n }\n\n .danger-btn:hover {\n background: #bf4646;\n }\n\n .status-indicator {\n font-size: 0.8rem;\n color: #bdc3c7;\n display: inline-flex;\n align-items: center;\n min-height: 18px;\n }\n\n .status-indicator.recording {\n color: #86efac;\n }\n\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: min(92%, 900px);\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n overflow-wrap: anywhere;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);\n }\n\n .message.user {\n align-self: flex-end;\n background: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background: white;\n color: #263238;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n .message.system {\n align-self: center;\n background: #fff7ed;\n color: #9a3412;\n border: 1px solid #fed7aa;\n max-width: min(92%, 720px);\n }\n\n .message.ai pre {\n background: #2d2d2d;\n color: #f8f8f2;\n padding: 10px;\n border-radius: 5px;\n overflow-x: auto;\n }\n\n .message.ai p {\n margin: 0 0 8px 0;\n }\n\n .message.ai p:last-child {\n margin: 0;\n }\n\n .message-images {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n gap: 8px;\n margin-top: 10px;\n }\n\n .message-image {\n width: 100%;\n max-height: 220px;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n background: rgba(255, 255, 255, 0.1);\n }\n\n .message.ai .message-image {\n border-color: var(--border-color);\n }\n\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative;\n }\n\n .math-preview-area {\n background: #fffdf5;\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none;\n }\n\n .math-preview-area.show {\n display: block;\n }\n\n .math-preview-area p {\n margin: 0;\n }\n\n .attachment-tray {\n display: none;\n gap: 8px;\n padding: 8px 10px;\n border-bottom: 1px solid #e5e7eb;\n overflow-x: auto;\n background: #f8fafc;\n }\n\n .attachment-tray.show {\n display: flex;\n }\n\n .attachment-thumb {\n position: relative;\n width: 70px;\n height: 70px;\n flex: 0 0 auto;\n }\n\n .attachment-thumb img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid #cbd5e1;\n display: block;\n }\n\n .attachment-thumb button {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 22px;\n height: 22px;\n border: 0;\n border-radius: 50%;\n background: #1f2937;\n color: white;\n cursor: pointer;\n line-height: 1;\n }\n\n .math-toolbar-container {\n background: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar {\n display: none;\n }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background: #e2e8f0;\n transform: translateY(1px);\n }\n\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n #attach-btn {\n width: 44px;\n height: 44px;\n border: 1px solid var(--border-color);\n background: #f8fafc;\n color: #334155;\n border-radius: 8px;\n cursor: pointer;\n flex-shrink: 0;\n font-weight: 800;\n font-size: 1.1rem;\n }\n\n #attach-btn:hover {\n background: #eef2f7;\n }\n\n textarea#user-input {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n color: #111827;\n }\n\n textarea#user-input:focus {\n border-color: var(--primary-color);\n }\n\n button#send-btn {\n background: var(--primary-color);\n color: white;\n border: 0;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: 700;\n cursor: pointer;\n flex-shrink: 0;\n }\n\n button#send-btn:hover {\n background: var(--primary-dark);\n }\n\n button#send-btn:disabled {\n background: #94a3b8;\n cursor: not-allowed;\n }\n\n @media (max-width: 640px) {\n .settings-group {\n min-width: 100%;\n }\n\n .settings-row {\n gap: 8px;\n }\n\n .message {\n max-width: 96%;\n }\n\n button#send-btn {\n padding: 0 14px;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"settings-header\" onclick=\"toggleSettings()\">\n <div class=\"settings-title\">\n <span>设置与同步</span>\n <span id=\"autosave-status\" class=\"status-indicator\"></span>\n </div>\n <span id=\"toggle-icon\">▼</span>\n </div>\n\n <div class=\"settings-panel\" id=\"settings-panel\">\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-url\">默认 API 地址</label>\n <input type=\"text\" id=\"api-url\" placeholder=\"https://api.openai.com/v1\">\n </div>\n <div class=\"settings-group\">\n <label for=\"api-key\">默认 API Key</label>\n <input type=\"password\" id=\"api-key\" placeholder=\"sk-...\">\n </div>\n <div class=\"settings-group\">\n <label for=\"model-name\">默认模型</label>\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"gpt-4o, deepseek-chat\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"api-list\">多 API 轮询列表</label>\n <textarea id=\"api-list\" placeholder=\"每行一个:API地址 | API Key | 模型(可选) https://api.openai.com/v1 | sk-xxx | gpt-4o https://api.deepseek.com/v1 | sk-yyy | deepseek-chat\"></textarea>\n <div class=\"setting-note\">发送时按轮询顺序选择接口;当前接口失败会自动尝试下一行。留空则使用上面的默认 API。</div>\n </div>\n </div>\n\n <div class=\"settings-row\">\n <div class=\"settings-group\">\n <label for=\"nutstore-url\">坚果云 WebDAV/MCP 地址</label>\n <input type=\"text\" id=\"nutstore-url\" placeholder=\"https://dav.jianguoyun.com/dav/\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-user\">坚果云账号</label>\n <input type=\"text\" id=\"nutstore-user\" placeholder=\"你的坚果云登录邮箱\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-pass\">坚果云应用密码</label>\n <input type=\"password\" id=\"nutstore-pass\" placeholder=\"坚果云第三方应用密码\">\n </div>\n <div class=\"settings-group\">\n <label for=\"nutstore-path\">同步文件路径</label>\n <input type=\"text\" id=\"nutstore-path\" placeholder=\"/MathAIChat/chat.json\">\n </div>\n </div>\n\n <div class=\"setting-note\">\n 已暴露浏览器端 MCP 桥接对象:window.MathChatMCP。坚果云同步使用 WebDAV;如果浏览器因 CORS 拦截,请用同源 MCP/WebDAV 代理转发。\n </div>\n\n <div class=\"settings-row\">\n <label class=\"checkbox-label\">\n <input type=\"checkbox\" id=\"nutstore-auto\">\n 启用坚果云自动同步\n </label>\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button id=\"btn-autosave\" class=\"action-btn autosave-btn\" onclick=\"setupAutoSave()\">启用本地自动保存</button>\n <input type=\"file\" id=\"import-file\" accept=\".json,application/json\" style=\"display:none\" onchange=\"handleFileImport(this)\">\n <button class=\"action-btn import-btn\" onclick=\"document.getElementById('import-file').click()\">加载对话</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncFromNutstore()\">从坚果云拉取</button>\n <button class=\"action-btn sync-btn\" onclick=\"syncToNutstore()\">同步到坚果云</button>\n <button class=\"action-btn danger-btn\" onclick=\"clearConversation()\">清空当前对话</button>\n </div>\n </div>\n\n <div id=\"chat-container\"></div>\n\n <div class=\"bottom-section\">\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n <div id=\"attachment-tray\" class=\"attachment-tray\"></div>\n\n <div class=\"math-toolbar-container\">\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <input type=\"file\" id=\"image-input\" accept=\"image/*\" multiple style=\"display:none\" onchange=\"handleImageUpload(this)\">\n <button id=\"attach-btn\" onclick=\"document.getElementById('image-input').click()\" title=\"上传图片\">+</button>\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n </div>\n\n <script>\n const STORAGE_KEYS = {\n apiUrl: 'api_url',\n apiKey: 'api_key',\n modelName: 'model_name',\n apiList: 'api_list',\n conversation: 'math_chat_conversation_v2',\n apiIndex: 'math_chat_api_index',\n nutstoreUrl: 'nutstore_url',\n nutstoreUser: 'nutstore_user',\n nutstorePass: 'nutstore_pass',\n nutstorePath: 'nutstore_path',\n nutstoreAuto: 'nutstore_auto_sync'\n };\n\n const defaultUrl = 'https://api.openai.com/v1';\n const defaultModel = 'gpt-4o';\n const defaultNutstoreUrl = 'https://dav.jianguoyun.com/dav/';\n const defaultNutstorePath = '/MathAIChat/chat.json';\n const maxImageBytes = 8 * 1024 * 1024;\n\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1 },\n { label: 'x', insert: 'x', offset: 0 },\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n const attachmentTray = document.getElementById('attachment-tray');\n\n let messageHistory = [];\n let pendingImages = [];\n let fileHandle = null;\n let isAutoSaveActive = false;\n let renderPromise = Promise.resolve();\n let apiRoundRobinIndex = Number(localStorage.getItem(STORAGE_KEYS.apiIndex) || 0);\n let autoSyncTimer = null;\n\n window.addEventListener('load', initApp);\n\n function initApp()\n {\n document.getElementById('api-url').value = localStorage.getItem(STORAGE_KEYS.apiUrl) || '';\n document.getElementById('api-key').value = localStorage.getItem(STORAGE_KEYS.apiKey) || '';\n document.getElementById('model-name').value = localStorage.getItem(STORAGE_KEYS.modelName) || defaultModel;\n document.getElementById('api-list').value = localStorage.getItem(STORAGE_KEYS.apiList) || '';\n document.getElementById('nutstore-url').value = localStorage.getItem(STORAGE_KEYS.nutstoreUrl) || defaultNutstoreUrl;\n document.getElementById('nutstore-user').value = localStorage.getItem(STORAGE_KEYS.nutstoreUser) || '';\n document.getElementById('nutstore-pass').value = localStorage.getItem(STORAGE_KEYS.nutstorePass) || '';\n document.getElementById('nutstore-path').value = localStorage.getItem(STORAGE_KEYS.nutstorePath) || defaultNutstorePath;\n document.getElementById('nutstore-auto').checked = localStorage.getItem(STORAGE_KEYS.nutstoreAuto) === 'true';\n\n if (window.marked)\n {\n marked.setOptions({\n highlight: (code, lang) =>\n {\n if (!window.hljs) return code;\n return hljs.highlight(code, { language: hljs.getLanguage(lang) ? lang : 'plaintext' }).value;\n }\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n setupGlobalClickHandlers();\n setupMcpBridge();\n loadConversationFromCache();\n }\n\n function setupGlobalClickHandlers()\n {\n document.addEventListener('click', (event) =>\n {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (dropdown && !dropdown.contains(event.target) && event.target !== btn)\n {\n dropdown.classList.remove('show');\n }\n });\n }\n\n function renderToolbar(containerId, symbols)\n {\n const container = document.getElementById(containerId);\n if (!container) return;\n container.innerHTML = '';\n symbols.forEach((sym) =>\n {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (event) => event.preventDefault();\n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n function insertSymbol(text, offsetBack)\n {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n function toggleSettings()\n {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n panel.classList.toggle('show');\n icon.textContent = panel.classList.contains('show') ? '▲' : '▼';\n }\n\n function saveSettings()\n {\n localStorage.setItem(STORAGE_KEYS.apiUrl, document.getElementById('api-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiKey, document.getElementById('api-key').value.trim());\n localStorage.setItem(STORAGE_KEYS.modelName, document.getElementById('model-name').value.trim());\n localStorage.setItem(STORAGE_KEYS.apiList, document.getElementById('api-list').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUrl, document.getElementById('nutstore-url').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreUser, document.getElementById('nutstore-user').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePass, document.getElementById('nutstore-pass').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstorePath, document.getElementById('nutstore-path').value.trim());\n localStorage.setItem(STORAGE_KEYS.nutstoreAuto, document.getElementById('nutstore-auto').checked ? 'true' : 'false');\n setStatus('配置已保存');\n }\n\n function setStatus(text, type = '')\n {\n const status = document.getElementById('autosave-status');\n status.textContent = text ? `● ${text}` : '';\n status.className = `status-indicator ${type}`;\n }\n\n async function setupAutoSave()\n {\n if (!('showDirectoryPicker' in window))\n {\n alert('您的浏览器不支持文件系统访问 API。请使用 Chrome/Edge 桌面版。');\n return;\n }\n\n try\n {\n const dirHandle = await window.showDirectoryPicker();\n const fileName = `chat_${formatTimestamp(new Date())}.json`;\n fileHandle = await dirHandle.getFileHandle(fileName, { create: true });\n isAutoSaveActive = true;\n const btn = document.getElementById('btn-autosave');\n btn.textContent = `正在保存:${fileName}`;\n btn.classList.add('active');\n setStatus('正在记录', 'recording');\n await writeToFile();\n } catch (err)\n {\n if (err.name !== 'AbortError')\n {\n console.error(err);\n alert('设置自动保存失败: ' + err.message);\n }\n }\n }\n\n async function writeToFile()\n {\n if (!fileHandle || !isAutoSaveActive) return;\n try\n {\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(buildConversationPayload(), null, 2));\n await writable.close();\n } catch (err)\n {\n console.error('自动保存写入失败:', err);\n setStatus('保存失败');\n }\n }\n\n function buildConversationPayload()\n {\n return {\n version: 2,\n app: 'Math AI Chat',\n exportedAt: new Date().toISOString(),\n messages: messageHistory\n };\n }\n\n function persistConversation()\n {\n localStorage.setItem(STORAGE_KEYS.conversation, JSON.stringify(buildConversationPayload()));\n writeToFile();\n scheduleAutoSync();\n }\n\n function loadConversationFromCache()\n {\n const raw = localStorage.getItem(STORAGE_KEYS.conversation);\n if (!raw)\n {\n renderGreeting();\n return;\n }\n\n try\n {\n const messages = parseConversationPayload(JSON.parse(raw));\n if (messages.length)\n {\n restoreConversation(messages, { silent: true });\n return;\n }\n } catch (err)\n {\n console.warn('本地缓存对话加载失败:', err);\n }\n\n renderGreeting();\n }\n\n function renderGreeting()\n {\n chatContainer.innerHTML = '';\n const div = document.createElement('div');\n div.className = 'message ai';\n div.innerHTML = '你好!我是你的数学助手。<br>你可以输入数学问题、上传图片,也可以从设置中导入或同步对话。';\n chatContainer.appendChild(div);\n }\n\n function handleFileImport(input)\n {\n const file = input.files[0];\n if (!file) return;\n\n const reader = new FileReader();\n reader.onload = (event) =>\n {\n try\n {\n const payload = JSON.parse(event.target.result);\n const loadedHistory = parseConversationPayload(payload);\n if (!loadedHistory.length)\n {\n throw new Error('文件中没有可加载的对话消息');\n }\n restoreConversation(loadedHistory);\n alert(`成功加载 ${loadedHistory.length} 条消息`);\n } catch (err)\n {\n alert('加载失败: ' + err.message);\n } finally\n {\n input.value = '';\n }\n };\n reader.onerror = () =>\n {\n alert('读取文件失败');\n input.value = '';\n };\n reader.readAsText(file, 'utf-8');\n }\n\n function parseConversationPayload(payload)\n {\n const source = Array.isArray(payload)\n ? payload\n : (payload?.messages || payload?.messageHistory || payload?.history || []);\n\n if (!Array.isArray(source))\n {\n throw new Error('文件格式错误:未找到 messages 数组');\n }\n\n return source\n .map(normalizeMessage)\n .filter(Boolean);\n }\n\n function normalizeMessage(msg)\n {\n if (!msg || typeof msg !== 'object') return null;\n const roleMap = { ai: 'assistant', bot: 'assistant', assistant: 'assistant', user: 'user', system: 'system' };\n const role = roleMap[msg.role] || 'user';\n const normalized = normalizeContent(msg.content ?? msg.text ?? '');\n if (typeof normalized === 'string' && normalized.length === 0) return null;\n if (Array.isArray(normalized) && normalized.length === 0) return null;\n return { role, content: normalized };\n }\n\n function normalizeContent(content)\n {\n if (typeof content === 'string') return content;\n if (!Array.isArray(content))\n {\n return typeof content === 'undefined' || content === null ? '' : String(content);\n }\n\n const parts = [];\n content.forEach((part) =>\n {\n if (!part) return;\n if (typeof part === 'string')\n {\n parts.push({ type: 'text', text: part });\n return;\n }\n if (part.type === 'text')\n {\n parts.push({ type: 'text', text: String(part.text || '') });\n return;\n }\n if (part.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n return;\n }\n if (part.type === 'image' || part.dataUrl)\n {\n const url = part.dataUrl || part.url || part.src;\n if (url) parts.push({ type: 'image_url', image_url: { url } });\n }\n });\n return parts;\n }\n\n function restoreConversation(messages, options = {})\n {\n messageHistory = messages.map((msg) => ({ role: msg.role, content: cloneContent(msg.content) }));\n renderConversation();\n persistConversation();\n if (!options.silent) setStatus('对话已加载', 'recording');\n }\n\n function cloneContent(content)\n {\n return JSON.parse(JSON.stringify(content));\n }\n\n function renderConversation()\n {\n chatContainer.innerHTML = '';\n if (!messageHistory.length)\n {\n renderGreeting();\n return;\n }\n messageHistory.forEach((msg) => appendMessage(msg.role, msg.content));\n typesetElement(chatContainer);\n }\n\n function exportChat()\n {\n if (!messageHistory.length)\n {\n alert('暂无对话记录可导出');\n return;\n }\n\n const dataStr = JSON.stringify(buildConversationPayload(), null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `math_chat_export_${formatTimestamp(new Date())}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n function clearConversation()\n {\n if (!confirm('确定要清空当前对话吗?')) return;\n messageHistory = [];\n localStorage.removeItem(STORAGE_KEYS.conversation);\n clearPendingImages();\n renderGreeting();\n setStatus('已清空');\n }\n\n function getApiConfigs()\n {\n const apiList = document.getElementById('api-list').value.trim();\n const globalModel = document.getElementById('model-name').value.trim() || defaultModel;\n const configs = [];\n\n if (apiList)\n {\n apiList.split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line && !line.startsWith('#'))\n .forEach((line) =>\n {\n const parts = line.includes('|')\n ? line.split('|').map((part) => part.trim())\n : line.split(',').map((part) => part.trim());\n const [url, key, model] = parts;\n if (url && key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: model || globalModel\n });\n }\n });\n }\n\n if (!configs.length)\n {\n const url = document.getElementById('api-url').value.trim() || defaultUrl;\n const key = document.getElementById('api-key').value.trim();\n if (key)\n {\n configs.push({\n url: normalizeChatUrl(url),\n key,\n model: globalModel\n });\n }\n }\n\n return configs;\n }\n\n function normalizeChatUrl(url)\n {\n let apiUrl = (url || defaultUrl).trim();\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n return apiUrl;\n }\n\n async function fetchAndShowModels(event)\n {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const configs = getApiConfigs();\n const config = configs[apiRoundRobinIndex % Math.max(configs.length, 1)] || configs[0];\n\n if (dropdown.classList.contains('show'))\n {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!config)\n {\n alert('请先填写 API Key 或多 API 轮询列表');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try\n {\n const modelsUrl = config.url.replace(/\\/chat\\/completions$/, '/models');\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${config.key}`,\n 'Content-Type': 'application/json'\n }\n });\n if (!response.ok) throw new Error(`HTTP ${response.status}`);\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => String(a.id).localeCompare(String(b.id)));\n dropdown.innerHTML = '';\n\n if (!models.length)\n {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach((model) =>\n {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () =>\n {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n } catch (error)\n {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${escapeHtml(error.message)}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function ()\n {\n this.style.height = 'auto';\n this.style.height = `${this.scrollHeight}px`;\n if (this.value === '') this.style.height = '44px';\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function (event)\n {\n if (event.key === 'Enter' && !event.shiftKey)\n {\n event.preventDefault();\n sendMessage();\n }\n });\n\n const updatePreview = debounce(function ()\n {\n const text = userInput.value;\n if (!text.trim())\n {\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n return;\n }\n previewArea.classList.add('show');\n previewArea.innerHTML = sanitizeHtml(renderMarkdown(text));\n typesetElement(previewArea);\n }, 250);\n\n async function handleImageUpload(input)\n {\n const files = Array.from(input.files || []);\n if (!files.length) return;\n\n for (const file of files)\n {\n if (!file.type.startsWith('image/'))\n {\n alert(`${file.name} 不是图片文件`);\n continue;\n }\n if (file.size > maxImageBytes)\n {\n alert(`${file.name} 超过 8MB,已跳过`);\n continue;\n }\n const dataUrl = await readFileAsDataUrl(file);\n pendingImages.push({\n name: file.name,\n type: file.type,\n size: file.size,\n dataUrl\n });\n }\n input.value = '';\n renderAttachmentTray();\n }\n\n function readFileAsDataUrl(file)\n {\n return new Promise((resolve, reject) =>\n {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(reader.error || new Error('图片读取失败'));\n reader.readAsDataURL(file);\n });\n }\n\n function renderAttachmentTray()\n {\n attachmentTray.innerHTML = '';\n attachmentTray.classList.toggle('show', pendingImages.length > 0);\n pendingImages.forEach((image, index) =>\n {\n const item = document.createElement('div');\n item.className = 'attachment-thumb';\n const img = document.createElement('img');\n img.src = image.dataUrl;\n img.alt = image.name || `图片 ${index + 1}`;\n const removeBtn = document.createElement('button');\n removeBtn.type = 'button';\n removeBtn.textContent = '×';\n removeBtn.title = '移除图片';\n removeBtn.onclick = () =>\n {\n pendingImages.splice(index, 1);\n renderAttachmentTray();\n };\n item.appendChild(img);\n item.appendChild(removeBtn);\n attachmentTray.appendChild(item);\n });\n }\n\n function clearPendingImages()\n {\n pendingImages = [];\n renderAttachmentTray();\n }\n\n function buildUserContent(text, images)\n {\n if (!images.length) return text;\n const parts = [];\n if (text) parts.push({ type: 'text', text });\n images.forEach((image) =>\n {\n parts.push({\n type: 'image_url',\n image_url: { url: image.dataUrl }\n });\n });\n return parts;\n }\n\n async function sendMessage()\n {\n const text = userInput.value.trim();\n const images = pendingImages.slice();\n const apiConfigs = getApiConfigs();\n\n if (!text && !images.length) return;\n if (!apiConfigs.length)\n {\n alert('请点击顶部设置填写 API Key,或添加多 API 轮询列表');\n if (!document.getElementById('settings-panel').classList.contains('show')) toggleSettings();\n return;\n }\n\n const userContent = buildUserContent(text, images);\n messageHistory.push({ role: 'user', content: userContent });\n appendMessage('user', userContent);\n persistConversation();\n\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n clearPendingImages();\n\n const aiMessageDiv = appendMessage('assistant', '<span class=\"typing\">思考中...</span>', { rawHtml: true });\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = '';\n const errors = [];\n const startIndex = apiRoundRobinIndex % apiConfigs.length;\n\n try\n {\n for (let attempt = 0; attempt < apiConfigs.length; attempt++)\n {\n const index = (startIndex + attempt) % apiConfigs.length;\n const config = apiConfigs[index];\n try\n {\n setStatus(`API ${index + 1}/${apiConfigs.length}`);\n fullText = await requestChatCompletion(config, aiMessageDiv);\n messageHistory.push({ role: 'assistant', content: fullText });\n apiRoundRobinIndex = (index + 1) % apiConfigs.length;\n localStorage.setItem(STORAGE_KEYS.apiIndex, String(apiRoundRobinIndex));\n persistConversation();\n aiMessageDiv.innerHTML = sanitizeHtml(renderMarkdown(fullText));\n typesetElement(aiMessageDiv);\n setStatus(`已使用 API ${index + 1}`, 'recording');\n return;\n } catch (error)\n {\n errors.push(`API ${index + 1}: ${error.message}`);\n }\n }\n throw new Error(errors.join(';'));\n } catch (error)\n {\n aiMessageDiv.innerHTML = `<span style=\"color:#b91c1c\">Error: ${escapeHtml(error.message)}</span>`;\n setStatus('请求失败');\n } finally\n {\n sendBtn.disabled = false;\n }\n }\n\n async function requestChatCompletion(config, targetDiv)\n {\n const response = await fetch(config.url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.key}`\n },\n body: JSON.stringify({\n model: config.model || defaultModel,\n messages: messageHistory,\n stream: true\n })\n });\n\n if (!response.ok)\n {\n throw new Error(await readApiError(response));\n }\n\n if (!response.body)\n {\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let buffer = '';\n let fullText = '';\n\n while (true)\n {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() || '';\n\n for (const rawLine of lines)\n {\n const line = rawLine.trim();\n if (!line || !line.startsWith('data:')) continue;\n const jsonStr = line.slice(5).trim();\n if (jsonStr === '[DONE]') return fullText;\n try\n {\n const data = JSON.parse(jsonStr);\n const content = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || '';\n if (content)\n {\n fullText += content;\n targetDiv.textContent = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n }\n } catch (err)\n {\n console.warn('跳过无法解析的流式片段:', jsonStr);\n }\n }\n }\n\n return fullText;\n }\n\n async function readApiError(response)\n {\n const fallback = `HTTP ${response.status}`;\n try\n {\n const data = await response.json();\n return data.error?.message || data.message || fallback;\n } catch (err)\n {\n return fallback;\n }\n }\n\n function appendMessage(role, content, options = {})\n {\n const div = document.createElement('div');\n const cssRole = role === 'assistant' ? 'ai' : role;\n div.className = `message ${cssRole}`;\n if (options.rawHtml)\n {\n div.innerHTML = content;\n } else\n {\n div.innerHTML = renderMessageContent(role, content);\n }\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n typesetElement(div);\n return div;\n }\n\n function renderMessageContent(role, content)\n {\n if (role === 'assistant')\n {\n return sanitizeHtml(renderMarkdown(String(content || '')));\n }\n\n if (role === 'system')\n {\n return escapeHtml(stringifyContent(content)).replace(/\\n/g, '<br>');\n }\n\n const { text, images } = splitMessageContent(content);\n const textHtml = escapeHtml(text).replace(/\\n/g, '<br>');\n const imagesHtml = images.length\n ? `<div class=\"message-images\">${images.map((url, index) => `<img class=\"message-image\" src=\"${escapeAttribute(url)}\" alt=\"上传图片 ${index + 1}\">`).join('')}</div>`\n : '';\n return `${textHtml}${imagesHtml}`;\n }\n\n function splitMessageContent(content)\n {\n if (typeof content === 'string') return { text: content, images: [] };\n const result = { text: '', images: [] };\n if (!Array.isArray(content)) return { text: String(content || ''), images: [] };\n content.forEach((part) =>\n {\n if (part?.type === 'text') result.text += part.text || '';\n if (part?.type === 'image_url')\n {\n const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;\n if (url) result.images.push(url);\n }\n });\n return result;\n }\n\n function stringifyContent(content)\n {\n if (typeof content === 'string') return content;\n const { text } = splitMessageContent(content);\n return text || JSON.stringify(content);\n }\n\n function renderMarkdown(text)\n {\n if (!window.marked) return escapeHtml(text).replace(/\\n/g, '<br>');\n return marked.parse(text || '');\n }\n\n function sanitizeHtml(html)\n {\n const template = document.createElement('template');\n template.innerHTML = html;\n template.content.querySelectorAll('script, iframe, object, embed').forEach((node) => node.remove());\n template.content.querySelectorAll('*').forEach((node) =>\n {\n Array.from(node.attributes).forEach((attr) =>\n {\n const name = attr.name.toLowerCase();\n const value = attr.value.trim().toLowerCase();\n if (name.startsWith('on') || value.startsWith('javascript:'))\n {\n node.removeAttribute(attr.name);\n }\n });\n });\n return template.innerHTML;\n }\n\n function typesetElement(element)\n {\n if (!window.MathJax?.typesetPromise) return;\n renderPromise = renderPromise\n .then(() => MathJax.typesetPromise([element]))\n .then(() => { chatContainer.scrollTop = chatContainer.scrollHeight; })\n .catch((err) => console.warn('MathJax render error:', err));\n }\n\n async function syncToNutstore()\n {\n try\n {\n saveSettings();\n await putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2));\n setStatus('已同步到坚果云', 'recording');\n alert('已同步到坚果云');\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云同步失败');\n alert('同步到坚果云失败: ' + err.message);\n }\n }\n\n async function syncFromNutstore()\n {\n try\n {\n saveSettings();\n const text = await getNutstoreFile();\n const payload = JSON.parse(text);\n const messages = parseConversationPayload(payload);\n if (!messages.length) throw new Error('远端文件没有可加载的消息');\n restoreConversation(messages);\n setStatus('已从坚果云拉取', 'recording');\n alert(`已从坚果云拉取 ${messages.length} 条消息`);\n } catch (err)\n {\n console.error(err);\n setStatus('坚果云拉取失败');\n alert('从坚果云拉取失败: ' + err.message);\n }\n }\n\n function scheduleAutoSync()\n {\n if (!document.getElementById('nutstore-auto').checked) return;\n const config = getNutstoreConfig();\n if (!config.url || !config.user || !config.pass || !config.path) return;\n clearTimeout(autoSyncTimer);\n autoSyncTimer = setTimeout(() =>\n {\n putNutstoreFile(JSON.stringify(buildConversationPayload(), null, 2))\n .then(() => setStatus('已自动同步', 'recording'))\n .catch((err) => console.warn('自动同步失败:', err));\n }, 1200);\n }\n\n function getNutstoreConfig()\n {\n return {\n url: document.getElementById('nutstore-url').value.trim() || defaultNutstoreUrl,\n user: document.getElementById('nutstore-user').value.trim(),\n pass: document.getElementById('nutstore-pass').value.trim(),\n path: document.getElementById('nutstore-path').value.trim() || defaultNutstorePath\n };\n }\n\n function buildNutstoreUrl(config, pathOverride)\n {\n const base = config.url.replace(/\\/+$/, '');\n const path = (pathOverride || config.path).replace(/^\\/+/, '');\n const encodedPath = path.split('/').map(encodeURIComponent).join('/');\n return `${base}/${encodedPath}`;\n }\n\n function getNutstoreHeaders(config)\n {\n if (!config.user || !config.pass)\n {\n throw new Error('请填写坚果云账号和应用密码');\n }\n return {\n Authorization: `Basic ${btoa(unescape(encodeURIComponent(`${config.user}:${config.pass}`)))}`\n };\n }\n\n async function ensureNutstoreFolders(config, headers)\n {\n const parts = config.path.replace(/^\\/+/, '').split('/').filter(Boolean);\n parts.pop();\n let current = '';\n for (const part of parts)\n {\n current += `/${part}`;\n const response = await fetch(buildNutstoreUrl(config, current), {\n method: 'MKCOL',\n headers\n });\n if (![200, 201, 204, 405, 409].includes(response.status))\n {\n throw new Error(`创建远端目录失败: HTTP ${response.status}`);\n }\n }\n }\n\n async function putNutstoreFile(content)\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n await ensureNutstoreFolders(config, headers);\n const response = await fetch(buildNutstoreUrl(config), {\n method: 'PUT',\n headers: {\n ...headers,\n 'Content-Type': 'application/json; charset=utf-8'\n },\n body: content\n });\n if (!response.ok)\n {\n throw new Error(`HTTP ${response.status}`);\n }\n }\n\n async function getNutstoreFile()\n {\n const config = getNutstoreConfig();\n const headers = getNutstoreHeaders(config);\n const response = await fetch(buildNutstoreUrl(config), {\n method: 'GET',\n headers\n });\n if (!response.ok)\n {\n throw new Error(`HTTP ${response.status}`);\n }\n return response.text();\n }\n\n function setupMcpBridge()\n {\n window.MathChatMCP = {\n version: '1.0.0',\n tools: [\n 'chat.export',\n 'chat.import',\n 'chat.clear',\n 'nutstore.pull',\n 'nutstore.push',\n 'config.get',\n 'config.setNutstore'\n ],\n async call(method, params = {})\n {\n switch (method)\n {\n case 'chat.export':\n return buildConversationPayload();\n case 'chat.import':\n restoreConversation(parseConversationPayload(params.payload || params.messages || []));\n return { ok: true, count: messageHistory.length };\n case 'chat.clear':\n messageHistory = [];\n persistConversation();\n renderGreeting();\n return { ok: true };\n case 'nutstore.pull':\n await syncFromNutstore();\n return { ok: true, count: messageHistory.length };\n case 'nutstore.push':\n await syncToNutstore();\n return { ok: true, count: messageHistory.length };\n case 'config.get':\n return {\n apiCount: getApiConfigs().length,\n nutstore: { ...getNutstoreConfig(), pass: getNutstoreConfig().pass ? '***' : '' }\n };\n case 'config.setNutstore':\n if (params.url) document.getElementById('nutstore-url').value = params.url;\n if (params.user) document.getElementById('nutstore-user').value = params.user;\n if (params.pass) document.getElementById('nutstore-pass').value = params.pass;\n if (params.path) document.getElementById('nutstore-path').value = params.path;\n saveSettings();\n return { ok: true };\n default:\n throw new Error(`Unknown MCP method: ${method}`);\n }\n },\n async handle(request)\n {\n const id = request?.id ?? null;\n try\n {\n const result = await this.call(request.method, request.params || {});\n return { jsonrpc: '2.0', id, result };\n } catch (err)\n {\n return { jsonrpc: '2.0', id, error: { code: -32000, message: err.message } };\n }\n }\n };\n }\n\n function debounce(func, wait)\n {\n let timeout;\n return function (...args)\n {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n function escapeHtml(text)\n {\n return String(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '`');\n }\n\n function formatTimestamp(date)\n {\n return date.toISOString().slice(0, 19).replace(/[-T:]/g, '');\n }\n </script>\n</body>\n\n</html>\n"}, "ea3a7c2b13bdd8f57bc8e1c0c8b83ca92f8d7b08": {"t": 1785765411.7398937, "n": 30300, "h": "bca6175a0f954dec", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat (Load & Save)</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input[type=\"text\"], \n .settings-panel input[type=\"password\"] {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n display: flex;\n align-items: center;\n gap: 5px;\n justify-content: center;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n .autosave-btn { background: #8e44ad; }\n .autosave-btn:hover { background: #9b59b6; }\n .autosave-btn.active { background: #2ecc71; pointer-events: none; }\n\n /* 新增:加载按钮样式 */\n .import-btn { background: #16a085; }\n .import-btn:hover { background: #1abc9c; }\n \n .status-indicator {\n font-size: 0.8rem;\n margin-left: 10px;\n color: #bdc3c7;\n display: flex;\n align-items: center;\n }\n .status-indicator.recording { color: #2ecc71; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative; /* 为了定位预览区 */\n }\n\n /* 实时预览区样式 */\n .math-preview-area {\n background: #fffdf5; /* 淡淡的黄色背景,区分输入区 */\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none; /* 默认隐藏 */\n box-shadow: inset 0 -2px 4px rgba(0,0,0,0.02);\n }\n\n .math-preview-area.show {\n display: block;\n animation: fadeIn 0.2s ease-in-out;\n }\n \n .math-preview-area p { margin: 0; }\n\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <div style=\"display:flex; align-items:center;\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"autosave-status\" class=\"status-indicator\"></span>\n </div>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\"></div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n \n <!-- 自动保存按钮 -->\n <button id=\"btn-autosave\" class=\"action-btn autosave-btn\" onclick=\"setupAutoSave()\">\n <span>📂</span> 启用自动保存\n </button>\n \n <!-- 新增:加载对话按钮 (触发隐藏的 file input) -->\n <input type=\"file\" id=\"import-file\" accept=\".json\" style=\"display:none\" onchange=\"handleFileImport(this)\">\n <button class=\"action-btn import-btn\" onclick=\"document.getElementById('import-file').click()\">\n <span>📥</span> 加载对话\n </button>\n\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br><b>提示:</b>你现在可以点击设置栏中的“加载对话”来恢复之前的聊天记录。\n </div>\n</div>\n\n<!-- 底部区域:预览区 + 工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <!-- 实时预览区 -->\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1},\n { label: 'x', insert: 'x', offset: 0},\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n \n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input')); \n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n \n // --- 自动保存相关变量 ---\n let fileHandle = null;\n let isAutoSaveActive = false;\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 自动保存功能 ---\n async function setupAutoSave() {\n if (!('showDirectoryPicker' in window)) {\n alert('您的浏览器不支持文件系统访问 API。请使用 Chrome/Edge 桌面版。');\n return;\n }\n\n try {\n const dirHandle = await window.showDirectoryPicker();\n const now = new Date();\n const timestamp = now.getFullYear() +\n String(now.getMonth() + 1).padStart(2, '0') +\n String(now.getDate()).padStart(2, '0') + '_' +\n String(now.getHours()).padStart(2, '0') +\n String(now.getMinutes()).padStart(2, '0') +\n String(now.getSeconds()).padStart(2, '0');\n const fileName = `chat_${timestamp}.json`;\n\n fileHandle = await dirHandle.getFileHandle(fileName, { create: true });\n \n isAutoSaveActive = true;\n const btn = document.getElementById('btn-autosave');\n btn.innerHTML = `<span>✅</span> ${fileName}`;\n btn.classList.add('active');\n \n const status = document.getElementById('autosave-status');\n status.textContent = \"● 正在记录\";\n status.classList.add('recording');\n\n await writeToFile();\n alert(`自动保存已开启!文件: ${fileName}`);\n\n } catch (err) {\n if (err.name !== 'AbortError') {\n console.error(err);\n alert('设置自动保存失败: ' + err.message);\n }\n }\n }\n\n async function writeToFile() {\n if (!fileHandle || !isAutoSaveActive) return;\n try {\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(messageHistory, null, 2));\n await writable.close();\n } catch (err) {\n console.error('自动保存写入失败:', err);\n const status = document.getElementById('autosave-status');\n status.textContent = \"● 保存失败\";\n status.style.color = \"red\";\n }\n }\n\n // --- 新增:加载对话功能 ---\n function handleFileImport(input) {\n const file = input.files[0];\n if (!file) return;\n\n const reader = new FileReader();\n reader.onload = function(e) {\n try {\n const loadedHistory = JSON.parse(e.target.result);\n \n if (!Array.isArray(loadedHistory)) {\n throw new Error(\"文件格式错误:内容不是数组\");\n }\n\n // 1. 清空当前界面\n chatContainer.innerHTML = '';\n messageHistory = [];\n\n // 2. 遍历并渲染消息\n loadedHistory.forEach(msg => {\n if (!msg.role || !msg.content) return;\n \n messageHistory.push(msg);\n \n let contentHtml = \"\";\n if (msg.role === 'assistant') {\n // AI 消息需要 Markdown 解析\n contentHtml = marked.parse(msg.content);\n } else {\n // 用户消息通常是纯文本,但也可能包含 LaTeX\n // 简单转义 HTML 标签防止 XSS,但保留 LaTeX 符号\n contentHtml = msg.content.replace(/</g, \"<\").replace(/>/g, \">\");\n }\n \n appendMessage(msg.role === 'assistant' ? 'ai' : 'user', contentHtml);\n });\n\n // 3. 重新渲染所有数学公式\n MathJax.typesetPromise([chatContainer]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n // 4. 如果开启了自动保存,立即将加载的内容写入当前自动保存文件\n if (isAutoSaveActive) {\n writeToFile();\n }\n\n alert(`成功加载 ${loadedHistory.length} 条消息`);\n \n // 关闭设置面板\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n\n } catch (err) {\n alert(\"加载失败: \" + err.message);\n }\n };\n reader.readAsText(file);\n input.value = ''; // 重置 input,允许重复选择同一文件\n }\n\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_export_${timestamp}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n function debounce(func, wait) {\n let timeout;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n let renderPromise = Promise.resolve();\n\n const updatePreview = debounce(function() {\n const text = userInput.value;\n if (!text.trim()) {\n previewArea.classList.remove('show');\n return;\n }\n previewArea.classList.add('show');\n previewArea.innerHTML = marked.parse(text);\n renderPromise = renderPromise.then(() => {\n return MathJax.typesetPromise([previewArea]);\n }).catch((err) => {\n console.log('MathJax render error: ', err);\n });\n\n }, 300);\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text); // 用户消息直接显示文本,appendMessage 内部会处理\n \n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n \n messageHistory.push({ role: \"user\", content: text });\n \n await writeToFile();\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n \n await writeToFile();\n\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n // 仅对用户消息进行 MathJax 渲染(AI 消息在流式传输结束后统一渲染)\n if (role === 'user') {\n MathJax.typesetPromise([div]);\n }\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "b35ad08f5e92173d3b3ff7e63b3c00b468f2d01b": {"t": 1785765413.3084202, "n": 25126, "h": "280d38cc12d1d948", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative; /* 为了定位预览区 */\n }\n\n /* ======== 新增:实时预览区样式 ======== */\n .math-preview-area {\n background: #fffdf5; /* 淡淡的黄色背景,区分输入区 */\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none; /* 默认隐藏 */\n box-shadow: inset 0 -2px 4px rgba(0,0,0,0.02);\n }\n\n .math-preview-area.show {\n display: block;\n animation: fadeIn 0.2s ease-in-out;\n }\n \n .math-preview-area p { margin: 0; }\n\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n /* ====================================== */\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:预览区 + 工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <!-- 新增:实时预览区 -->\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$$', offset: 1},\n { label: 'x', insert: 'x', offset: 0},\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n \n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input')); // 触发输入事件以更新预览\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n // --- 新增:防抖函数,防止输入时频繁渲染卡顿 ---\n function debounce(func, wait) {\n let timeout;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n // --- 新增:渲染预览逻辑 ---\n let renderPromise = Promise.resolve(); // 用于串行化 MathJax 渲染任务\n\n const updatePreview = debounce(function() {\n const text = userInput.value;\n \n // 如果输入为空,隐藏预览区\n if (!text.trim()) {\n previewArea.classList.remove('show');\n return;\n }\n\n // 显示预览区\n previewArea.classList.add('show');\n \n // 1. 先用 marked 解析 Markdown (处理普通文本格式)\n // 注意:我们只渲染文本,不执行高亮,以保持预览轻量\n previewArea.innerHTML = marked.parse(text);\n\n // 2. 调用 MathJax 渲染 LaTeX\n // 使用 Promise 链确保上一次渲染完成后再进行下一次,防止崩溃\n renderPromise = renderPromise.then(() => {\n return MathJax.typesetPromise([previewArea]);\n }).catch((err) => {\n console.log('MathJax render error: ', err);\n });\n\n }, 300); // 延迟 300ms 执行\n\n userInput.addEventListener('input', function() {\n // 自动调整高度\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n \n // 触发预览更新\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n \n // 发送后清空输入框和预览区\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n \n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n // 对用户发送的消息也进行一次渲染(如果用户手动输入了LaTeX)\n if (role === 'user') {\n MathJax.typesetPromise([div]);\n }\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "6941425ceb6e7f01dd0590c6e9031373507c721e": {"t": 1785765414.6475604, "n": 25125, "h": "813acfefd2fe4884", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative; /* 为了定位预览区 */\n }\n\n /* ======== 新增:实时预览区样式 ======== */\n .math-preview-area {\n background: #fffdf5; /* 淡淡的黄色背景,区分输入区 */\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none; /* 默认隐藏 */\n box-shadow: inset 0 -2px 4px rgba(0,0,0,0.02);\n }\n\n .math-preview-area.show {\n display: block;\n animation: fadeIn 0.2s ease-in-out;\n }\n \n .math-preview-area p { margin: 0; }\n\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n /* ====================================== */\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:预览区 + 工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <!-- 新增:实时预览区 -->\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '\\'', insert: '\\'', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$', offset: 0},\n { label: 'x', insert: 'x', offset: 0},\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n \n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input')); // 触发输入事件以更新预览\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n // --- 新增:防抖函数,防止输入时频繁渲染卡顿 ---\n function debounce(func, wait) {\n let timeout;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n // --- 新增:渲染预览逻辑 ---\n let renderPromise = Promise.resolve(); // 用于串行化 MathJax 渲染任务\n\n const updatePreview = debounce(function() {\n const text = userInput.value;\n \n // 如果输入为空,隐藏预览区\n if (!text.trim()) {\n previewArea.classList.remove('show');\n return;\n }\n\n // 显示预览区\n previewArea.classList.add('show');\n \n // 1. 先用 marked 解析 Markdown (处理普通文本格式)\n // 注意:我们只渲染文本,不执行高亮,以保持预览轻量\n previewArea.innerHTML = marked.parse(text);\n\n // 2. 调用 MathJax 渲染 LaTeX\n // 使用 Promise 链确保上一次渲染完成后再进行下一次,防止崩溃\n renderPromise = renderPromise.then(() => {\n return MathJax.typesetPromise([previewArea]);\n }).catch((err) => {\n console.log('MathJax render error: ', err);\n });\n\n }, 300); // 延迟 300ms 执行\n\n userInput.addEventListener('input', function() {\n // 自动调整高度\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n \n // 触发预览更新\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n \n // 发送后清空输入框和预览区\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n \n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n // 对用户发送的消息也进行一次渲染(如果用户手动输入了LaTeX)\n if (role === 'user') {\n MathJax.typesetPromise([div]);\n }\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "bc1b8f2964c14cb36c5ee8a51225c69d1060b300": {"t": 1785765418.3797424, "n": 25075, "h": "abe6da633b28c8cf", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative; /* 为了定位预览区 */\n }\n\n /* ======== 新增:实时预览区样式 ======== */\n .math-preview-area {\n background: #fffdf5; /* 淡淡的黄色背景,区分输入区 */\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none; /* 默认隐藏 */\n box-shadow: inset 0 -2px 4px rgba(0,0,0,0.02);\n }\n\n .math-preview-area.show {\n display: block;\n animation: fadeIn 0.2s ease-in-out;\n }\n \n .math-preview-area p { margin: 0; }\n\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n /* ====================================== */\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:预览区 + 工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <!-- 新增:实时预览区 -->\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$', offset: 0},\n { label: 'x', insert: 'x', offset: 0},\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n \n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input')); // 触发输入事件以更新预览\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n // --- 新增:防抖函数,防止输入时频繁渲染卡顿 ---\n function debounce(func, wait) {\n let timeout;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n // --- 新增:渲染预览逻辑 ---\n let renderPromise = Promise.resolve(); // 用于串行化 MathJax 渲染任务\n\n const updatePreview = debounce(function() {\n const text = userInput.value;\n \n // 如果输入为空,隐藏预览区\n if (!text.trim()) {\n previewArea.classList.remove('show');\n return;\n }\n\n // 显示预览区\n previewArea.classList.add('show');\n \n // 1. 先用 marked 解析 Markdown (处理普通文本格式)\n // 注意:我们只渲染文本,不执行高亮,以保持预览轻量\n previewArea.innerHTML = marked.parse(text);\n\n // 2. 调用 MathJax 渲染 LaTeX\n // 使用 Promise 链确保上一次渲染完成后再进行下一次,防止崩溃\n renderPromise = renderPromise.then(() => {\n return MathJax.typesetPromise([previewArea]);\n }).catch((err) => {\n console.log('MathJax render error: ', err);\n });\n\n }, 300); // 延迟 300ms 执行\n\n userInput.addEventListener('input', function() {\n // 自动调整高度\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n \n // 触发预览更新\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n \n // 发送后清空输入框和预览区\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n \n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n // 对用户发送的消息也进行一次渲染(如果用户手动输入了LaTeX)\n if (role === 'user') {\n MathJax.typesetPromise([div]);\n }\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "990c49b886fd0395183f8b2907fcae18b022b221": {"t": 1785765419.8561034, "n": 25028, "h": "24d1620076e0d990", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n position: relative; /* 为了定位预览区 */\n }\n\n /* ======== 新增:实时预览区样式 ======== */\n .math-preview-area {\n background: #fffdf5; /* 淡淡的黄色背景,区分输入区 */\n border-bottom: 1px solid #e0e0e0;\n padding: 8px 15px;\n font-size: 0.9rem;\n color: #555;\n max-height: 120px;\n overflow-y: auto;\n display: none; /* 默认隐藏 */\n box-shadow: inset 0 -2px 4px rgba(0,0,0,0.02);\n }\n\n .math-preview-area.show {\n display: block;\n animation: fadeIn 0.2s ease-in-out;\n }\n \n .math-preview-area p { margin: 0; }\n\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(5px); }\n to { opacity: 1; transform: translateY(0); }\n }\n /* ====================================== */\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:预览区 + 工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <!-- 新增:实时预览区 -->\n <div id=\"math-preview\" class=\"math-preview-area\"></div>\n\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 },\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 }\n ];\n\n const funcSymbols = [\n { label: '$', insert: '$', offset: 0},\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const previewArea = document.getElementById('math-preview');\n \n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input')); // 触发输入事件以更新预览\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n // --- 新增:防抖函数,防止输入时频繁渲染卡顿 ---\n function debounce(func, wait) {\n let timeout;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => func.apply(this, args), wait);\n };\n }\n\n // --- 新增:渲染预览逻辑 ---\n let renderPromise = Promise.resolve(); // 用于串行化 MathJax 渲染任务\n\n const updatePreview = debounce(function() {\n const text = userInput.value;\n \n // 如果输入为空,隐藏预览区\n if (!text.trim()) {\n previewArea.classList.remove('show');\n return;\n }\n\n // 显示预览区\n previewArea.classList.add('show');\n \n // 1. 先用 marked 解析 Markdown (处理普通文本格式)\n // 注意:我们只渲染文本,不执行高亮,以保持预览轻量\n previewArea.innerHTML = marked.parse(text);\n\n // 2. 调用 MathJax 渲染 LaTeX\n // 使用 Promise 链确保上一次渲染完成后再进行下一次,防止崩溃\n renderPromise = renderPromise.then(() => {\n return MathJax.typesetPromise([previewArea]);\n }).catch((err) => {\n console.log('MathJax render error: ', err);\n });\n\n }, 300); // 延迟 300ms 执行\n\n userInput.addEventListener('input', function() {\n // 自动调整高度\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n \n // 触发预览更新\n updatePreview();\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n \n // 发送后清空输入框和预览区\n userInput.value = '';\n userInput.style.height = '44px';\n previewArea.classList.remove('show');\n previewArea.innerHTML = '';\n \n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n // 对用户发送的消息也进行一次渲染(如果用户手动输入了LaTeX)\n if (role === 'user') {\n MathJax.typesetPromise([div]);\n }\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "f50c792ee206a6bbf463f56b4d9f16da980ff12f": {"t": 1785765421.1814647, "n": 22862, "h": "c5c89ca75029a7c3", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n userInput.value = '';\n userInput.style.height = '44px';\n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "50d47beae8ca4ee40d402a6343c4ae82e4088ed2": {"t": 1785765422.9443681, "n": 64146, "h": "fb5689c4bc254bf9", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column; /* 改为列布局以适应移动端更好 */\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* --- 新增:模型选择器样式 --- */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n /* 滚动条样式 */\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n /* ------------------------- */\n\n .save-btn {\n padding: 8px 15px;\n background: #27ae60;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n }\n .save-btn:hover { background: #2ecc71; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar {\n display: flex;\n gap: 8px;\n padding: 8px 10px;\n background-color: var(--toolbar-bg);\n overflow-x: auto;\n white-space: nowrap;\n border-bottom: 1px solid #e0e0e0;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .math-toolbar::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 修改后的模型输入区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n userInput.value = '';\n userInput.style.height = '44px';\n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "8fddaaf4ffcc1b2c6867cc3a32b92bddb885883b": {"t": 1785765424.5497854, "n": 51845, "h": "116ddd9c8f0585ee", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column; /* 改为列布局以适应移动端更好 */\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* --- 新增:模型选择器样式 --- */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n /* 滚动条样式 */\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n /* ------------------------- */\n\n .save-btn {\n padding: 8px 15px;\n background: #27ae60;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n }\n .save-btn:hover { background: #2ecc71; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar {\n display: flex;\n gap: 8px;\n padding: 8px 10px;\n background-color: var(--toolbar-bg);\n overflow-x: auto;\n white-space: nowrap;\n border-bottom: 1px solid #e0e0e0;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .math-toolbar::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 修改后的模型输入区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n userInput.value = '';\n userInput.style.height = '44px';\n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "8a88f6f7ac3a463de0dc372322859e232060b74e": {"t": 1785765425.9036303, "n": 32496, "h": "7a79fca49678f9a8", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column; /* 改为列布局以适应移动端更好 */\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* --- 新增:模型选择器样式 --- */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n /* 滚动条样式 */\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n /* ------------------------- */\n\n .save-btn {\n padding: 8px 15px;\n background: #27ae60;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n }\n .save-btn:hover { background: #2ecc71; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar {\n display: flex;\n gap: 8px;\n padding: 8px 10px;\n background-color: var(--toolbar-bg);\n overflow-x: auto;\n white-space: nowrap;\n border-bottom: 1px solid #e0e0e0;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .math-toolbar::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 修改后的模型输入区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column;\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* 模型选择器样式 */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n\n /* 按钮通用样式 */\n .action-btn {\n padding: 8px 15px;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n white-space: nowrap;\n }\n\n .save-btn { background: #27ae60; }\n .save-btn:hover { background: #2ecc71; }\n\n .export-btn { background: #e67e22; }\n .export-btn:hover { background: #d35400; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar-container {\n background-color: var(--toolbar-bg);\n border-bottom: 1px solid #e0e0e0;\n display: flex;\n flex-direction: column;\n }\n\n .math-toolbar-row {\n display: flex;\n gap: 8px;\n padding: 6px 10px;\n overflow-x: auto;\n white-space: nowrap;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n \n /* 给第一行加个分割线,视觉上区分 */\n .math-toolbar-row:first-child {\n border-bottom: 1px solid #e2e8f0;\n }\n\n .math-toolbar-row::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 32px; /* 保证小符号好点 */\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 模型选择区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"action-btn save-btn\" onclick=\"saveSettings()\">保存配置</button>\n <!-- 新增:导出对话按钮 -->\n <button class=\"action-btn export-btn\" onclick=\"exportChat()\">导出对话</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar-container\">\n <!-- 第一行:基础符号 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-basic\"></div>\n <!-- 第二行:高级函数 -->\n <div class=\"math-toolbar-row\" id=\"math-toolbar-funcs\"></div>\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n \n // 新增的基础符号\n const basicSymbols = [\n { label: '+', insert: '+', offset: 0 },\n { label: '-', insert: '-', offset: 0 },\n { label: '*', insert: '*', offset: 0 }, // 输入 *,AI 通常能识别为乘法\n { label: '/', insert: '/', offset: 0 },\n { label: '=', insert: '=', offset: 0 },\n { label: '%', insert: '%', offset: 0 },\n { label: '(', insert: '(', offset: 0 },\n { label: ')', insert: ')', offset: 0 },\n { label: '^', insert: '^', offset: 0 },\n { label: '→', insert: '\\\\to ', offset: 0 } // 箭头通常用 LaTeX\n ];\n\n // 原有的高级符号\n const funcSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n \n // 渲染工具栏的辅助函数\n function renderToolbar(containerId, symbols) {\n const container = document.getElementById(containerId);\n symbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n container.appendChild(btn);\n });\n }\n\n // 渲染两行工具栏\n renderToolbar('math-toolbar-basic', basicSymbols);\n renderToolbar('math-toolbar-funcs', funcSymbols);\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:导出对话功能 ---\n function exportChat() {\n if (messageHistory.length === 0) {\n alert('暂无对话记录可导出');\n return;\n }\n \n // 创建 JSON 数据\n const dataStr = JSON.stringify(messageHistory, null, 2);\n const blob = new Blob([dataStr], { type: 'application/json' });\n \n // 创建下载链接\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n \n // 生成文件名:math_chat_日期.json\n const date = new Date();\n const timestamp = date.toISOString().slice(0,19).replace(/[-T:]/g,\"\");\n a.download = `math_chat_${timestamp}.json`;\n \n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n }\n\n // 获取模型列表逻辑\n async function fetchAndShowModels(event) {\n event.stopPropagation();\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = '';\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n userInput.value = '';\n userInput.style.height = '44px';\n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n return div;\n }\n</script>\n\n</body>\n</html>\n"}, "50b815f3f1447e50fe8f0b2dc197d20c9a8fe1be": {"t": 1785765427.4569466, "n": 21031, "h": "0dd0c78a68419f9c", "c": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n <title>Math AI Chat</title>\n \n <!-- Markdown 解析 -->\n <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n \n <!-- 代码高亮 -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n\n <!-- MathJax 公式渲染 -->\n <script>\n window.MathJax = {\n tex: {\n inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],\n displayMath: [['$$', '$$'], ['[', ']']],\n processEscapes: true\n },\n options: {\n ignoreHtmlClass: 'tex2jax_ignore',\n processHtmlClass: 'tex2jax_process'\n },\n startup: {\n typeset: false\n }\n };\n </script>\n <script id=\"MathJax-script\" async src=\"https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js\"></script>\n\n <style>\n :root {\n --bg-color: #f4f4f9;\n --primary-color: #4a90e2;\n --toolbar-bg: #eef2f5;\n --border-color: #d1d5db;\n }\n\n body, html {\n margin: 0;\n padding: 0;\n height: 100%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n /* 顶部设置栏 */\n .settings-header {\n background: #2c3e50;\n color: white;\n padding: 10px 15px;\n font-size: 0.9rem;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n align-items: center;\n flex-shrink: 0;\n }\n \n .settings-panel {\n background: #34495e;\n padding: 10px;\n display: none;\n flex-direction: column; /* 改为列布局以适应移动端更好 */\n gap: 10px;\n border-bottom: 1px solid #4a6278;\n flex-shrink: 0;\n }\n \n @media (min-width: 600px) {\n .settings-panel {\n flex-direction: row;\n flex-wrap: wrap;\n }\n }\n\n .settings-panel.show { display: flex; }\n\n .settings-panel input {\n background: #2c3e50;\n border: 1px solid #5dade2;\n color: white;\n padding: 8px;\n border-radius: 4px;\n flex: 1;\n min-width: 120px;\n font-size: 0.9rem;\n }\n\n /* --- 新增:模型选择器样式 --- */\n .model-selector-group {\n position: relative;\n display: flex;\n flex: 1;\n min-width: 200px;\n }\n\n .model-selector-group input {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-right: none;\n }\n\n .model-list-btn {\n background: #2980b9;\n border: 1px solid #5dade2;\n color: white;\n padding: 0 12px;\n cursor: pointer;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.2s;\n }\n\n .model-list-btn:hover { background: #3498db; }\n .model-list-btn:active { background: #1f618d; }\n\n /* 模型下拉列表 */\n .model-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n background: #2c3e50;\n border: 1px solid #5dade2;\n border-radius: 4px;\n max-height: 250px;\n overflow-y: auto;\n z-index: 1000;\n display: none;\n box-shadow: 0 4px 6px rgba(0,0,0,0.3);\n margin-top: 2px;\n }\n\n .model-dropdown.show { display: block; }\n\n .model-option {\n padding: 8px 12px;\n color: #ecf0f1;\n cursor: pointer;\n font-size: 0.9rem;\n border-bottom: 1px solid #34495e;\n }\n\n .model-option:last-child { border-bottom: none; }\n .model-option:hover { background: #34495e; color: #5dade2; }\n .model-option.loading { text-align: center; color: #bdc3c7; cursor: default; }\n .model-option.error { color: #e74c3c; font-size: 0.8rem; }\n\n /* 滚动条样式 */\n .model-dropdown::-webkit-scrollbar { width: 6px; }\n .model-dropdown::-webkit-scrollbar-thumb { background: #5dade2; border-radius: 3px; }\n .model-dropdown::-webkit-scrollbar-track { background: #2c3e50; }\n /* ------------------------- */\n\n .save-btn {\n padding: 8px 15px;\n background: #27ae60;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-weight: bold;\n }\n .save-btn:hover { background: #2ecc71; }\n\n /* 聊天区域 */\n #chat-container {\n flex: 1;\n overflow-y: auto;\n padding: 15px;\n display: flex;\n flex-direction: column;\n gap: 15px;\n scroll-behavior: smooth;\n -webkit-overflow-scrolling: touch;\n }\n\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 12px;\n line-height: 1.6;\n word-wrap: break-word;\n font-size: 0.95rem;\n box-shadow: 0 1px 2px rgba(0,0,0,0.1);\n }\n\n .message.user {\n align-self: flex-end;\n background-color: var(--primary-color);\n color: white;\n border-bottom-right-radius: 2px;\n }\n\n .message.ai {\n align-self: flex-start;\n background-color: white;\n color: #333;\n border-bottom-left-radius: 2px;\n border: 1px solid var(--border-color);\n }\n\n /* 底部区域容器 */\n .bottom-section {\n background: white;\n border-top: 1px solid var(--border-color);\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n }\n\n /* 数学符号工具栏 */\n .math-toolbar {\n display: flex;\n gap: 8px;\n padding: 8px 10px;\n background-color: var(--toolbar-bg);\n overflow-x: auto;\n white-space: nowrap;\n border-bottom: 1px solid #e0e0e0;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n }\n .math-toolbar::-webkit-scrollbar { display: none; }\n\n .math-btn {\n background: white;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 12px;\n font-size: 0.9rem;\n font-family: Consolas, \"Times New Roman\", serif;\n cursor: pointer;\n user-select: none;\n color: #334155;\n box-shadow: 0 1px 2px rgba(0,0,0,0.05);\n transition: all 0.1s;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .math-btn:active {\n background-color: #e2e8f0;\n transform: translateY(1px);\n }\n \n .math-btn span { pointer-events: none; }\n\n /* 输入区域 */\n .input-area {\n display: flex;\n padding: 10px;\n gap: 10px;\n align-items: flex-end;\n background: white;\n }\n\n textarea {\n flex: 1;\n border: 1px solid var(--border-color);\n border-radius: 8px;\n padding: 10px;\n font-size: 1rem;\n resize: none;\n height: 44px;\n max-height: 120px;\n outline: none;\n font-family: inherit;\n }\n\n textarea:focus { border-color: var(--primary-color); }\n\n button#send-btn {\n background-color: var(--primary-color);\n color: white;\n border: none;\n padding: 0 20px;\n height: 44px;\n border-radius: 8px;\n font-weight: bold;\n cursor: pointer;\n flex-shrink: 0;\n }\n \n button#send-btn:disabled { background-color: #94a3b8; }\n\n .message.ai pre { background: #2d2d2d; color: #f8f8f2; padding: 10px; border-radius: 5px; overflow-x: auto; }\n .message.ai p { margin: 0 0 8px 0; }\n .message.ai p:last-child { margin: 0; }\n \n * { touch-action: manipulation; }\n </style>\n</head>\n<body>\n\n<!-- 顶部设置 (可折叠) -->\n<div class=\"settings-header\" onclick=\"toggleSettings()\">\n <span>⚙️ 设置与 API Key</span>\n <span id=\"toggle-icon\">▼</span>\n</div>\n<div class=\"settings-panel\" id=\"settings-panel\">\n <input type=\"text\" id=\"api-url\" placeholder=\"API 地址 (如 https://api.openai.com/v1)\">\n <input type=\"password\" id=\"api-key\" placeholder=\"API Key (sk-...)\">\n \n <!-- 修改后的模型输入区域 -->\n <div class=\"model-selector-group\">\n <input type=\"text\" id=\"model-name\" placeholder=\"模型 (如 gpt-4o, deepseek-chat)\">\n <button class=\"model-list-btn\" onclick=\"fetchAndShowModels(event)\" title=\"获取模型列表\">▼</button>\n <div id=\"model-dropdown\" class=\"model-dropdown\">\n <!-- 模型列表将在这里生成 -->\n </div>\n </div>\n\n <button class=\"save-btn\" onclick=\"saveSettings()\">保存配置</button>\n</div>\n\n<!-- 聊天内容 -->\n<div id=\"chat-container\">\n <div class=\"message ai\">\n 你好!我是你的数学助手。\n <br>点击下方的工具栏可以快速输入数学符号,我会自动渲染公式。\n <br>例如:$\\int_{0}^{\\pi} \\sin(x) dx = 2$\n </div>\n</div>\n\n<!-- 底部区域:工具栏 + 输入框 -->\n<div class=\"bottom-section\">\n <div class=\"math-toolbar\" id=\"math-toolbar\">\n <!-- 按钮将通过 JS 动态生成 -->\n </div>\n\n <div class=\"input-area\">\n <textarea id=\"user-input\" placeholder=\"输入数学问题...\" rows=\"1\"></textarea>\n <button id=\"send-btn\" onclick=\"sendMessage()\">发送</button>\n </div>\n</div>\n\n<script>\n // --- 1. 数学符号配置 ---\n const mathSymbols = [\n { label: 'x²', insert: '^{2}', offset: 0 },\n { label: 'xⁿ', insert: '^{}', offset: 1 },\n { label: '√', insert: '\\\\sqrt{}', offset: 1 },\n { label: 'ⁿ√', insert: '\\\\sqrt[]{}', offset: 3 },\n { label: 'frac', insert: '\\\\frac{}{}', offset: 3 },\n { label: 'sin', insert: '\\\\sin()', offset: 1 },\n { label: 'cos', insert: '\\\\cos()', offset: 1 },\n { label: 'tan', insert: '\\\\tan()', offset: 1 },\n { label: 'ln', insert: '\\\\ln()', offset: 1 },\n { label: 'log', insert: '\\\\log_{}{}', offset: 3 },\n { label: 'eˣ', insert: 'e^{}', offset: 1 },\n { label: 'π', insert: '\\\\pi', offset: 0 },\n { label: 'θ', insert: '\\\\theta', offset: 0 },\n { label: '∞', insert: '\\\\infty', offset: 0 },\n { label: '∫', insert: '\\\\int ', offset: 0 },\n { label: '∫ab', insert: '\\\\int_{}^{}', offset: 3 },\n { label: 'dx', insert: 'dx', offset: 0 },\n { label: 'd/dx', insert: '\\\\frac{d}{dx}', offset: 0 },\n { label: '∂', insert: '\\\\partial', offset: 0 },\n { label: '∑', insert: '\\\\sum_{}^{}', offset: 3 },\n { label: 'lim', insert: '\\\\lim_{ \\\\to }', offset: 4 },\n { label: '≤', insert: '\\\\le', offset: 0 },\n { label: '≥', insert: '\\\\ge', offset: 0 },\n { label: '≈', insert: '\\\\approx', offset: 0 },\n { label: '≠', insert: '\\\\ne', offset: 0 },\n { label: '→', insert: '\\\\to', offset: 0 },\n { label: '∵', insert: '\\\\because', offset: 0 },\n { label: '∴', insert: '\\\\therefore', offset: 0 }\n ];\n\n // --- 2. 初始化逻辑 ---\n const chatContainer = document.getElementById('chat-container');\n const userInput = document.getElementById('user-input');\n const toolbar = document.getElementById('math-toolbar');\n \n mathSymbols.forEach(sym => {\n const btn = document.createElement('div');\n btn.className = 'math-btn';\n btn.textContent = sym.label;\n btn.onmousedown = (e) => e.preventDefault(); \n btn.onclick = () => insertSymbol(sym.insert, sym.offset);\n toolbar.appendChild(btn);\n });\n\n function insertSymbol(text, offsetBack) {\n const start = userInput.selectionStart;\n const end = userInput.selectionEnd;\n const value = userInput.value;\n userInput.value = value.substring(0, start) + text + value.substring(end);\n const newPos = start + text.length - offsetBack;\n userInput.setSelectionRange(newPos, newPos);\n userInput.focus();\n userInput.dispatchEvent(new Event('input'));\n }\n\n // --- 3. 聊天与 API 逻辑 ---\n const defaultUrl = \"https://api.openai.com/v1\";\n const defaultModel = \"gpt-3.5-turbo\";\n let messageHistory = [];\n\n window.onload = () => {\n document.getElementById('api-url').value = localStorage.getItem('api_url') || '';\n document.getElementById('api-key').value = localStorage.getItem('api_key') || '';\n document.getElementById('model-name').value = localStorage.getItem('model_name') || defaultModel;\n \n marked.setOptions({\n highlight: (code, lang) => highlight.highlight(code, { language: highlight.getLanguage(lang) ? lang : 'plaintext' }).value\n });\n\n // 点击页面其他地方关闭下拉框\n document.addEventListener('click', (e) => {\n const dropdown = document.getElementById('model-dropdown');\n const btn = document.querySelector('.model-list-btn');\n if (!dropdown.contains(e.target) && e.target !== btn) {\n dropdown.classList.remove('show');\n }\n });\n };\n\n function toggleSettings() {\n const panel = document.getElementById('settings-panel');\n const icon = document.getElementById('toggle-icon');\n if (panel.style.display === 'flex') {\n panel.style.display = 'none';\n icon.textContent = '▼';\n } else {\n panel.style.display = 'flex';\n icon.textContent = '▲';\n }\n }\n\n function saveSettings() {\n localStorage.setItem('api_url', document.getElementById('api-url').value);\n localStorage.setItem('api_key', document.getElementById('api-key').value);\n localStorage.setItem('model_name', document.getElementById('model-name').value);\n alert('配置已保存');\n // 仅当面板打开时关闭\n const panel = document.getElementById('settings-panel');\n if(panel.style.display === 'flex') toggleSettings();\n }\n\n // --- 新增:获取模型列表逻辑 ---\n async function fetchAndShowModels(event) {\n event.stopPropagation(); // 防止触发 document 的关闭事件\n const dropdown = document.getElementById('model-dropdown');\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n\n // 切换显示/隐藏\n if (dropdown.classList.contains('show')) {\n dropdown.classList.remove('show');\n return;\n }\n\n // 基础校验\n if (!apiKey) {\n alert('请先填写 API Key');\n return;\n }\n\n dropdown.classList.add('show');\n dropdown.innerHTML = '<div class=\"model-option loading\">正在获取模型列表...</div>';\n\n try {\n // 构造 models 端点\n // 如果用户填的是 .../v1/chat/completions,去掉 /chat/completions\n if (apiUrl.endsWith('/chat/completions')) {\n apiUrl = apiUrl.replace('/chat/completions', '');\n }\n // 确保不以 / 结尾\n if (apiUrl.endsWith('/')) {\n apiUrl = apiUrl.slice(0, -1);\n }\n // 拼接 /models\n const modelsUrl = `${apiUrl}/models`;\n\n const response = await fetch(modelsUrl, {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n }\n });\n\n if (!response.ok) {\n throw new Error(`HTTP Error: ${response.status}`);\n }\n\n const data = await response.json();\n \n // 兼容不同的 API 返回格式,通常是 data.data 数组\n const models = Array.isArray(data.data) ? data.data : (Array.isArray(data) ? data : []);\n \n // 按 ID 排序\n models.sort((a, b) => a.id.localeCompare(b.id));\n\n dropdown.innerHTML = ''; // 清空 Loading\n\n if (models.length === 0) {\n dropdown.innerHTML = '<div class=\"model-option error\">未找到模型</div>';\n return;\n }\n\n models.forEach(model => {\n const div = document.createElement('div');\n div.className = 'model-option';\n div.textContent = model.id;\n div.onclick = () => {\n document.getElementById('model-name').value = model.id;\n dropdown.classList.remove('show');\n };\n dropdown.appendChild(div);\n });\n\n } catch (error) {\n dropdown.innerHTML = `<div class=\"model-option error\">获取失败: ${error.message}<br>请检查 URL 和 Key</div>`;\n }\n }\n\n // 输入框自适应高度\n userInput.addEventListener('input', function() {\n this.style.height = 'auto';\n this.style.height = (this.scrollHeight) + 'px';\n if(this.value === '') this.style.height = '44px';\n });\n\n userInput.addEventListener('keydown', function(e) {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n\n async function sendMessage() {\n const text = userInput.value.trim();\n const apiKey = document.getElementById('api-key').value.trim();\n let apiUrl = document.getElementById('api-url').value.trim() || defaultUrl;\n const model = document.getElementById('model-name').value.trim() || defaultModel;\n\n if (!text) return;\n if (!apiKey) { alert('请点击顶部设置填写 API Key'); toggleSettings(); return; }\n\n if (apiUrl.endsWith('/')) apiUrl = apiUrl.slice(0, -1);\n if (!apiUrl.endsWith('/chat/completions')) apiUrl += '/chat/completions';\n\n appendMessage('user', text);\n userInput.value = '';\n userInput.style.height = '44px';\n messageHistory.push({ role: \"user\", content: text });\n\n const aiMessageDiv = appendMessage('ai', '<span class=\"typing\">思考中...</span>');\n const sendBtn = document.getElementById('send-btn');\n sendBtn.disabled = true;\n\n let fullText = \"\";\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({ model: model, messages: messageHistory, stream: true })\n });\n\n if (!response.ok) throw new Error((await response.json()).error?.message || '请求失败');\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n aiMessageDiv.innerHTML = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const chunk = decoder.decode(value, { stream: true });\n const lines = chunk.split('\\n');\n \n for (const line of lines) {\n if (line.startsWith('data: ')) {\n const jsonStr = line.slice(6);\n if (jsonStr === '[DONE]') break;\n try {\n const content = JSON.parse(jsonStr).choices[0]?.delta?.content || \"\";\n fullText += content;\n aiMessageDiv.innerText = fullText;\n chatContainer.scrollTop = chatContainer.scrollHeight;\n } catch (e) {}\n }\n }\n }\n\n messageHistory.push({ role: \"assistant\", content: fullText });\n aiMessageDiv.innerHTML = marked.parse(fullText);\n MathJax.typesetPromise([aiMessageDiv]).then(() => {\n chatContainer.scrollTop = chatContainer.scrollHeight;\n });\n\n } catch (error) {\n aiMessageDiv.innerHTML = `<span style=\"color:red\">Error: ${error.message}</span>`;\n } finally {\n sendBtn.disabled = false;\n }\n }\n\n function appendMessage(role, html) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n div.innerHTML = html;\n chatContainer.appendChild(div);\n chatContainer.scrollTop = chatContainer.scrollHeight;\n return div;\n }\n</script>\n\n</body>\n</html>\n\n"}}}} |