Files

1 line
662 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "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 | 模型(可选)&#10;https://api.openai.com/v1 | sk-xxx | gpt-4o&#10;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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '&#096;');\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 | 模型(可选)&#10;https://api.openai.com/v1 | sk-xxx | gpt-4o&#10;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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '&#096;');\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 | 模型(可选)&#10;https://api.openai.com/v1 | sk-xxx | gpt-4o&#10;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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '&#096;');\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 | 模型(可选)&#10;https://api.openai.com/v1 | sk-xxx | gpt-4o&#10;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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n }\n\n function escapeAttribute(text)\n {\n return escapeHtml(text).replace(/`/g, '&#096;');\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, \"&lt;\").replace(/>/g, \"&gt;\");\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"}}}}