1 line
101 KiB
JSON
1 line
101 KiB
JSON
{"repo": "caihuailei/mindboard", "files": {"frontend/js/pages/refine.js": {"4588be819df2e7dd2df7d40a0cc315aacc5ad4ca": {"t": 1785768251.7455962, "n": 6572, "h": "26a28f8f09af7687", "c": "// LLM Refine page — streaming + state persistence\nimport { toast, copyToClipboard, downloadText } from '../ui.js';\nimport { API } from '../api.js';\nimport { navigate } from '../router.js';\n\nfunction getActiveConfig() {\n const activePreset = localStorage.getItem('asr_llm_config_active') || 'sensenova';\n return JSON.parse(localStorage.getItem(`asr_llm_config_${activePreset}`) || '{}');\n}\n\nfunction hasConfig() {\n const c = getActiveConfig();\n return !!(c.api_url && c.api_key && c.model_name);\n}\n\nexport function render() {\n const saved = getActiveConfig();\n const configured = hasConfig();\n\n return `\n <div class=\"card\">\n <h2>LLM 文本润色</h2>\n ${!configured ? '<div class=\"text-sm\" style=\"padding:8px 12px;background:var(--warning-bg);border-radius:var(--radius-sm);margin-bottom:12px;color:var(--warning)\">请先在 <a href=\"#/config\" style=\"color:var(--accent)\">配置页面</a> 设置 LLM 参数</div>' : ''}\n\n <div class=\"form-group\">\n <label>输入文本</label>\n <textarea class=\"form-input\" id=\"refineInput\" rows=\"8\" placeholder=\"粘贴需要润色的文本,或从结果页面导入...\"></textarea>\n </div>\n\n <div class=\"btn-group\">\n <button class=\"btn btn-primary\" id=\"refineBtn\" ${!configured ? 'disabled' : ''}>润色文本</button>\n <button class=\"btn btn-secondary\" id=\"importResultBtn\">从结果导入</button>\n </div>\n\n <div id=\"refineConfigSummary\" class=\"config-summary text-sm mt-8\">\n ${configured ? `模型: <code>${saved.model_name}</code> · ${saved.api_url}` : '<span class=\"text-dim\">未配置 LLM</span>'}\n </div>\n </div>\n\n <div id=\"refineResult\" class=\"hidden\">\n <div class=\"card\">\n <h2>润色结果</h2>\n <div id=\"refineOutput\" class=\"transcript-body\"></div>\n <div class=\"btn-group\">\n <button class=\"btn btn-primary btn-sm\" id=\"copyRefineBtn\">复制结果</button>\n <button class=\"btn btn-secondary btn-sm\" id=\"downloadRefineBtn\">下载 .md</button>\n </div>\n </div>\n </div>\n\n <div id=\"refineLoading\" class=\"hidden card text-center\" style=\"text-align:center\">\n <div class=\"spinner\" style=\"margin:0 auto\"></div>\n <p class=\"mt-8 text-dim\">正在润色 <span id=\"refineProgress\" class=\"text-dim\"></span></p>\n </div>\n `;\n}\n\nexport function init() {\n document.getElementById('refineBtn').addEventListener('click', doRefine);\n document.getElementById('importResultBtn').addEventListener('click', importResult);\n document.getElementById('copyRefineBtn')?.addEventListener('click', () => {\n const text = document.getElementById('refineOutput')?.textContent || '';\n if (text) copyToClipboard(text);\n });\n document.getElementById('downloadRefineBtn')?.addEventListener('click', () => {\n const text = document.getElementById('refineOutput')?.textContent || '';\n if (text) {\n const now = new Date();\n const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\n const created = now.toISOString().replace('Z', '+08:00');\n const frontmatter = `---\ntitle: 润色结果 ${ts}\nsource: LLM 文本润色\nauthor: ASR 系统\npublished: ${created}\ncreated: ${created}\ndescription: ASR 转写文本润色结果\ntags: [ASR, 润色, LLM]\n---\n\n`;\n downloadText(frontmatter + text, `refined_${ts}.md`);\n }\n });\n\n // Restore refine input from sessionStorage\n const savedInput = sessionStorage.getItem('refine_input');\n if (savedInput) {\n document.getElementById('refineInput').value = savedInput;\n sessionStorage.removeItem('refine_input');\n }\n\n // Restore refine result from sessionStorage (page-switch persistence)\n const savedResult = sessionStorage.getItem('refine_result');\n if (savedResult) {\n document.getElementById('refineOutput').textContent = savedResult;\n document.getElementById('refineResult').classList.remove('hidden');\n }\n}\n\nasync function doRefine() {\n const config = getActiveConfig();\n if (!config.api_url || !config.api_key || !config.model_name) {\n toast('请先在配置页面设置 LLM', 'error');\n return;\n }\n\n const text = document.getElementById('refineInput').value.trim();\n if (!text) { toast('请输入需要润色的文本', 'error'); return; }\n\n document.getElementById('refineBtn').disabled = true;\n document.getElementById('refineLoading').classList.remove('hidden');\n document.getElementById('refineResult').classList.add('hidden');\n document.getElementById('refineProgress').textContent = '';\n\n const output = document.getElementById('refineOutput');\n\n try {\n // Use streaming\n let streamedText = '';\n const startTime = Date.now();\n await API.refineTextStream(text, config,\n (chunk, fullText) => {\n streamedText = fullText;\n output.textContent = streamedText;\n const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);\n document.getElementById('refineProgress').textContent = `(${elapsed}s · ${streamedText.length} 字)`;\n },\n (finalText) => {\n // Save to sessionStorage for page-switch persistence\n sessionStorage.setItem('refine_result', finalText);\n }\n );\n\n document.getElementById('refineResult').classList.remove('hidden');\n sessionStorage.setItem('refine_result', streamedText);\n toast('润色完成', 'success');\n } catch (e) {\n toast('润色失败: ' + e.message, 'error');\n document.getElementById('refineResult').classList.remove('hidden');\n if (!output.textContent) output.textContent = '错误: ' + e.message;\n } finally {\n document.getElementById('refineBtn').disabled = false;\n document.getElementById('refineLoading').classList.add('hidden');\n }\n}\n\nfunction importResult() {\n const results = JSON.parse(localStorage.getItem('asr_results') || '[]');\n if (results.length === 0) {\n toast('没有转写结果可以导入', 'info');\n return;\n }\n const list = results.map((r, i) =>\n `<div style=\"padding:8px;cursor:pointer;border-bottom:1px solid var(--border)\" data-idx=\"${i}\">\n <strong>${r.filename}</strong>\n <span class=\"text-dim text-sm\"> · ${(r.text || '').length} 字 · ${new Date(r.date).toLocaleString('zh-CN')}</span>\n </div>`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `<div class=\"modal\"><h3>选择要导入的结果</h3>${list || '<p>没有结果</p>'}</div>`;\n document.body.appendChild(overlay);\n\n overlay.querySelectorAll('[data-idx]').forEach(el => {\n el.addEventListener('click', () => {\n const idx = parseInt(el.dataset.idx);\n document.getElementById('refineInput').value = results[idx].text || '';\n overlay.remove();\n });\n });\n overlay.addEventListener('click', (e) => {\n if (e.target === overlay) overlay.remove();\n });\n}\n"}}, "frontend/js/pages/schedule.js": {"4588be819df2e7dd2df7d40a0cc315aacc5ad4ca": {"t": 1785768252.0871258, "n": 8555, "h": "1162cbed53132b96", "c": "// Course schedule page\nimport { toast } from '../ui.js';\n\nconst SCHEDULE_KEY = 'asr_schedule';\nconst DAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];\n\nfunction loadSchedule() {\n try {\n const raw = localStorage.getItem(SCHEDULE_KEY);\n return raw ? JSON.parse(raw) : [];\n } catch { return []; }\n}\n\nfunction saveSchedule(entries) {\n localStorage.setItem(SCHEDULE_KEY, JSON.stringify(entries));\n}\n\nexport function render() {\n const entries = loadSchedule();\n return `\n <div class=\"schedule-page\">\n <div class=\"schedule-header\">\n <h2>课程表</h2>\n <button class=\"btn btn-primary\" id=\"addCourseBtn\">+ 添加课程</button>\n </div>\n <div class=\"schedule-table-wrap\">\n <table class=\"schedule-table\" id=\"scheduleTable\">\n <thead>\n <tr>\n <th>时间</th>\n ${DAYS.map(d => `<th>${d}</th>`).join('')}\n </tr>\n </thead>\n <tbody id=\"scheduleBody\"></tbody>\n </table>\n </div>\n </div>\n `;\n}\n\nexport function init() {\n document.getElementById('addCourseBtn')?.addEventListener('click', () => openCourseEditor());\n renderTable();\n\n // Edit/delete buttons via delegation\n document.getElementById('scheduleBody')?.addEventListener('click', (e) => {\n const editBtn = e.target.closest('.schedule-edit');\n const deleteBtn = e.target.closest('.schedule-delete');\n if (editBtn) openCourseEditor(editBtn.dataset.id);\n if (deleteBtn) deleteCourse(deleteBtn.dataset.id);\n });\n}\n\nfunction renderTable() {\n const tbody = document.getElementById('scheduleBody');\n if (!tbody) return;\n const entries = loadSchedule();\n\n // Build time slots from entries + defaults\n const timeSlots = new Set();\n const defaultSlots = ['08:00', '09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'];\n for (const t of defaultSlots) timeSlots.add(t);\n for (const e of entries) timeSlots.add(e.start);\n\n const sortedSlots = [...timeSlots].sort();\n\n const today = new Date().getDay(); // 0=Sun, 1=Mon...\n const todayIdx = today === 0 ? 7 : today; // 1=Mon...7=Sun\n\n let html = '';\n for (const slot of sortedSlots) {\n html += '<tr>';\n html += `<td class=\"schedule-time\">${slot}</td>`;\n for (let d = 1; d <= 7; d++) {\n const cell = entries.filter(e => e.day === d && e.start === slot);\n const isToday = d === todayIdx;\n html += `<td class=\"schedule-cell ${isToday ? 'schedule-today' : ''}\">`;\n for (const c of cell) {\n html += `\n <div class=\"schedule-card\" data-id=\"${c.id}\">\n <div class=\"schedule-card-subject\">${esc(c.subject)}</div>\n <div class=\"schedule-card-info\">${esc(c.room || '')}${c.room && c.teacher ? ' · ' : ''}${esc(c.teacher || '')}</div>\n <div class=\"schedule-card-actions\">\n <button class=\"schedule-edit\" data-id=\"${c.id}\" title=\"编辑\">✏️</button>\n <button class=\"schedule-delete\" data-id=\"${c.id}\" title=\"删除\">×</button>\n </div>\n </div>`;\n }\n html += '</td>';\n }\n html += '</tr>';\n }\n tbody.innerHTML = html || '<tr><td colspan=\"8\" style=\"text-align:center;padding:40px;color:var(--muted-fg)\">暂无课程,点击右上角添加</td></tr>';\n}\n\nfunction openCourseEditor(id) {\n const entries = loadSchedule();\n const existing = id ? entries.find(e => e.id === id) : null;\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `\n <div class=\"modal\">\n <h3>${existing ? '编辑课程' : '添加课程'}</h3>\n <form id=\"courseForm\" style=\"display:flex;flex-direction:column;gap:12px;\">\n <label>课程名称 <input required id=\"cfSubject\" value=\"${existing ? esc(existing.subject) : ''}\" placeholder=\"高等数学\"></label>\n <label>星期\n <select id=\"cfDay\">\n ${DAYS.map((d, i) => `<option value=\"${i + 1}\" ${existing && existing.day === i + 1 ? 'selected' : ''}>${d}</option>`).join('')}\n </select>\n </label>\n <label>开始时间 <input type=\"time\" required id=\"cfStart\" value=\"${existing ? existing.start : '08:00'}\"></label>\n <label>结束时间 <input type=\"time\" required id=\"cfEnd\" value=\"${existing ? existing.end : '09:40'}\"></label>\n <label>教室 <input id=\"cfRoom\" value=\"${existing ? esc(existing.room || '') : ''}\" placeholder=\"A101\"></label>\n <label>教师 <input id=\"cfTeacher\" value=\"${existing ? esc(existing.teacher || '') : ''}\" placeholder=\"张教授\"></label>\n <div style=\"display:flex;gap:8px;justify-content:flex-end;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"cfCancel\">取消</button>\n <button type=\"submit\" class=\"btn btn-primary\">${existing ? '保存' : '添加'}</button>\n </div>\n </form>\n </div>\n `;\n document.body.appendChild(overlay);\n\n overlay.querySelector('#cfCancel')?.addEventListener('click', () => overlay.remove());\n overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });\n\n overlay.querySelector('#courseForm')?.addEventListener('submit', (e) => {\n e.preventDefault();\n const entry = {\n id: existing?.id || ('c_' + Date.now()),\n subject: document.getElementById('cfSubject').value.trim(),\n day: parseInt(document.getElementById('cfDay').value),\n start: document.getElementById('cfStart').value,\n end: document.getElementById('cfEnd').value,\n room: document.getElementById('cfRoom').value.trim(),\n teacher: document.getElementById('cfTeacher').value.trim(),\n };\n if (!entry.subject) { toast('请输入课程名称', 'error'); return; }\n\n if (existing) {\n const idx = entries.findIndex(e => e.id === id);\n if (idx >= 0) entries[idx] = entry;\n } else {\n entries.push(entry);\n // Auto-create tutor for new course\n createTutorForCourse(entry.subject);\n }\n saveSchedule(entries);\n overlay.remove();\n renderTable();\n toast(existing ? '已更新' : '已添加' + (!existing ? '(导师已自动创建)' : ''), 'success');\n });\n}\n\nfunction deleteCourse(id) {\n if (!confirm('确定删除此课程?')) return;\n const entries = loadSchedule();\n saveSchedule(entries.filter(e => e.id !== id));\n renderTable();\n toast('已删除', 'success');\n}\n\nfunction esc(s) {\n if (!s) return '';\n return s.replace(/[&<>\"']/g, m => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":\"'\"}[m]));\n}\n\nasync function createTutorForCourse(subject) {\n const { API } = await import('../api.js');\n if (!API?.nanobotCreateTutor) return;\n\n // Create with default soul immediately\n const defaultSoul = `你是一名${subject}课程导师,擅长用清晰易懂的方式讲解相关知识。回答时请结合实际例子,语言简洁专业。`;\n try {\n await API.nanobotCreateTutor(subject, defaultSoul);\n } catch {\n // Tutor may already exist — non-critical\n }\n\n // Async: generate better SOUL.md via LLM and update\n try {\n const config = loadLlmConfig();\n if (!config?.api_url || !config?.api_key) return;\n\n const prompt = `请为课程\"${subject}\"生成一段导师人设(150字以内),包含:学科定位、教学风格和回答规范。只输出人设正文,不要标题或格式。`;\n const res = await fetch('/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n messages: [\n { role: 'system', content: '你是一名教育专家,擅长为各学科课程设计AI导师人设。' },\n { role: 'user', content: prompt },\n ],\n model: config.model_name,\n temperature: 0.7,\n api_url: config.api_url,\n api_key: config.api_key,\n }),\n });\n if (!res.ok) return;\n const data = await res.json();\n const soul = data.choices?.[0]?.message?.content?.trim();\n if (soul) {\n await API.nanobotSetTutorSoul(subject, soul);\n }\n } catch {\n // Non-critical: default soul is already serviceable\n }\n}\n\nfunction loadLlmConfig() {\n try {\n const active = localStorage.getItem('asr_llm_config_active') || 'sensenova';\n const saved = JSON.parse(localStorage.getItem('asr_llm_config_' + active) || '{}');\n const presets = {\n sensenova: { url: 'https://token.sensenova.cn/v1', model: 'deepseek-v4-flash' },\n deepseek: { url: 'https://api.deepseek.com', model: 'deepseek-chat' },\n openai: { url: 'https://api.openai.com/v1', model: 'gpt-4o' },\n modelscope: { url: 'https://api-inference.modelscope.cn/v1', model: 'ZhipuAI/GLM-5' },\n ollama: { url: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },\n nanobot: { url: 'http://127.0.0.1:18900/v1', model: 'nanobot' },\n };\n const p = presets[active] || {};\n return {\n api_url: saved.api_url || p.url || '',\n api_key: saved.api_key || '',\n model_name: saved.model_name || p.model || '',\n };\n } catch { return {}; }\n}\n"}}, "frontend/js/pages/analyze.js": {"4588be819df2e7dd2df7d40a0cc315aacc5ad4ca": {"t": 1785768252.4296312, "n": 10240, "h": "06740bb3ec817d7a", "c": "// AI 分析页面 — 文本结构化摘要\nimport { toast, copyToClipboard, downloadText } from '../ui.js';\nimport { API } from '../api.js';\nimport { navigate } from '../router.js';\n\nconst MODES = [\n { value: 'summarize', label: '摘要' },\n { value: 'code-review', label: '代码审查' },\n { value: 'security', label: '安全审计' },\n { value: 'extract', label: '信息提取' },\n];\n\nconst MODE_DESC = {\n summarize: '结构化摘要:核心内容、关键议题、待办事项',\n 'code-review': '代码审查:问题、架构评估、改进建议',\n security: '安全审计:漏洞清单、修复方案',\n extract: '信息提取:核心要点、实体、时间线',\n};\n\nfunction getActiveConfig() {\n const activePreset = localStorage.getItem('asr_llm_config_active') || 'sensenova';\n return JSON.parse(localStorage.getItem(`asr_llm_config_${activePreset}`) || '{}');\n}\n\nfunction hasConfig() {\n const c = getActiveConfig();\n return !!(c.api_url && c.api_key && c.model_name);\n}\n\nfunction getCustomPrompts() {\n try {\n return JSON.parse(localStorage.getItem('ai_custom_prompts') || '{}');\n } catch { return {}; }\n}\n\nexport function render() {\n const saved = getActiveConfig();\n const configured = hasConfig();\n const customPrompts = getCustomPrompts();\n\n const modeTabs = MODES.map(m =>\n `<button class=\"tab-btn\" data-mode=\"${m.value}\">${m.label}</button>`\n ).join('');\n\n // Custom prompts selector\n const customOpts = Object.keys(customPrompts).map(k =>\n `<option value=\"custom:${k}\">自定义: ${k}</option>`\n ).join('');\n\n return `\n <div class=\"card\">\n <h2>AI 智能分析</h2>\n ${!configured ? '<div class=\"text-sm\" style=\"padding:8px 12px;background:var(--warning-bg);border-radius:var(--radius-sm);margin-bottom:12px;color:var(--warning)\">请先在 <a href=\"#/config\" style=\"color:var(--accent)\">配置页面</a> 设置 LLM 参数</div>' : ''}\n\n <!-- 模式选择 -->\n <div class=\"form-group\">\n <label>分析模式</label>\n <div class=\"tab-bar\" id=\"modeTabs\">${modeTabs}</div>\n <select class=\"form-input mt-8\" id=\"analyzeMode\">\n ${MODES.map(m => `<option value=\"${m.value}\">${m.label}</option>`).join('')}\n ${customOpts ? `<optgroup label=\"自定义\">${customOpts}</optgroup>` : ''}\n <option value=\"custom\">自定义提示词</option>\n </select>\n <div id=\"modeDesc\" class=\"text-sm text-dim mt-4\">${MODE_DESC.summarize}</div>\n </div>\n\n <!-- 自定义提示词(隐藏,选自定义时显示) -->\n <div id=\"customPromptBox\" class=\"hidden form-group\">\n <label>自定义提示词</label>\n <textarea class=\"form-input\" id=\"customPrompt\" rows=\"5\" placeholder=\"输入你的分析要求...\"></textarea>\n <div class=\"text-sm text-dim mt-4\">\n <a href=\"javascript:void(0)\" id=\"saveAsPreset\">保存为预设</a>\n </div>\n </div>\n\n <!-- 文本输入 -->\n <div class=\"form-group\">\n <label>输入文本</label>\n <textarea class=\"form-input\" id=\"analyzeInput\" rows=\"8\" placeholder=\"粘贴需要分析的文本,或从转写结果导入...\"></textarea>\n </div>\n\n <div class=\"btn-group\">\n <button class=\"btn btn-primary\" id=\"analyzeBtn\" ${!configured ? 'disabled' : ''}>开始分析</button>\n <button class=\"btn btn-secondary\" id=\"importResultBtn\">从转写结果导入</button>\n </div>\n\n <div id=\"analyzeConfigSummary\" class=\"config-summary text-sm mt-8\">\n ${configured ? `模型: <code>${saved.model_name}</code> · ${saved.api_url}` : '<span class=\"text-dim\">未配置 LLM</span>'}\n </div>\n </div>\n\n <div id=\"analyzeResult\" class=\"hidden\">\n <div class=\"card\">\n <div style=\"display:flex;justify-content:space-between;align-items:center\">\n <h2>分析结果</h2>\n <span class=\"badge badge-success\" id=\"analyzeModeBadge\"></span>\n </div>\n <div id=\"analyzeOutput\" class=\"transcript-body\" style=\"white-space:pre-wrap\"></div>\n <div class=\"btn-group\">\n <button class=\"btn btn-primary btn-sm\" id=\"copyAnalyzeBtn\">复制</button>\n <button class=\"btn btn-secondary btn-sm\" id=\"downloadAnalyzeBtn\">下载 .md</button>\n </div>\n </div>\n </div>\n\n <div id=\"analyzeLoading\" class=\"hidden card\" style=\"text-align:center\">\n <div class=\"spinner\" style=\"margin:0 auto\"></div>\n <p class=\"mt-8 text-dim\">正在分析 <span id=\"analyzeProgress\" class=\"text-dim\"></span></p>\n </div>\n `;\n}\n\nexport function init() {\n document.getElementById('analyzeBtn').addEventListener('click', doAnalyze);\n document.getElementById('importResultBtn').addEventListener('click', importResult);\n\n // Mode tab buttons\n document.querySelectorAll('#modeTabs .tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('#modeTabs .tab-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n const mode = btn.dataset.mode;\n document.getElementById('analyzeMode').value = mode;\n updateModeDisplay(mode);\n });\n });\n\n // Mode select change\n document.getElementById('analyzeMode').addEventListener('change', (e) => {\n updateModeDisplay(e.target.value);\n });\n\n // Save custom preset\n document.getElementById('saveAsPreset').addEventListener('click', saveCustomPreset);\n\n // Copy\n document.getElementById('copyAnalyzeBtn')?.addEventListener('click', () => {\n const text = document.getElementById('analyzeOutput')?.textContent || '';\n if (text) copyToClipboard(text);\n });\n\n // Download\n document.getElementById('downloadAnalyzeBtn')?.addEventListener('click', () => {\n const text = document.getElementById('analyzeOutput')?.textContent || '';\n if (text) {\n const now = new Date();\n const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\n downloadText(text, `analyze_${ts}.md`);\n }\n });\n\n // Restore from sessionStorage\n const savedInput = sessionStorage.getItem('analyze_input');\n if (savedInput) {\n document.getElementById('analyzeInput').value = savedInput;\n sessionStorage.removeItem('analyze_input');\n }\n const savedResult = sessionStorage.getItem('analyze_result');\n const savedMode = sessionStorage.getItem('analyze_mode');\n if (savedResult) {\n document.getElementById('analyzeOutput').textContent = savedResult;\n document.getElementById('analyzeResult').classList.remove('hidden');\n if (savedMode) document.getElementById('analyzeModeBadge').textContent = savedMode;\n }\n\n // Activate default tab\n document.querySelector('#modeTabs .tab-btn')?.classList.add('active');\n}\n\nfunction updateModeDisplay(mode) {\n const customBox = document.getElementById('customPromptBox');\n const desc = document.getElementById('modeDesc');\n\n if (mode === 'custom' || mode.startsWith('custom:')) {\n customBox.classList.remove('hidden');\n desc.textContent = '使用自定义提示词进行分析';\n\n // Load saved preset\n if (mode.startsWith('custom:')) {\n const name = mode.slice(7);\n const prompts = getCustomPrompts();\n if (prompts[name]) {\n document.getElementById('customPrompt').value = prompts[name];\n }\n }\n } else {\n customBox.classList.add('hidden');\n desc.textContent = MODE_DESC[mode] || '';\n }\n}\n\nasync function doAnalyze() {\n const config = getActiveConfig();\n if (!config.api_url || !config.api_key || !config.model_name) {\n toast('请先在配置页面设置 LLM', 'error');\n return;\n }\n\n const text = document.getElementById('analyzeInput').value.trim();\n if (!text) { toast('请输入需要分析的文本', 'error'); return; }\n\n let mode = document.getElementById('analyzeMode').value;\n let customPrompt = '';\n\n // Handle custom prompt\n if (mode === 'custom' || mode.startsWith('custom:')) {\n customPrompt = document.getElementById('customPrompt').value.trim();\n if (!customPrompt) { toast('请输入自定义提示词', 'error'); return; }\n mode = 'summarize'; // mode doesn't matter, prompt overrides\n }\n\n document.getElementById('analyzeBtn').disabled = true;\n document.getElementById('analyzeLoading').classList.remove('hidden');\n document.getElementById('analyzeResult').classList.add('hidden');\n document.getElementById('analyzeProgress').textContent = '';\n\n const output = document.getElementById('analyzeOutput');\n const modeLabel = MODES.find(m => m.value === mode)?.label || mode;\n document.getElementById('analyzeModeBadge').textContent = modeLabel;\n\n try {\n const result = await API.aiAnalyze(text, mode, customPrompt, config);\n output.textContent = result.result;\n document.getElementById('analyzeResult').classList.remove('hidden');\n sessionStorage.setItem('analyze_result', result.result);\n sessionStorage.setItem('analyze_mode', modeLabel);\n toast('分析完成', 'success');\n } catch (e) {\n toast('分析失败: ' + e.message, 'error');\n document.getElementById('analyzeResult').classList.remove('hidden');\n if (!output.textContent) output.textContent = '错误: ' + e.message;\n } finally {\n document.getElementById('analyzeBtn').disabled = false;\n document.getElementById('analyzeLoading').classList.add('hidden');\n }\n}\n\nfunction saveCustomPreset() {\n const prompt = document.getElementById('customPrompt').value.trim();\n if (!prompt) { toast('请先输入提示词', 'error'); return; }\n\n const name = prompt('预设名称(如:会议纪要、代码审查)');\n if (!name) return;\n\n const prompts = getCustomPrompts();\n prompts[name] = prompt;\n localStorage.setItem('ai_custom_prompts', JSON.stringify(prompts));\n toast(`预设 \"${name}\" 已保存`, 'success');\n}\n\nfunction importResult() {\n const results = JSON.parse(localStorage.getItem('asr_results') || '[]');\n if (results.length === 0) {\n toast('没有转写结果可以导入', 'info');\n return;\n }\n const list = results.map((r, i) =>\n `<div style=\"padding:8px;cursor:pointer;border-bottom:1px solid var(--border)\" data-idx=\"${i}\">\n <strong>${r.filename}</strong>\n <span class=\"text-dim text-sm\"> · ${(r.text || '').length} 字 · ${new Date(r.date).toLocaleString('zh-CN')}</span>\n </div>`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `<div class=\"modal\"><h3>选择要导入的结果</h3>${list || '<p>没有结果</p>'}</div>`;\n document.body.appendChild(overlay);\n\n overlay.querySelectorAll('[data-idx]').forEach(el => {\n el.addEventListener('click', () => {\n const idx = parseInt(el.dataset.idx);\n document.getElementById('analyzeInput').value = results[idx].text || '';\n overlay.remove();\n });\n });\n overlay.addEventListener('click', (e) => {\n if (e.target === overlay) overlay.remove();\n });\n}\n"}}, "frontend/js/pages/config.js": {"4588be819df2e7dd2df7d40a0cc315aacc5ad4ca": {"t": 1785768253.6169906, "n": 26965, "h": "4d9ee42e5b1e6e4f", "c": "// Config page — LLM configuration, per-preset independent keys\nimport { toast } from '../ui.js';\nimport { API } from '../api.js';\n\nconst NANOBOT_PRESETS = {\n sensenova: { url: 'https://token.sensenova.cn/v1', model: 'deepseek-v4-flash', label: 'SenseNova' },\n deepseek: { url: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },\n openai: { url: 'https://api.openai.com/v1', model: 'gpt-4o', label: 'OpenAI' },\n ollama: { url: 'http://localhost:11434/v1', model: 'qwen2.5:7b', label: 'Ollama 本地' },\n};\n\nconst PRESETS = {\n sensenova: { url: 'https://token.sensenova.cn/v1', model: 'deepseek-v4-flash', label: 'SenseNova' },\n deepseek: { url: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },\n openai: { url: 'https://api.openai.com/v1', model: 'gpt-4o', label: 'OpenAI' },\n modelscope: { url: 'https://api-inference.modelscope.cn/v1', model: 'ZhipuAI/GLM-5', label: '魔搭 ModelScope' },\n ollama: { url: 'http://localhost:11434/v1', model: 'qwen2.5:7b', label: 'Ollama 本地' },\n lmstudio: { url: 'http://localhost:1234/v1', model: '', label: 'LM Studio' },\n nanobot: { url: 'http://127.0.0.1:18900/v1', model: 'nanobot', label: 'Nanobot 记忆增强' },\n custom: { url: '', model: '', label: '自定义' },\n};\n\nconst DEFAULT_PROMPTS = {\n deepseek: '你是一个专业的文本助手。请根据用户提供的转写文本进行润色、整理或分析。保持原文的核心信息,改善表达流畅度。',\n sensenova: '你是一个专业的文本助手。请根据用户提供的转写文本进行润色、整理或分析。保持原文的核心信息,改善表达流畅度。',\n openai: '你是一个专业的文本助手。请根据用户提供的转写文本进行润色、整理或分析。保持原文的核心信息,改善表达流畅度。',\n modelscope: '你是一个专业的文本助手。请根据用户提供的转写文本进行润色、整理或分析。保持原文的核心信息,改善表达流畅度。',\n ollama: '你是一个本地AI助手,请帮助我整理和分析文本。',\n lmstudio: '你是一个AI助手,请帮助我处理文本任务。',\n nanobot: '', // 由 SOUL.md 控制\n custom: '',\n};\n\nfunction storageKey(preset) {\n return `asr_llm_config_${preset}`;\n}\n\nfunction loadPresetConfig(preset) {\n try {\n return JSON.parse(localStorage.getItem(storageKey(preset)) || '{}');\n } catch { return {}; }\n}\n\nfunction getActivePreset() {\n return localStorage.getItem('asr_llm_config_active') || 'sensenova';\n}\n\nfunction setActivePreset(name) {\n localStorage.setItem('asr_llm_config_active', name);\n}\n\nexport function render() {\n const activePreset = getActivePreset();\n const saved = loadPresetConfig(activePreset);\n\n // Load saved Obsidian path\n let savedObsidianPath = '';\n try { savedObsidianPath = JSON.parse(localStorage.getItem('asr_obsidian_config') || '{}').path || ''; } catch {}\n\n let presetBtns = Object.entries(PRESETS).map(([key, p]) =>\n `<button class=\"preset-btn ${key === activePreset ? 'active' : ''}\" data-preset=\"${key}\">${p.label}</button>`\n ).join('');\n\n return `\n <div class=\"card\" style=\"border:2px solid var(--primary)\">\n <h2>智能记忆管家(Nanobot)</h2>\n <div class=\"form-hint\" style=\"margin-bottom:12px\">\n Nanobot 会学习你的文本偏好,越用越懂你。启动后在对话页和润色/分析中自动带上你的风格。\n </div>\n <div id=\"nanobotSection\">\n <div class=\"text-dim\" style=\"text-align:center;padding:12px\">加载中...</div>\n </div>\n </div>\n\n <div class=\"card\">\n <h2>LLM 预设模板(每个预设独立保存)</h2>\n <div class=\"presets\" id=\"presetBtns\">${presetBtns}</div>\n\n <form id=\"configForm\">\n <div class=\"form-group\">\n <label>API 地址</label>\n <input type=\"text\" class=\"form-input\" id=\"apiUrl\" value=\"${saved.api_url || PRESETS[activePreset].url}\" placeholder=\"https://api.deepseek.com\">\n </div>\n <div class=\"form-group\">\n <label>API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"apiKey\" value=\"${saved.api_key || ''}\" placeholder=\"sk-...\" autocomplete=\"off\">\n <div class=\"form-hint\" style=\"color:var(--warning)\">每个预设独立保存,切换预设不会串 Key</div>\n </div>\n <div class=\"form-group\">\n <label>模型名称</label>\n <input type=\"text\" class=\"form-input\" id=\"modelName\" value=\"${saved.model_name || PRESETS[activePreset].model}\" placeholder=\"deepseek-chat\">\n </div>\n <div class=\"form-group\">\n <label>系统提示词</label>\n <textarea class=\"form-input\" id=\"systemPrompt\" rows=\"4\"></textarea>\n </div>\n <div class=\"form-group\">\n <label>Temperature: <span id=\"tempValue\">${saved.temperature || 0.3}</span></label>\n <input type=\"range\" class=\"form-input\" id=\"temperature\" min=\"0\" max=\"1\" step=\"0.05\" value=\"${saved.temperature || 0.3}\">\n </div>\n <div class=\"btn-group\">\n <button type=\"button\" class=\"btn btn-primary\" id=\"saveTestBtn\">保存并测试</button>\n <button type=\"button\" class=\"btn btn-secondary\" id=\"saveBtn\">仅保存</button>\n <button type=\"button\" class=\"btn btn-danger\" id=\"clearBtn\">清除此预设</button>\n </div>\n </form>\n </div>\n\n <div class=\"card\">\n <h2>连接状态</h2>\n <div class=\"status-indicator\">\n <span class=\"status-dot gray\" id=\"statusDot\"></span>\n <span id=\"statusText\">未配置</span>\n </div>\n <div class=\"config-summary mt-8\">\n <p>当前预设: <code id=\"currentPreset\">${PRESETS[activePreset].label}</code></p>\n <p>API 地址: <code id=\"currentUrl\">${saved.api_url || '-'}</code></p>\n <p>模型: <code id=\"currentModel\">${saved.model_name || '-'}</code></p>\n <p>Key 已设置: <code id=\"currentKey\">${saved.api_key ? '是' : '否'}</code></p>\n </div>\n </div>\n\n <div id=\"testResult\" class=\"hidden\" style=\"padding:12px;border-radius:var(--radius-sm);margin-bottom:16px\"></div>\n\n <div class=\"card\">\n <details>\n <summary style=\"cursor:pointer\"><h2 style=\"display:inline;font-size:1.15em\">背景设置</h2></summary>\n <div style=\"margin-top:12px\">\n <div class=\"form-group\">\n <label>背景图片 URL(可选)</label>\n <input type=\"text\" class=\"form-input\" id=\"bgImageInput\" value=\"${localStorage.getItem('asr_bg_image') || ''}\" placeholder=\"https://example.com/wallpaper.jpg\">\n <div class=\"form-hint\">设置后页面背景将使用此图片,半透明遮罩保证内容可读。留空恢复默认。</div>\n </div>\n <div class=\"btn-group\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"setBgBtn\">应用背景</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"clearBgBtn\">清除背景</button>\n </div>\n </div>\n </details>\n </div>\n\n <div class=\"card\">\n <h2>Obsidian 知识库同步</h2>\n <div class=\"form-hint\" style=\"margin-bottom:12px\">\n 将本地 Obsidian 笔记同步到对应导师的知识库,自动按学科分类。\n </div>\n <div id=\"obsidianSection\">\n <div class=\"form-group\">\n <label>Obsidian 仓库路径</label>\n <input type=\"text\" class=\"form-input\" id=\"obsidianPath\" value=\"${savedObsidianPath || 'D:\\\\my-note'}\" placeholder=\"D:\\\\my-note\">\n </div>\n <div style=\"display:flex;align-items:center;gap:12px;margin-bottom:8px\">\n <button type=\"button\" class=\"btn btn-primary\" id=\"obsidianSyncBtn\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\"/></svg>\n 立即同步\n </button>\n <span class=\"text-dim text-sm\" id=\"obsidianStatus\"></span>\n </div>\n <div class=\"form-hint\" id=\"obsidianLastSync\"></div>\n </div>\n </div>\n `;\n}\n\nexport function init() {\n document.querySelectorAll('.preset-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n switchPreset(btn.dataset.preset);\n });\n });\n\n document.getElementById('temperature').addEventListener('input', (e) => {\n document.getElementById('tempValue').textContent = parseFloat(e.target.value).toFixed(2);\n });\n\n document.getElementById('saveTestBtn').addEventListener('click', saveAndTest);\n document.getElementById('saveBtn').addEventListener('click', () => { saveConfig(); toast('配置已保存', 'success'); });\n document.getElementById('clearBtn').addEventListener('click', clearPreset);\n\n // Init textarea with active preset's system_prompt\n const activePreset = getActivePreset();\n const saved = loadPresetConfig(activePreset);\n document.getElementById('systemPrompt').value = saved.system_prompt || DEFAULT_PROMPTS[activePreset] || '';\n document.getElementById('tempValue').textContent = saved.temperature || 0.3;\n\n // Background image\n document.getElementById('setBgBtn').addEventListener('click', () => {\n const url = document.getElementById('bgImageInput').value.trim();\n if (url) {\n localStorage.setItem('asr_bg_image', url);\n document.body.style.setProperty('--bg-image', `url(\"${url.replace(/\"/g, '\\\\\"')}\")`);\n toast('背景已应用', 'success');\n } else {\n localStorage.removeItem('asr_bg_image');\n document.body.style.setProperty('--bg-image', 'none');\n toast('已清除背景', 'info');\n }\n });\n document.getElementById('clearBgBtn').addEventListener('click', () => {\n document.getElementById('bgImageInput').value = '';\n localStorage.removeItem('asr_bg_image');\n document.body.style.setProperty('--bg-image', 'none');\n toast('背景已清除', 'info');\n });\n\n updateStatus();\n initNanobot();\n initObsidianSync();\n}\n\nfunction switchPreset(name) {\n setActivePreset(name);\n const saved = loadPresetConfig(name);\n const defaults = PRESETS[name];\n\n document.getElementById('apiUrl').value = saved.api_url || defaults.url || '';\n document.getElementById('apiKey').value = saved.api_key || '';\n document.getElementById('modelName').value = saved.model_name || defaults.model || '';\n document.getElementById('systemPrompt').value = saved.system_prompt || DEFAULT_PROMPTS[name] || '';\n document.getElementById('temperature').value = saved.temperature || 0.3;\n document.getElementById('tempValue').textContent = saved.temperature || 0.3;\n\n // Clear test result\n const el = document.getElementById('testResult');\n el.classList.add('hidden');\n updateStatus();\n}\n\nfunction applyPreset(name) {\n const p = PRESETS[name];\n if (!p) return;\n document.getElementById('apiUrl').value = p.url;\n document.getElementById('modelName').value = p.model;\n}\n\nfunction getConfig() {\n return {\n api_url: document.getElementById('apiUrl').value.trim(),\n api_key: document.getElementById('apiKey').value.trim(),\n model_name: document.getElementById('modelName').value.trim(),\n system_prompt: document.getElementById('systemPrompt').value.trim(),\n temperature: parseFloat(document.getElementById('temperature').value) || 0.3,\n };\n}\n\nfunction getCurrentPresetName() {\n return document.querySelector('.preset-btn.active')?.dataset?.preset || 'custom';\n}\n\nasync function saveConfig() {\n const preset = getCurrentPresetName();\n const config = getConfig();\n localStorage.setItem(storageKey(preset), JSON.stringify(config));\n // Sync to backend so /chat endpoint knows the API key\n try {\n await API.setLlmConfig(config);\n } catch {}\n updateStatus();\n}\n\nasync function saveAndTest() {\n const c = getConfig();\n if (!c.api_url || !c.api_key || !c.model_name) {\n toast('请填写 API 地址、Key 和模型名称', 'error');\n return;\n }\n saveConfig();\n const el = document.getElementById('testResult');\n el.className = '';\n el.textContent = '正在测试连接...';\n el.classList.remove('hidden');\n try {\n const r = await API.llmTest(c);\n el.className = '';\n el.style.background = 'var(--success-bg)';\n el.style.color = 'var(--success)';\n el.style.border = '1px solid var(--success)';\n el.textContent = '连接成功!' + (r.reply ? '回复: ' + r.reply : '');\n updateStatus(true);\n } catch (e) {\n el.style.background = 'var(--error-bg)';\n el.style.color = 'var(--error)';\n el.style.border = '1px solid var(--error)';\n el.textContent = '连接失败: ' + e.message;\n updateStatus(false);\n }\n el.classList.remove('hidden');\n}\n\nfunction clearPreset() {\n const preset = getCurrentPresetName();\n localStorage.removeItem(storageKey(preset));\n const defaults = PRESETS[preset];\n document.getElementById('apiKey').value = '';\n document.getElementById('apiUrl').value = defaults.url || '';\n document.getElementById('modelName').value = defaults.model || '';\n document.getElementById('systemPrompt').value = DEFAULT_PROMPTS[preset] || '';\n updateStatus();\n toast('当前预设配置已清除', 'info');\n}\n\nfunction updateStatus(connected) {\n const dot = document.getElementById('statusDot');\n const txt = document.getElementById('statusText');\n const preset = getCurrentPresetName();\n const c = loadPresetConfig(preset);\n\n document.getElementById('currentPreset').textContent = PRESETS[preset]?.label || preset;\n document.getElementById('currentUrl').textContent = c.api_url || '-';\n document.getElementById('currentModel').textContent = c.model_name || '-';\n document.getElementById('currentKey').textContent = c.api_key ? '是' : '否';\n\n if (!c.api_url || !c.api_key || !c.model_name) {\n dot.className = 'status-dot gray';\n txt.textContent = '未配置';\n } else if (connected === true) {\n dot.className = 'status-dot green';\n txt.textContent = '已连接';\n } else if (connected === false) {\n dot.className = 'status-dot red';\n txt.textContent = '连接失败';\n } else {\n dot.className = 'status-dot yellow';\n txt.textContent = '已配置(未测试)';\n }\n}\n\n// ============ nanobot 记忆管家 ============\nimport * as Sidebar from '../components/sidebar.js';\n\nasync function initNanobot() {\n const section = document.getElementById('nanobotSection');\n if (!section) return;\n\n try {\n const [st, provider] = await Promise.all([\n API.nanobotStatus(),\n API.getNanobotProvider(),\n ]);\n Sidebar.updateNanobotStatus(st.running);\n renderNanobotSection(st, provider);\n loadAllNanobotFiles();\n } catch (e) {\n section.innerHTML = `<div class=\"text-dim\" style=\"padding:8px\">nanobot 服务不可用(后端可能未启动)</div>`;\n }\n}\n\nfunction renderNanobotSection(st, provider) {\n const section = document.getElementById('nanobotSection');\n if (!section) return;\n\n const enabled = st.enabled;\n const running = st.running;\n\n // Status bar\n const statusHTML = `\n <div style=\"display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px\">\n <span class=\"status-dot ${running ? 'green' : 'yellow'}\"></span>\n <span>${running ? '运行中' : (enabled ? '已启用(未启动)' : '未启用')}</span>\n ${running ? '<span class=\"badge badge-completed\">就绪</span>' : ''}\n <button class=\"btn ${enabled ? 'btn-danger' : 'btn-primary'}\" id=\"nbToggleBtn\" style=\"margin-left:auto\">\n ${enabled ? '停止 Nanobot' : '一键启动 Nanobot'}\n </button>\n <button class=\"btn btn-ghost btn-sm\" id=\"nbRefreshBtn\" title=\"刷新\">刷新</button>\n </div>\n `;\n\n // File editors (all collapsible)\n const editors = [\n { file: 'SOUL.md', title: 'SOUL.md 人设', desc: 'AI 的角色定义和行为风格' },\n { file: 'USER.md', title: 'USER.md 用户档案', desc: '用户偏好、常用术语、风格偏好' },\n { file: 'MEMORY.md', title: 'MEMORY.md 项目记忆', desc: 'Dream 自动学习的项目记忆' },\n { file: 'AGENTS.md', title: 'AGENTS.md', desc: '智能体配置规则' },\n { file: 'TOOLS.md', title: 'TOOLS.md', desc: '工具定义和使用说明' },\n { file: 'HEARTBEAT.md', title: 'HEARTBEAT.md', desc: '心跳任务配置' },\n ];\n\n const editorsHTML = editors.map(e => `\n <details style=\"margin-bottom:12px\">\n <summary style=\"cursor:pointer;font-size:0.95em;color:var(--muted-fg)\"><strong>${e.title}</strong> <span class=\"text-dim text-sm\">${e.desc}</span></summary>\n <div style=\"margin-top:12px\">\n <div class=\"form-group\">\n <textarea class=\"form-input\" id=\"nbContent_${e.file.replace(/[^a-zA-Z]/g, '_')}\" rows=\"6\" style=\"font-family:var(--font-mono,monospace);font-size:0.85em\" placeholder=\"加载中...\"></textarea>\n <div class=\"flex gap-8 mt-8\" style=\"flex-wrap:wrap\">\n <button class=\"btn btn-primary btn-sm\" data-nb-save=\"${e.file}\">保存</button>\n <button class=\"btn btn-ghost btn-sm\" data-nb-reset=\"${e.file}\">恢复默认</button>\n <span class=\"text-sm text-dim\" id=\"nbStatus_${e.file.replace(/[^a-zA-Z]/g, '_')}\"></span>\n </div>\n </div>\n </div>\n </details>\n `).join('');\n\n // Dream settings\n const dreamIntervals = [\n [0.5, '30分钟'], [1, '1小时'], [2, '2小时'], [4, '4小时'], [0, '关闭'],\n ];\n const savedInterval = localStorage.getItem('asr_nanobot_dream_interval');\n const currentInterval = savedInterval != null ? parseFloat(savedInterval) : 2;\n\n const dreamHTML = `\n <details style=\"margin-top:12px;border-top:1px solid var(--border);padding-top:8px\">\n <summary style=\"cursor:pointer;font-size:0.85em;color:var(--muted-fg)\">Dream 机制设置</summary>\n <div style=\"margin-top:12px\">\n <div class=\"form-group\">\n <label>Dream 间隔</label>\n <select class=\"form-input\" id=\"nbDreamInterval\">\n ${dreamIntervals.map(([v, l]) => `<option value=\"${v}\" ${v === currentInterval ? 'selected' : ''}>${l}</option>`).join('')}\n </select>\n <div class=\"form-hint\">Dream 会在后台定期从交互历史中提取偏好、术语,自动更新 USER.md 和 MEMORY.md。</div>\n </div>\n <div class=\"flex gap-8 mt-8\" style=\"flex-wrap:wrap\">\n <button class=\"btn btn-secondary btn-sm\" id=\"nbDreamSave\">保存间隔</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"nbDreamTrigger\">手动触发 Dream</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"nbDreamClearHistory\">清空交互历史</button>\n </div>\n </div>\n </details>\n `;\n\n // Provider settings (existing)\n const providerHTML = `\n <details style=\"margin-top:12px;border-top:1px solid var(--border);padding-top:8px\">\n <summary style=\"cursor:pointer;font-size:0.85em;color:var(--muted-fg)\">高级设置(独立 LLM 配置)</summary>\n <div style=\"margin-top:8px\">\n <div style=\"margin-bottom:8px\">\n <label style=\"font-size:0.82em;color:var(--muted-fg)\">快捷预设</label>\n <div id=\"nbPresets\" style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${Object.entries(NANOBOT_PRESETS).map(([k, p]) =>\n `<button type=\"button\" class=\"preset-btn nb-preset ${k === 'sensenova' ? 'active' : ''}\" data-nbpreset=\"${k}\">${p.label}</button>`\n ).join('')}\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:8px\">\n <label>API 地址</label>\n <input type=\"text\" class=\"form-input form-input-sm\" id=\"nbApiUrl\" value=\"${provider?.api_url || ''}\" placeholder=\"https://token.sensenova.cn/v1\">\n </div>\n <div class=\"form-group\" style=\"margin-bottom:8px\">\n <label>API Key</label>\n <input type=\"password\" class=\"form-input form-input-sm\" id=\"nbApiKey\" value=\"\" placeholder=\"sk-...\" autocomplete=\"off\">\n <div class=\"form-hint text-sm\" style=\"color:var(--warning)\">独立保存,切换回继承模式不会丢失</div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:8px\">\n <label>模型名称</label>\n <input type=\"text\" class=\"form-input form-input-sm\" id=\"nbModelName\" value=\"${provider?.model_name || ''}\" placeholder=\"deepseek-v4-flash\">\n </div>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\">\n <button class=\"btn btn-secondary btn-sm\" id=\"nbProviderSave\">保存(独立配置生效)</button>\n <button class=\"btn btn-ghost btn-sm\" id=\"nbInherit\">恢复继承模式</button>\n </div>\n </div>\n </details>\n `;\n\n section.innerHTML = statusHTML + editorsHTML + dreamHTML + providerHTML;\n\n // Wire events\n document.getElementById('nbToggleBtn').addEventListener('click', async () => {\n const btn = document.getElementById('nbToggleBtn');\n btn.disabled = true;\n try {\n if (!enabled) {\n const r = await API.nanobotStart();\n if (r.success) { toast(r.message, 'success'); reloadNanobotStatus(); loadAllNanobotFiles(); }\n else { toast(r.message, 'error'); }\n } else {\n const r = await API.nanobotStop();\n if (r.success) { toast('Nanobot 已停止', 'info'); reloadNanobotStatus(); }\n else { toast(r.message, 'error'); }\n }\n } catch (err) { toast('操作失败: ' + err.message, 'error'); }\n btn.disabled = false;\n });\n\n document.getElementById('nbRefreshBtn').addEventListener('click', () => { loadAllNanobotFiles(); });\n\n // Save buttons\n document.querySelectorAll('[data-nb-save]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const filename = btn.dataset.nbSave;\n const id = filename.replace(/[^a-zA-Z]/g, '_');\n const ta = document.getElementById('nbContent_' + id);\n const statusEl = document.getElementById('nbStatus_' + id);\n if (!ta) return;\n btn.disabled = true;\n try {\n const r = await API.saveNanobotWorkspace(filename, ta.value);\n if (r.success) {\n statusEl.innerHTML = '<span style=\"color:var(--success)\">已保存</span>';\n toast(filename + ' 已保存', 'success');\n setTimeout(() => { if (statusEl) statusEl.innerHTML = ''; }, 2000);\n }\n } catch (err) {\n statusEl.innerHTML = '<span style=\"color:var(--error)\">保存失败</span>';\n }\n btn.disabled = false;\n });\n });\n\n // Reset buttons\n document.querySelectorAll('[data-nb-reset]').forEach(btn => {\n btn.addEventListener('click', async () => {\n const filename = btn.dataset.nbReset;\n const id = filename.replace(/[^a-zA-Z]/g, '_');\n const ta = document.getElementById('nbContent_' + id);\n if (!ta) return;\n try {\n const r = await API.resetNanobotMemory(filename);\n if (r.success) {\n ta.value = (await API.getNanobotFile(filename)).content || '';\n toast(filename + ' 已恢复默认', 'info');\n }\n } catch (err) { toast('重置失败: ' + err.message, 'error'); }\n });\n });\n\n // Nanobot preset buttons\n document.querySelectorAll('.nb-preset').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('.nb-preset').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n const p = NANOBOT_PRESETS[btn.dataset.nbpreset];\n document.getElementById('nbApiUrl').value = p.url;\n document.getElementById('nbModelName').value = p.model;\n });\n });\n\n document.getElementById('nbProviderSave')?.addEventListener('click', async () => {\n const c = {\n api_url: document.getElementById('nbApiUrl').value.trim(),\n api_key: document.getElementById('nbApiKey').value.trim(),\n model_name: document.getElementById('nbModelName').value.trim(),\n mode: 'independent',\n };\n if (!c.api_url || !c.api_key || !c.model_name) {\n toast('请填写 API 地址、Key 和模型名称', 'error'); return;\n }\n try {\n const r = await API.setNanobotProvider(c);\n toast(r.message || '已保存(下次启动生效)', 'success');\n } catch (err) { toast('保存失败: ' + err.message, 'error'); }\n });\n\n document.getElementById('nbInherit')?.addEventListener('click', async () => {\n try {\n await API.setNanobotProvider({ mode: 'inherit' });\n toast('已切换为继承模式(使用当前 LLM 预设)', 'success');\n reloadNanobotStatus();\n } catch (err) { toast('切换失败: ' + err.message, 'error'); }\n });\n\n // Dream interval save\n document.getElementById('nbDreamSave')?.addEventListener('click', async () => {\n const interval = parseFloat(document.getElementById('nbDreamInterval').value);\n localStorage.setItem('asr_nanobot_dream_interval', String(interval));\n try {\n await API.setNanobotConfig({ enabled: st.enabled, dream_interval_h: interval });\n toast('Dream 间隔已保存', 'success');\n } catch (err) { toast('保存失败: ' + err.message, 'error'); }\n });\n\n // Dream manual trigger\n document.getElementById('nbDreamTrigger')?.addEventListener('click', () => {\n toast('Dream 任务已触发(请查看 Nanobot 日志确认)', 'info');\n });\n\n // Clear history\n document.getElementById('nbDreamClearHistory')?.addEventListener('click', () => {\n if (!confirm('确定清空所有交互历史?Dream 将失去历史上下文。\\n\\n请手动删除: ' + (st.workspace || '.nanobot-workspace') + '/memory/history.jsonl')) return;\n });\n}\n\nasync function loadAllNanobotFiles() {\n const files = ['SOUL.md', 'USER.md', 'MEMORY.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md'];\n for (const fn of files) {\n const id = fn.replace(/[^a-zA-Z]/g, '_');\n const ta = document.getElementById('nbContent_' + id);\n if (!ta) continue;\n try {\n const r = await API.getNanobotFile(fn);\n ta.value = r.content || '';\n } catch { ta.value = ''; }\n }\n}\n\nasync function reloadNanobotStatus() {\n try {\n const [st, provider] = await Promise.all([\n API.nanobotStatus(),\n API.getNanobotProvider(),\n ]);\n renderNanobotSection(st, provider);\n loadAllNanobotFiles();\n } catch {}\n}\n\n// ============ Obsidian 知识库同步 ============\n\nasync function initObsidianSync() {\n // Load last sync status\n try {\n const status = await API.obsidianStatus();\n const statusEl = document.getElementById('obsidianStatus');\n const lastSyncEl = document.getElementById('obsidianLastSync');\n if (status.last_sync) {\n lastSyncEl.textContent = `上次同步: ${new Date(status.last_sync * 1000).toLocaleString('zh-CN')} · ${status.last_synced} 文件已同步`;\n }\n } catch {}\n\n // Save Obsidian path on input change\n document.getElementById('obsidianPath')?.addEventListener('change', (e) => {\n try {\n const cfg = JSON.parse(localStorage.getItem('asr_obsidian_config') || '{}');\n cfg.path = e.target.value;\n localStorage.setItem('asr_obsidian_config', JSON.stringify(cfg));\n } catch {}\n });\n\n // Sync button\n document.getElementById('obsidianSyncBtn')?.addEventListener('click', async () => {\n const btn = document.getElementById('obsidianSyncBtn');\n const statusEl = document.getElementById('obsidianStatus');\n btn.disabled = true;\n btn.innerHTML = '<span class=\"spinner\" style=\"width:14px;height:14px;display:inline-block\"></span> 同步中...';\n statusEl.textContent = '';\n\n const path = document.getElementById('obsidianPath')?.value?.trim();\n if (!path) {\n toast('请输入 Obsidian 仓库路径', 'error');\n btn.disabled = false;\n btn.innerHTML = '立即同步';\n return;\n }\n\n try {\n const result = await API.obsidianSync(path);\n if (result.success) {\n toast(result.message, 'success');\n statusEl.innerHTML = `<span style=\"color:var(--success)\">✓ ${result.files_synced} 新增, ${result.files_skipped} 跳过</span>`;\n document.getElementById('obsidianLastSync').textContent =\n `上次同步: ${new Date().toLocaleString('zh-CN')} · 共 ${result.files_scanned} 文件`;\n } else {\n toast(result.message || '同步失败', 'error');\n }\n } catch (err) {\n toast('同步失败: ' + err.message, 'error');\n }\n btn.disabled = false;\n btn.innerHTML = `\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\"/></svg>\n 立即同步`;\n });\n}\n"}}, "frontend/js/pages/chat.js": {"4588be819df2e7dd2df7d40a0cc315aacc5ad4ca": {"t": 1785768254.4782045, "n": 39789, "h": "c434133f8acf0bfc", "c": "// Chat page — DeepTutor style: Markdown, streaming, sessions, thinking cards\nimport { toast, copyToClipboard } from '../ui.js';\nimport { getActiveSession, addMessage, updateLastAssistant, getAllSessions } from '../store.js';\nimport { navigate } from '../router.js';\n\n// Module-level state — reset on each init() call\nlet isStreaming = false;\nlet abortController = null;\nlet currentStreamingEl = null;\nlet currentSessionId = null;\nlet titleGenDone = false; // track if AI title generation has run for this session\n\n// Tutor presets\nconst TUTORS = {\n general: { name: '通用助手', icon: '🤖', system: '你是一个全能的AI助手,擅长回答问题、分析文本和提供建议。请用简洁、专业的语言回答。' },\n math: { name: '数学导师', icon: '📐', system: '你是一名数学导师,擅长用严谨的数学语言解释概念。回答时请使用准确的数学符号和公式(用 $...$ 或 $$...$$ 包裹 LaTeX 公式),并给出详细的推导步骤。' },\n language: { name: '语文导师', icon: '📝', system: '你是一名语文导师,擅长文本分析、写作指导和文学鉴赏。请用优美的语言帮助学生理解文章的结构、修辞和思想内涵。' },\n english: { name: '英语导师', icon: '🌍', system: 'You are an English tutor. Help with grammar, vocabulary, writing, and reading comprehension. Correct mistakes gently and explain the rules clearly.' },\n physics: { name: '物理导师', icon: '⚡', system: '你是一名物理导师,擅长用物理直觉和数学工具解释现象。回答时请用清晰的逻辑,从基本原理出发推导结论,并使用 LaTeX 公式($...$ 或 $$...$$)表达。' },\n programming: { name: '编程导师', icon: '💻', system: '你是一名编程导师,擅长代码审查、算法设计和调试。回答时请给出完整的代码示例,并解释关键思路。用 ```语言 包裹代码块。' },\n asr: { name: 'ASR 润色', icon: '🎙️', system: '你是专业的ASR文本润色助手。擅长修正语音识别错误、优化文本流畅度和格式。请保持原文意思不变,仅改善表达方式。' },\n};\n\n// Context: load from transcribe_list API\nlet contextFiles = [];\n\n// Tutor cache: loaded from Nanobot API, falls back to local TUTORS\nlet tutorCache = {}; // { name: { soul, knowledge } }\n\nexport function render() {\n const session = getActiveSession();\n if (!session) return '<div class=\"card\"><p>会话异常,请刷新页面</p></div>';\n\n const msgs = session.messages || [];\n\n // Welcome screen\n const activeTutor = localStorage.getItem('asr_active_tutor') || 'general';\n const tutor = TUTORS[activeTutor] || TUTORS.general;\n const welcomeHTML = msgs.length === 0 ? `\n <div class=\"chat-welcome\">\n <div class=\"chat-welcome-avatar\">${tutor.icon || '🤖'}</div>\n <h2>有什么想聊的?</h2>\n <p class=\"chat-welcome-subtitle\">与${tutor.name}对话</p>\n <div class=\"chat-suggestions\">\n <button class=\"suggestion-btn\" data-text=\"帮我润色这段文本:\">\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z\"/><polyline points=\"14 2 14 8 20 8\"/><line x1=\"16\" y1=\"13\" x2=\"8\" y2=\"13\"/><line x1=\"16\" y1=\"17\" x2=\"8\" y2=\"17\"/></svg>\n 润色文本\n </button>\n <button class=\"suggestion-btn\" data-text=\"帮我总结一下:\">\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><line x1=\"8\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"8\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"8\" y1=\"18\" x2=\"21\" y2=\"18\"/><line x1=\"3\" y1=\"6\" x2=\"3.01\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"3.01\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"3.01\" y2=\"18\"/></svg>\n 内容摘要\n </button>\n <button class=\"suggestion-btn\" data-text=\"这段代码有什么问题?\">\n <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n 代码审查\n </button>\n </div>\n </div>\n ` : '';\n\n const msgsHTML = msgs.map((m, i) => messageBubble(m, i, session)).join('');\n\n // Context chips\n const fileChip = pendingFileContext ? `<div class=\"context-chip\">\n <svg viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n ${escCtx(pendingFileContext.name)}\n <button class=\"context-chip-remove\" id=\"ctxChipRemove\">×</button>\n </div>` : '';\n const tutorChip = activeTutor !== 'general' ? `<div class=\"context-chip\" title=\"当前导师\">${escCtx(tutor.name)}\n <button class=\"context-chip-remove\" id=\"tutorChipRemove\">×</button>\n </div>` : '';\n const contextChipsHTML = (fileChip || tutorChip) ? `<div class=\"context-chips\">${fileChip}${tutorChip}</div>` : '';\n\n return `\n <div class=\"chat-page\">\n ${welcomeHTML}\n <div class=\"chat-messages\" id=\"chatMessages\">${msgsHTML}</div>\n\n <div class=\"chat-composer\">\n ${contextChipsHTML}\n <!-- File preview area -->\n <div id=\"chatFilePreview\" class=\"chat-file-preview hidden\"></div>\n <div class=\"chat-composer-row\">\n <!-- Compose menu button (plus/minus toggle) -->\n <button class=\"compose-menu-btn\" id=\"composeMenuBtn\" title=\"上传文件 / 选择导师\">\n <span class=\"compose-plus\">+</span>\n <span class=\"compose-minus\">-</span>\n </button>\n <textarea id=\"chatInput\" rows=\"1\" placeholder=\"输入消息... (Enter 发送, Shift+Enter 换行)\"></textarea>\n ${isStreaming ? `<button class=\"chat-stop-btn\" id=\"chatStopBtn\" title=\"停止生成\"><svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"currentColor\"><rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\"/></svg></button>` : `<button class=\"chat-send-btn\" id=\"chatSendBtn\" title=\"发送\"><svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\"/><polygon points=\"22 2 15 22 11 13 2 9 22 2\"/></svg></button>`}\n </div>\n <div class=\"chat-composer-hint\">按 Enter 发送,Shift+Enter 换行</div>\n\n <!-- Compose menu dropdown -->\n <div class=\"compose-menu hidden\" id=\"composeMenu\">\n <div class=\"compose-menu-section\">\n <div class=\"compose-menu-label\">上传文件</div>\n <div class=\"compose-menu-item\" id=\"composeUpload\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48\"/></svg>\n 选择文件\n </div>\n <input type=\"file\" id=\"chatFileInput\" hidden>\n </div>\n <div class=\"compose-menu-section\">\n <div class=\"compose-menu-label\">选择导师</div>\n <div class=\"compose-menu-item tutor-menu-item\" data-tutor=\"general\">\n 🤖 通用助手\n </div>\n <div class=\"compose-menu-item tutor-menu-item\" data-tutor=\"math\">\n 📐 数学导师\n </div>\n <div class=\"compose-menu-item tutor-menu-item\" data-tutor=\"physics\">\n ⚡ 物理导师\n </div>\n </div>\n </div>\n </div>\n </div>\n `;\n}\n\nexport function init() {\n const session = getActiveSession();\n if (!session) return;\n\n // Reset streaming state ONLY on session switch (prevents stale stop button\n // after navigating away during stream, without breaking mid-send reRender)\n if (session.id !== currentSessionId) {\n if (abortController) abortController.abort();\n isStreaming = false;\n abortController = null;\n currentStreamingEl = null;\n }\n\n // Track session to detect switching\n currentSessionId = session.id;\n titleGenDone = session.messages.length > 2; // if already has exchange, skip title gen\n\n const input = document.getElementById('chatInput');\n const sendBtn = document.getElementById('chatSendBtn');\n const stopBtn = document.getElementById('chatStopBtn');\n const msgs = document.getElementById('chatMessages');\n const composeBtn = document.getElementById('composeMenuBtn');\n const composeMenu = document.getElementById('composeMenu');\n\n // ── Compose menu toggle ──\n composeBtn?.addEventListener('click', (e) => {\n e.stopPropagation();\n const hidden = composeMenu?.classList.toggle('hidden');\n composeBtn?.classList.toggle('expanded', !hidden);\n });\n\n // ── Upload from compose menu ──\n document.getElementById('composeUpload')?.addEventListener('click', (e) => {\n e.stopPropagation();\n document.getElementById('chatFileInput')?.click();\n });\n document.getElementById('chatFileInput')?.addEventListener('change', handleFileUpload);\n\n // ── Tutor selection from compose menu ──\n // Mark active tutor on mount\n const activeTutor = localStorage.getItem('asr_active_tutor') || 'general';\n composeMenu?.querySelector(`.tutor-menu-item[data-tutor=\"${activeTutor}\"]`)\n ?.classList.add('active');\n\n document.querySelectorAll('.tutor-menu-item').forEach(item => {\n item.addEventListener('click', () => {\n const tutor = item.dataset.tutor;\n localStorage.setItem('asr_active_tutor', tutor);\n toast(`已切换至: ${TUTORS[tutor].name}`, 'success');\n // Update active highlight\n composeMenu?.querySelectorAll('.tutor-menu-item').forEach(i => i.classList.remove('active'));\n item.classList.add('active');\n composeMenu?.classList.add('hidden');\n composeBtn?.classList.remove('expanded');\n });\n });\n\n // Close compose menu on outside click\n document.addEventListener('click', (e) => {\n if (!e.target.closest('#composeMenuBtn') && !e.target.closest('#composeMenu')) {\n composeMenu?.classList.add('hidden');\n composeBtn?.classList.remove('expanded');\n }\n });\n\n // Auto-resize textarea\n input?.addEventListener('input', () => {\n input.style.height = 'auto';\n input.style.height = Math.min(input.scrollHeight, 120) + 'px';\n });\n\n // Send on Enter\n input?.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n send();\n }\n });\n\n sendBtn?.addEventListener('click', send);\n stopBtn?.addEventListener('click', stopStreaming);\n\n // Context chip removal\n document.getElementById('ctxChipRemove')?.addEventListener('click', () => {\n clearFilePreview();\n });\n document.getElementById('tutorChipRemove')?.addEventListener('click', () => {\n localStorage.setItem('asr_active_tutor', 'general');\n toast('已切换至: 通用助手', 'success');\n reRender();\n });\n\n // Suggestion buttons\n document.querySelectorAll('.suggestion-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n input.value = btn.dataset.text;\n input.focus();\n });\n });\n\n // Copy buttons on assistant messages\n document.querySelectorAll('.chat-copy-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.idx);\n const msg = session.messages[idx];\n if (msg) copyToClipboard(msg.content);\n });\n });\n\n // Follow-up question buttons (event delegation)\n document.getElementById('chatMessages')?.addEventListener('click', (e) => {\n const followupBtn = e.target.closest('.follow-up-btn');\n if (followupBtn) {\n const text = followupBtn.dataset.question;\n if (text && input) {\n input.value = text;\n input.focus();\n send();\n }\n }\n });\n\n smartScrollToBottom(msgs);\n input?.focus();\n\n // Load tutors from Nanobot API (non-blocking, fallback to local TUTORS)\n loadTutorsFromAPI();\n}\n\nasync function loadTutorsFromAPI() {\n const { API } = await import('../api.js');\n if (!API?.nanobotListTutors) return;\n try {\n const data = await API.nanobotListTutors();\n const tutors = data?.tutors || [];\n for (const t of tutors) {\n tutorCache[t.name] = { soul: t.soul, knowledge: null };\n }\n // Rebuild compose menu tutor items if API returned tutors\n if (tutors.length > 0) {\n rebuildTutorMenuItems(tutors);\n }\n } catch {\n // Fallback to local TUTORS — already defined\n }\n}\n\nfunction rebuildTutorMenuItems(tutors) {\n const menu = document.getElementById('composeMenu');\n if (!menu) return;\n const section = menu.querySelector('.compose-menu-section:last-child');\n if (!section) return;\n const activeTutor = localStorage.getItem('asr_active_tutor') || 'general';\n section.innerHTML = tutors.map(t =>\n `<div class=\"compose-menu-item tutor-menu-item ${t.name === activeTutor ? 'active' : ''}\" data-tutor=\"${t.name}\">${t.name}</div>`\n ).join('');\n // Re-bind click handlers\n section.querySelectorAll('.tutor-menu-item').forEach(item => {\n item.addEventListener('click', () => {\n const tutor = item.dataset.tutor;\n localStorage.setItem('asr_active_tutor', tutor);\n const name = TUTORS[tutor]?.name || tutor;\n toast(`已切换至: ${name}`, 'success');\n menu?.querySelectorAll('.tutor-menu-item').forEach(i => i.classList.remove('active'));\n item.classList.add('active');\n menu?.classList.add('hidden');\n document.getElementById('composeMenuBtn')?.classList.remove('expanded');\n });\n });\n}\n\n// Load transcribe list and show file picker\nasync function loadContextPicker() {\n const picker = document.getElementById('ctxPicker');\n const listEl = document.getElementById('ctxPickerList');\n if (!picker || !listEl) return;\n\n picker.classList.remove('hidden');\n listEl.innerHTML = '<div class=\"ctx-picker-loading\">加载中...</div>';\n\n try {\n const { API } = await import('../api.js');\n const [transList, results] = await Promise.all([\n API.transcribeList().catch(() => []),\n API.listResults().catch(() => []),\n ]);\n\n contextFiles = [...transList.map(f => ({ id: f.file_id || f.id, name: f.filename || f.name, type: '转写' })),\n ...results.map(f => ({ id: f.file_id || f.id, name: f.filename || f.name, type: '结果' }))];\n\n if (contextFiles.length === 0) {\n listEl.innerHTML = '<div class=\"ctx-picker-empty\">暂无文件,请先转写或分析</div>';\n return;\n }\n\n listEl.innerHTML = contextFiles.map(f =>\n `<div class=\"ctx-picker-file\" data-file-id=\"${f.id}\"><span class=\"ctx-picker-file-type\">${f.type}</span><span class=\"ctx-picker-file-name\">${escCtx(f.name)}</span></div>`\n ).join('');\n\n listEl.querySelectorAll('.ctx-picker-file').forEach(item => {\n item.addEventListener('click', () => {\n const fileId = item.dataset.fileId;\n const file = contextFiles.find(f => f.id === fileId);\n if (file) {\n // Insert file reference into input\n const input = document.getElementById('chatInput');\n if (input) {\n input.value += `[参考文件: ${file.name}] `;\n input.focus();\n }\n toast(`已添加: ${file.name}`, 'success');\n }\n closeCtxPicker();\n });\n });\n } catch (e) {\n listEl.innerHTML = `<div class=\"ctx-picker-empty\">加载失败: ${escCtx(e.message)}</div>`;\n }\n}\n\nfunction closeCtxPicker() {\n document.getElementById('ctxPicker')?.classList.add('hidden');\n}\n\nfunction escCtx(s) {\n if (!s) return '';\n return s.replace(/[&<>\"']/g, m => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":\"'\"}[m]));\n}\n\n/* ═══════════════════════════════════════════\n Send message + streaming\n ═══════════════════════════════════════════ */\n\nasync function send() {\n const input = document.getElementById('chatInput');\n const text = input?.value?.trim();\n if (!text || isStreaming) return;\n\n const session = getActiveSession();\n if (!session) return;\n\n // Load LLM config from localStorage (per-preset)\n const config = loadLlmConfig();\n if (!config.api_url || !config.api_key || !config.model_name) {\n toast('请先在配置页面设置 LLM', 'error');\n navigate('/config');\n return;\n }\n\n // Get file context (if any uploaded)\n const fileContext = pendingFileContext;\n let userContent = text;\n if (fileContext) {\n userContent = `[文件: ${fileContext.name}]\\n${fileContext.content}\\n\\n---\\n\\n${text}`;\n clearFilePreview();\n }\n\n addMessage('user', userContent);\n input.value = '';\n input.style.height = 'auto';\n\n // Add empty assistant message\n addMessage('assistant', '');\n\n isStreaming = true;\n abortController = new AbortController();\n\n // Re-render to show user message + thinking indicator\n reRender();\n\n // Add typing indicator at bottom\n const msgs = document.getElementById('chatMessages');\n if (msgs) {\n const typingEl = document.createElement('div');\n typingEl.className = 'typing-indicator';\n typingEl.id = 'typingIndicator';\n typingEl.innerHTML = '<span>正在生成</span><span class=\"typing-dots\"></span>';\n msgs.appendChild(typingEl);\n smartScrollToBottom(msgs);\n }\n\n const assistantEl = msgs?.querySelector('.chat-msg.assistant:last-child .md-renderer');\n currentStreamingEl = assistantEl;\n\n try {\n let fullText = '';\n let usage = null;\n\n // Capture first exchange for title generation\n const isFirstExchange = !titleGenDone && session.messages.length <= 2;\n let firstUserMsg = '', firstAiReply = '';\n if (isFirstExchange) {\n firstUserMsg = text;\n }\n\n // Build messages for API (with tutor system prompt)\n const apiMessages = buildApiMessages(session, userContent, config);\n\n // Send config to backend so it knows the API key (sync localStorage → server)\n await syncLlmConfigToBackend(config);\n\n await chatStream(apiMessages, config,\n (chunk) => {\n fullText += chunk;\n updateLastAssistant(fullText);\n if (currentStreamingEl) {\n currentStreamingEl.innerHTML = renderMarkdown(fullText, true);\n processThinkingCards(currentStreamingEl);\n }\n smartScrollToBottom(msgs);\n },\n (result) => {\n fullText = result.content || fullText;\n usage = result.usage;\n }\n );\n\n // Finalize\n // Remove typing indicator\n document.getElementById('typingIndicator')?.remove();\n updateLastAssistant(fullText);\n if (usage) {\n session.usage.prompt_tokens += usage.prompt_tokens || 0;\n session.usage.completion_tokens += usage.completion_tokens || 0;\n }\n\n reRender();\n const newMsgs = document.getElementById('chatMessages');\n smartScrollToBottom(newMsgs);\n\n // Title generation (first exchange only)\n if (isFirstExchange && !titleGenDone) {\n titleGenDone = true;\n generateTitle(session.id, firstUserMsg, fullText, config);\n }\n\n // Follow-up questions (async, non-blocking)\n generateFollowUps(session.messages, fullText, config);\n\n // Update sidebar session titles\n updateSidebarSessions();\n } catch (err) {\n document.getElementById('typingIndicator')?.remove();\n toast('发送失败: ' + err.message, 'error');\n updateLastAssistant('错误: ' + err.message);\n reRender();\n } finally {\n document.getElementById('typingIndicator')?.remove();\n isStreaming = false;\n abortController = null;\n currentStreamingEl = null;\n }\n}\n\nfunction stopStreaming() {\n if (abortController) {\n abortController.abort();\n abortController = null;\n }\n isStreaming = false;\n if (currentStreamingEl) {\n const cursor = currentStreamingEl.querySelector('.streaming-cursor');\n if (cursor) cursor.remove();\n }\n reRender();\n}\n\nasync function chatStream(apiMessages, config, onChunk, onComplete) {\n const res = await fetch('/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n messages: apiMessages,\n model: config.model_name,\n temperature: config.temperature || 0.3,\n api_url: config.api_url,\n api_key: config.api_key,\n }),\n signal: abortController?.signal,\n });\n\n if (!res.ok) {\n const err = await res.json().catch(() => ({}));\n throw new Error(err.detail || `HTTP ${res.status}`);\n }\n\n // Check if it's SSE or JSON\n const contentType = res.headers.get('content-type') || '';\n if (contentType.includes('application/json')) {\n const data = await res.json();\n const content = data.choices?.[0]?.message?.content || '';\n onChunk(content);\n onComplete({ content, usage: data.usage });\n return;\n }\n\n // SSE streaming\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let fullText = '';\n let lastUsage = null;\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed.startsWith('data: ')) continue;\n const data = trimmed.slice(6);\n if (data === '[DONE]') continue;\n try {\n const parsed = JSON.parse(data);\n const chunk = parsed.choices?.[0]?.delta?.content;\n if (chunk) {\n fullText += chunk;\n onChunk(chunk);\n }\n if (parsed.choices?.[0]?.finish_reason) {\n lastUsage = parsed.usage || null;\n }\n } catch (e) {\n if (!e.message?.startsWith('Unexpected')) throw e;\n }\n }\n }\n\n onComplete({ content: fullText, usage: lastUsage });\n}\n\nfunction buildApiMessages(session, newText, config) {\n const contexts = buildContextText(session);\n\n // Tutor system prompt — prefer API cache, fallback to local TUTORS\n const activeTutor = localStorage.getItem('asr_active_tutor') || 'general';\n const tutorSoul = tutorCache[activeTutor]?.soul || TUTORS[activeTutor]?.system || TUTORS.general.system;\n const baseSystem = config.system_prompt || tutorSoul;\n\n const fullSystem = contexts ? `${baseSystem}\\n\\n## 参考上下文\\n${contexts}` : baseSystem;\n\n return [\n { role: 'system', content: fullSystem },\n ...session.messages.map(m => ({ role: m.role, content: m.content })),\n { role: 'user', content: newText },\n ];\n}\n\nfunction buildContextText(session) {\n if (!session.contexts || session.contexts.length === 0) return '';\n let text = '';\n for (const ctx of session.contexts) {\n if (ctx.text) {\n text += `### ${ctx.label || ctx.key}\\n${ctx.text}\\n\\n`;\n }\n }\n return text.trim();\n}\n\n/* ═══════════════════════════════════════════\n Sync localStorage LLM config → backend\n ═══════════════════════════════════════════ */\n\nasync function syncLlmConfigToBackend(config) {\n try {\n await fetch('/llm_config', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n api_url: config.api_url,\n api_key: config.api_key,\n model_name: config.model_name,\n system_prompt: config.system_prompt || '',\n temperature: config.temperature || 0.3,\n }),\n });\n } catch {\n // Non-critical: backend may not need persistent config\n }\n}\n\n/* ═══════════════════════════════════════════\n Context chips\n ═══════════════════════════════════════════ */\n\nfunction toggleCtxPicker() {\n document.getElementById('ctxPicker')?.classList.toggle('hidden');\n}\n\nfunction addCtx(key) {\n const session = getActiveSession();\n if (!session) return;\n if (!session.contexts) session.contexts = [];\n if (session.contexts.find(c => c.key === key)) return;\n\n let ctxData = { key, label: '', text: '', id: '' };\n if (key === 'transcription') {\n const results = JSON.parse(localStorage.getItem('asr_results') || '[]');\n if (results.length > 0) {\n ctxData.text = results[0].text || '';\n ctxData.label = results[0].filename || '最新转写';\n ctxData.id = results[0].filename || '';\n }\n } else if (key === 'refined') {\n ctxData.text = sessionStorage.getItem('refine_result') || '';\n ctxData.label = '润色结果';\n } else if (key === 'analysis') {\n ctxData.text = sessionStorage.getItem('analyze_result') || '';\n ctxData.label = '分析结果';\n } else if (key === 'import') {\n importFromResults();\n return;\n }\n\n session.contexts.push(ctxData);\n reRender();\n toast(`已添加上下文: ${ctxData.label || key}`, 'success');\n}\n\nfunction removeCtx(key) {\n const session = getActiveSession();\n if (!session) return;\n session.contexts = (session.contexts || []).filter(c => c.key !== key);\n reRender();\n}\n\nfunction importFromResults() {\n const results = JSON.parse(localStorage.getItem('asr_results') || '[]');\n if (results.length === 0) { toast('没有转写结果可以导入', 'info'); return; }\n\n const list = results.map((r, i) =>\n `<div style=\"padding:8px;cursor:pointer;border-bottom:1px solid var(--border)\" data-idx=\"${i}\">\n <strong>${r.filename}</strong>\n <span class=\"text-dim text-sm\"> · ${(r.text || '').length} 字</span>\n </div>`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `<div class=\"modal\"><h3>选择要导入的结果</h3>${list}</div>`;\n document.body.appendChild(overlay);\n\n overlay.querySelectorAll('[data-idx]').forEach(el => {\n el.addEventListener('click', () => {\n const idx = parseInt(el.dataset.idx);\n const session = getActiveSession();\n if (session) {\n if (!session.contexts) session.contexts = [];\n session.contexts.push({\n key: 'import', label: results[idx].filename,\n text: results[idx].text || '', id: results[idx].filename || '',\n });\n reRender();\n toast(`已导入: ${results[idx].filename}`, 'success');\n }\n overlay.remove();\n });\n });\n overlay.addEventListener('click', (e) => {\n if (e.target === overlay) overlay.remove();\n });\n}\n\n/* ═══════════════════════════════════════════\n Markdown rendering\n ═══════════════════════════════════════════ */\n\nfunction renderMarkdown(text, streaming) {\n if (!text) return streaming ? '<span class=\"streaming-cursor\"></span>' : '';\n\n // Try markdown-it + KaTeX first\n if (typeof md !== 'undefined' && md) {\n try {\n let html = md.render(text);\n html = processCodeBlocks(html);\n if (streaming) html += '<span class=\"streaming-cursor\"></span>';\n return html;\n } catch {\n // Fallback to marked\n }\n }\n\n // Fallback: marked with basic math rendering\n if (typeof marked !== 'undefined') {\n let html = marked.parse(text, { async: false });\n // Render inline math $...$ and display math $$...$$\n html = renderMathInHtml(html);\n html = processCodeBlocks(html);\n if (streaming) html += '<span class=\"streaming-cursor\"></span>';\n return html;\n }\n\n // Last resort: plain text with basic formatting\n let html = escapeHtml(text);\n html = html.replace(/```(\\w*)\\n?([\\s\\S]*?)```/g, '<pre><code>$2</code></pre>');\n html = html.replace(/`([^`]+)`/g, '<code>$1</code>');\n html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');\n html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');\n html = html.replace(/\\n/g, '<br>');\n if (streaming) html += '<span class=\"streaming-cursor\"></span>';\n return html;\n}\n\n// Render KaTeX math in HTML (fallback when markdown-it isn't available)\nfunction renderMathInHtml(html) {\n if (typeof katex === 'undefined') return html;\n // Display math $$...$$\n html = html.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, (match, tex) => {\n try { return '<div class=\"katex-display\">' + katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false }) + '</div>'; }\n catch { return match; }\n });\n // Inline math $...$ (but not inside pre/code tags)\n html = html.replace(/(?<!<pre[^>]*>)(?<!<code[^>]*>)(?<!<[^>]*)\\$([^$\\n]+?)\\$(?![^<]*<\\/code>)(?![^<]*<\\/pre>)/g, (match, tex) => {\n try { return katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false }); }\n catch { return match; }\n });\n return html;\n}\n\nfunction processCodeBlocks(html) { return html; }\n\n// Post-render: apply syntax highlighting and add copy buttons\nfunction postRender(el) {\n if (!el) return;\n\n el.querySelectorAll('pre code').forEach(block => {\n if (block.classList.contains('hljs')) return;\n\n if (typeof hljs !== 'undefined') {\n const langClass = [...block.classList].find(c => c.startsWith('language-'));\n const lang = langClass ? langClass.replace('language-', '') : null;\n\n if (lang && hljs.getLanguage(lang)) {\n hljs.highlightElement(block);\n } else {\n const text = block.textContent;\n if (text.length < 5000) {\n const result = hljs.highlightAuto(text);\n if (result.relevance > 5) {\n block.innerHTML = result.value;\n block.classList.add('hljs');\n }\n }\n }\n }\n\n const pre = block.closest('pre');\n if (pre && !pre.querySelector('.code-copy-btn')) {\n const lang = [...block.classList].find(c => c.startsWith('language-'))?.replace('language-', '') || 'text';\n const btn = document.createElement('button');\n btn.className = 'code-copy-btn';\n btn.textContent = '复制';\n btn.addEventListener('click', () => {\n copyToClipboard(block.textContent);\n btn.textContent = '已复制!';\n setTimeout(() => { btn.textContent = '复制'; }, 1500);\n });\n\n const label = document.createElement('span');\n label.className = 'code-lang-label';\n label.textContent = lang;\n\n const header = document.createElement('div');\n header.className = 'code-header';\n header.appendChild(label);\n header.appendChild(btn);\n\n pre.classList.add('code-block-wrapper');\n pre.insertBefore(header, pre.firstChild);\n }\n });\n\n processThinkingCards(el);\n}\n\n/* ═══════════════════════════════════════════\n Thinking cards (<think> tags)\n ═══════════════════════════════════════════ */\n\nfunction processThinkingCards(el) {\n if (!el) return;\n const html = el.innerHTML;\n if (!html.includes('<think>')) return;\n\n const processed = html.replace(/<think>([\\s\\S]*?)(<\\/?think>|$)/g, (match, content) => {\n const isComplete = match.includes('</think>');\n return `<details class=\"thinking-card\" ${isComplete ? '' : 'open'}>\n <summary class=\"thinking-summary\">\n <span class=\"thinking-icon\">${isComplete ? '' : '<span class=\"spinner\" style=\"width:14px;height:14px\"></span>'}</span>\n <span>模型思考过程</span>\n </summary>\n <div class=\"thinking-content\">${content.trim()}</div>\n </details>`;\n });\n\n if (processed !== html) el.innerHTML = processed;\n}\n\n/* ═══════════════════════════════════════════\n Message bubble rendering\n ═══════════════════════════════════════════ */\n\nfunction messageBubble(m, idx, session) {\n const isUser = m.role === 'user';\n const isEmpty = !m.content && m.role === 'assistant';\n const prevMsg = idx > 0 && session ? session.messages[idx - 1] : null;\n const showAvatar = !prevMsg || prevMsg.role !== m.role;\n const showDivider = prevMsg && prevMsg.role !== m.role;\n\n const userAvatar = localStorage.getItem('user-avatar') || '';\n const aiAvatar = localStorage.getItem('ai-avatar') || '';\n\n let html = '';\n if (showDivider) {\n html += '<div class=\"msg-divider\"></div>';\n }\n\n if (isUser) {\n const avatarHTML = showAvatar\n ? (userAvatar\n ? `<div class=\"avatar\"><img src=\"${userAvatar}\" alt=\"\"></div>`\n : `<div class=\"avatar\">你</div>`)\n : '';\n html += `\n <div class=\"chat-msg user\">\n ${avatarHTML ? `<div class=\"chat-msg-avatar\">${avatarHTML}</div>` : ''}\n <div class=\"chat-msg-bubble\">${escapeHtml(m.content)}</div>\n </div>`;\n return html;\n }\n\n const avatarHTML = showAvatar\n ? (aiAvatar\n ? `<div class=\"avatar\"><img src=\"${aiAvatar}\" alt=\"\"></div>`\n : `<div class=\"avatar avatar--primary\">AI</div>`)\n : '';\n const content = isEmpty ? '<span class=\"spinner\" style=\"display:inline-block\"></span> 正在思考...' : renderMarkdown(m.content);\n\n html += `\n <div class=\"chat-msg assistant\">\n ${avatarHTML ? `<div class=\"chat-msg-avatar\">${avatarHTML}</div>` : ''}\n <div class=\"md-renderer prose prose-enhanced\">${content}</div>\n ${m.content && !isEmpty ? `\n <div class=\"chat-msg-actions\">\n <button class=\"chat-copy-btn\" data-idx=\"${idx}\" title=\"复制\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/></svg>\n 复制\n </button>\n <button class=\"chat-msg-action\" onclick=\"window.downloadChatMsg(${idx})\" title=\"下载\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n 下载\n </button>\n </div>` : ''}\n </div>`;\n return html;\n}\n\n/* ═══════════════════════════════════════════\n Scroll — smart: don't scroll if user is reading above\n ═══════════════════════════════════════════ */\n\nfunction smartScrollToBottom(container) {\n if (!container) return;\n const threshold = 80;\n const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;\n if (atBottom) {\n requestAnimationFrame(() => {\n container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });\n });\n }\n}\n\n/* ═══════════════════════════════════════════\n Helpers\n ═══════════════════════════════════════════ */\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\nfunction loadLlmConfig() {\n try {\n const active = localStorage.getItem('asr_llm_config_active') || 'sensenova';\n const saved = JSON.parse(localStorage.getItem('asr_llm_config_' + active) || '{}');\n const presets = {\n sensenova: { url: 'https://token.sensenova.cn/v1', model: 'deepseek-v4-flash' },\n deepseek: { url: 'https://api.deepseek.com', model: 'deepseek-chat' },\n openai: { url: 'https://api.openai.com/v1', model: 'gpt-4o' },\n modelscope: { url: 'https://api-inference.modelscope.cn/v1', model: 'ZhipuAI/GLM-5' },\n ollama: { url: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },\n nanobot: { url: 'http://127.0.0.1:18900/v1', model: 'nanobot' },\n };\n const p = presets[active] || {};\n return {\n api_url: saved.api_url || p.url || '',\n api_key: saved.api_key || '',\n model_name: saved.model_name || p.model || '',\n temperature: saved.temperature || 0.3,\n system_prompt: saved.system_prompt || '',\n };\n } catch { return {}; }\n}\n\nfunction reRender() {\n const main = document.getElementById('route-content') || document.getElementById('main-content');\n if (!main) return;\n\n const msgs = document.getElementById('chatMessages');\n const scrollTop = msgs?.scrollTop || 0;\n\n main.innerHTML = render();\n init();\n\n main.querySelectorAll('.md-renderer.prose').forEach(el => postRender(el));\n\n if (msgs) msgs.scrollTop = scrollTop;\n}\n\nfunction updateSidebarSessions() {\n try {\n const sessions = getAllSessions().map(s => ({\n hash: `/chat/${s.id}`,\n title: s.title,\n updated: s.updated,\n }));\n import('../components/sidebar.js').then(Sidebar => {\n Sidebar.updateSessions(sessions);\n });\n } catch {}\n}\n\n/* ═══════════════════════════════════════════\n AI Title Generation (Phase 2)\n ═══════════════════════════════════════════ */\n\nasync function generateTitle(sessionId, firstUserMsg, firstAiReply, config) {\n try {\n const { setTitleGenPending, generateAiTitle } = await import('../store.js');\n setTitleGenPending(sessionId, firstUserMsg, firstAiReply);\n generateAiTitle(sessionId, firstUserMsg, firstAiReply, config).then(() => {\n // Update sidebar after title is generated\n updateSidebarSessions();\n });\n } catch {}\n}\n\n/* ═══════════════════════════════════════════\n Follow-up Questions (Phase 3)\n ═══════════════════════════════════════════ */\n\nasync function generateFollowUps(messages, aiReply, config) {\n try {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), 5000);\n const lastFew = messages.slice(-6); // last 3 exchanges\n const res = await fetch('/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: controller.signal,\n body: JSON.stringify({\n messages: [\n { role: 'system', content: '根据上面的对话内容,生成 3 个简短的追问建议,每句不超过 25 字,用 JSON 数组格式返回,例如:[\"追问1\", \"追问2\", \"追问3\"]。只返回 JSON,不要其他内容。' },\n ...lastFew.map(m => ({ role: m.role, content: m.content.slice(0, 500) })),\n ],\n model: config.model_name,\n temperature: 0.7,\n api_url: config.api_url,\n api_key: config.api_key,\n }),\n });\n clearTimeout(timer);\n if (!res.ok) return;\n const data = await res.json();\n let content = data.choices?.[0]?.message?.content || '';\n // Parse JSON array from response\n content = content.trim();\n const match = content.match(/\\[[\\s\\S]*\\]/);\n if (!match) return;\n const questions = JSON.parse(match[0]);\n if (!Array.isArray(questions) || questions.length === 0) return;\n\n // Render follow-up buttons\n const msgsEl = document.getElementById('chatMessages');\n if (!msgsEl) return;\n const lastMsg = msgsEl.querySelector('.chat-msg.assistant:last-child');\n if (!lastMsg) return;\n const followUpDiv = document.createElement('div');\n followUpDiv.className = 'follow-up-questions';\n followUpDiv.innerHTML = questions.slice(0, 3).map(q =>\n `<button class=\"follow-up-btn\" data-question=\"${escapeHtml(q)}\">💡 ${escapeHtml(q)}</button>`\n ).join('');\n lastMsg.appendChild(followUpDiv);\n } catch {\n // Silently fail — non-critical feature\n }\n}\n\n/* ═══════════════════════════════════════════\n File Upload (Phase 5)\n ═══════════════════════════════════════════ */\n\nlet pendingFileContext = null; // { name, content }\n\nfunction handleFileUpload(e) {\n const file = e.target.files?.[0];\n if (!file) return;\n\n // Only support text files for now\n const allowedTypes = ['text/plain', 'text/markdown', 'text/csv', 'application/json', 'text/html'];\n const allowedExt = ['.txt', '.md', '.csv', '.json', '.html', '.xml', '.log', '.srt'];\n const ext = '.' + file.name.split('.').pop().toLowerCase();\n\n if (!allowedTypes.includes(file.type) && !allowedExt.includes(ext)) {\n toast('仅支持文本文件 (.txt, .md, .csv, .json, .html, .srt)', 'error');\n return;\n }\n\n if (file.size > 5 * 1024 * 1024) {\n toast('文件不能超过 5MB', 'error');\n return;\n }\n\n const reader = new FileReader();\n reader.onload = (ev) => {\n pendingFileContext = { name: file.name, content: ev.target.result };\n renderFilePreview(file.name, file.size);\n };\n reader.onerror = () => toast('文件读取失败', 'error');\n reader.readAsText(file);\n\n // Reset input so same file can be selected again\n e.target.value = '';\n}\n\nfunction renderFilePreview(name, size) {\n const preview = document.getElementById('chatFilePreview');\n if (!preview) return;\n preview.classList.remove('hidden');\n preview.innerHTML = `\n <div class=\"chat-file-chip\">\n <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n <span class=\"chat-file-name\">${escapeHtml(name)}</span>\n <span class=\"chat-file-size\">${formatFileSize(size)}</span>\n <button class=\"chat-file-remove\" title=\"移除\">×</button>\n </div>\n `;\n preview.querySelector('.chat-file-remove')?.addEventListener('click', clearFilePreview);\n}\n\nfunction clearFilePreview() {\n pendingFileContext = null;\n const preview = document.getElementById('chatFilePreview');\n if (preview) {\n preview.classList.add('hidden');\n preview.innerHTML = '';\n }\n}\n\nfunction formatFileSize(bytes) {\n if (bytes < 1024) return bytes + ' B';\n if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';\n return (bytes / (1024 * 1024)).toFixed(1) + ' MB';\n}\n\n// Global download handler\nwindow.downloadChatMsg = function(idx) {\n const session = getActiveSession();\n if (session && session.messages[idx]) {\n const now = new Date();\n const ts = now.toISOString().slice(0, 10);\n const text = `# ASR Chat — ${session.title}\\n\\n${session.messages[idx].content}`;\n const blob = new Blob([text], { type: 'text/markdown' });\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = `chat_${ts}_${idx}.md`;\n a.click();\n }\n};\n"}}}} |