{"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
\n

LLM 文本润色

\n ${!configured ? '
请先在 配置页面 设置 LLM 参数
' : ''}\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n ${configured ? `模型: ${saved.model_name} · ${saved.api_url}` : '未配置 LLM'}\n
\n
\n\n
\n
\n

润色结果

\n
\n
\n \n \n
\n
\n
\n\n
\n
\n

正在润色

\n
\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 `
\n ${r.filename}\n · ${(r.text || '').length} 字 · ${new Date(r.date).toLocaleString('zh-CN')}\n
`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `

选择要导入的结果

${list || '

没有结果

'}
`;\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
\n
\n

课程表

\n \n
\n
\n \n \n \n \n ${DAYS.map(d => ``).join('')}\n \n \n \n
时间${d}
\n
\n
\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 += '';\n html += `${slot}`;\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 += ``;\n for (const c of cell) {\n html += `\n
\n
${esc(c.subject)}
\n
${esc(c.room || '')}${c.room && c.teacher ? ' · ' : ''}${esc(c.teacher || '')}
\n
\n \n \n
\n
`;\n }\n html += '';\n }\n html += '';\n }\n tbody.innerHTML = html || '暂无课程,点击右上角添加';\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
\n

${existing ? '编辑课程' : '添加课程'}

\n
\n \n \n \n \n \n \n
\n \n \n
\n
\n
\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 ``\n ).join('');\n\n // Custom prompts selector\n const customOpts = Object.keys(customPrompts).map(k =>\n ``\n ).join('');\n\n return `\n
\n

AI 智能分析

