Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/caihuailei_mindboard.json
T

1 line
101 KiB
JSON
Raw 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": "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=\"删除\">&times;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":\"&#39;\"}[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\">&times;</button>\n </div>` : '';\n const tutorChip = activeTutor !== 'general' ? `<div class=\"context-chip\" title=\"当前导师\">${escCtx(tutor.name)}\n <button class=\"context-chip-remove\" id=\"tutorChipRemove\">&times;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":\"&#39;\"}[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=\"移除\">&times;</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"}}}}