\n ${!configured ? '
请先在 配置页面 设置 LLM 参数
' : ''}\n\n \n
\n \n
${modeTabs}
\n \n
${MODE_DESC.summarize}
\n
\n\n \n
\n \n \n \n
\n\n \n
\n \n \n
\n\n
\n \n \n
\n\n
\n ${configured ? `模型: ${saved.model_name} · ${saved.api_url}` : '未配置 LLM'}\n
\n
\n\n
\n
\n
\n

分析结果

\n \n
\n
\n
\n \n \n
\n
\n
\n\n
\n
\n

正在分析

\n
\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 `
\n ${r.filename}\n · ${(r.text || '').length} 字 · ${new Date(r.date).toLocaleString('zh-CN')}\n
`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `

选择要导入的结果

${list || '

没有结果

'}
`;\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 ``\n ).join('');\n\n return `\n
\n

智能记忆管家(Nanobot)

\n
\n Nanobot 会学习你的文本偏好,越用越懂你。启动后在对话页和润色/分析中自动带上你的风格。\n
\n
\n
加载中...
\n
\n
\n\n
\n

LLM 预设模板(每个预设独立保存)

\n
${presetBtns}
\n\n
\n
\n \n \n
\n
\n \n \n
每个预设独立保存,切换预设不会串 Key
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n \n
\n
\n
\n\n
\n

连接状态

\n
\n \n 未配置\n
\n
\n

当前预设: ${PRESETS[activePreset].label}

\n

API 地址: ${saved.api_url || '-'}

\n

模型: ${saved.model_name || '-'}

\n

Key 已设置: ${saved.api_key ? '是' : '否'}

\n
\n
\n\n
\n\n
\n
\n

背景设置

\n
\n
\n \n \n
设置后页面背景将使用此图片,半透明遮罩保证内容可读。留空恢复默认。
\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Obsidian 知识库同步

\n
\n 将本地 Obsidian 笔记同步到对应导师的知识库,自动按学科分类。\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\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 = `
nanobot 服务不可用(后端可能未启动)
`;\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
\n \n ${running ? '运行中' : (enabled ? '已启用(未启动)' : '未启用')}\n ${running ? '就绪' : ''}\n \n \n
\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
\n ${e.title} ${e.desc}\n
\n
\n \n
\n \n \n \n
\n
\n
\n
\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
\n Dream 机制设置\n
\n
\n \n \n
Dream 会在后台定期从交互历史中提取偏好、术语,自动更新 USER.md 和 MEMORY.md。
\n
\n
\n \n \n \n
\n
\n
\n `;\n\n // Provider settings (existing)\n const providerHTML = `\n
\n 高级设置(独立 LLM 配置)\n
\n
\n \n
\n ${Object.entries(NANOBOT_PRESETS).map(([k, p]) =>\n ``\n ).join('')}\n
\n
\n
\n \n \n
\n
\n \n \n
独立保存,切换回继承模式不会丢失
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\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 = '已保存';\n toast(filename + ' 已保存', 'success');\n setTimeout(() => { if (statusEl) statusEl.innerHTML = ''; }, 2000);\n }\n } catch (err) {\n statusEl.innerHTML = '保存失败';\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 = ' 同步中...';\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 = `✓ ${result.files_synced} 新增, ${result.files_skipped} 跳过`;\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 \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 '

会话异常,请刷新页面

';\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
\n
${tutor.icon || '🤖'}
\n

有什么想聊的?

\n

与${tutor.name}对话

\n
\n \n \n \n
\n
\n ` : '';\n\n const msgsHTML = msgs.map((m, i) => messageBubble(m, i, session)).join('');\n\n // Context chips\n const fileChip = pendingFileContext ? `
\n \n ${escCtx(pendingFileContext.name)}\n \n
` : '';\n const tutorChip = activeTutor !== 'general' ? `
${escCtx(tutor.name)}\n \n
` : '';\n const contextChipsHTML = (fileChip || tutorChip) ? `
${fileChip}${tutorChip}
` : '';\n\n return `\n
\n ${welcomeHTML}\n
${msgsHTML}
\n\n
\n ${contextChipsHTML}\n \n
\n
\n \n \n \n ${isStreaming ? `` : ``}\n
\n
按 Enter 发送,Shift+Enter 换行
\n\n \n
\n
\n
上传文件
\n
\n \n 选择文件\n
\n \n
\n
\n
选择导师
\n
\n 🤖 通用助手\n
\n
\n 📐 数学导师\n
\n
\n ⚡ 物理导师\n
\n
\n
\n
\n
\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 `
${t.name}
`\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 = '
加载中...
';\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 = '
暂无文件,请先转写或分析
';\n return;\n }\n\n listEl.innerHTML = contextFiles.map(f =>\n `
${f.type}${escCtx(f.name)}
`\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 = `
加载失败: ${escCtx(e.message)}
`;\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 = '正在生成';\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 `
\n ${r.filename}\n · ${(r.text || '').length} 字\n
`\n ).join('');\n\n const overlay = document.createElement('div');\n overlay.className = 'modal-overlay';\n overlay.innerHTML = `

选择要导入的结果

${list}
`;\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 ? '' : '';\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 += '';\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 += '';\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, '
$2
');\n html = html.replace(/`([^`]+)`/g, '$1');\n html = html.replace(/\\*\\*(.+?)\\*\\*/g, '$1');\n html = html.replace(/\\*(.+?)\\*/g, '$1');\n html = html.replace(/\\n/g, '
');\n if (streaming) html += '';\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 '
' + katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false }) + '
'; }\n catch { return match; }\n });\n // Inline math $...$ (but not inside pre/code tags)\n html = html.replace(/(?]*>)(?]*>)(?]*)\\$([^$\\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 ( tags)\n ═══════════════════════════════════════════ */\n\nfunction processThinkingCards(el) {\n if (!el) return;\n const html = el.innerHTML;\n if (!html.includes('')) return;\n\n const processed = html.replace(/([\\s\\S]*?)(<\\/?think>|$)/g, (match, content) => {\n const isComplete = match.includes('');\n return `
\n \n ${isComplete ? '' : ''}\n 模型思考过程\n \n
${content.trim()}
\n
`;\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 += '
';\n }\n\n if (isUser) {\n const avatarHTML = showAvatar\n ? (userAvatar\n ? `
\"\"
`\n : `
你
`)\n : '';\n html += `\n
\n ${avatarHTML ? `
${avatarHTML}
` : ''}\n
${escapeHtml(m.content)}
\n
`;\n return html;\n }\n\n const avatarHTML = showAvatar\n ? (aiAvatar\n ? `
\"\"
`\n : `
AI
`)\n : '';\n const content = isEmpty ? ' 正在思考...' : renderMarkdown(m.content);\n\n html += `\n
\n ${avatarHTML ? `
${avatarHTML}
` : ''}\n
${content}
\n ${m.content && !isEmpty ? `\n
\n \n \n
` : ''}\n
`;\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 ``\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
\n \n ${escapeHtml(name)}\n ${formatFileSize(size)}\n \n
\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"}}}}