{"repo": "albert-libra/puppycli", "files": {"src/puppycli/static/app.js": {"c69cec25b40458c63bb517eaa1e3ecd7ba10964b": {"t": 1785746690.2776036, "n": 51742, "h": "37b13c9149d43fea", "c": "/* ========================================\n PuppyCLI — Frontend Application Logic\n ======================================== */\n\n// --- i18n ---\nconst i18n = {\n _lang: 'en',\n _data: {\n 'new-chat': { zh: '新建对话', en: 'New Chat' },\n 'settings': { zh: '设置', en: 'Settings' },\n 'theme': { zh: '切换主题', en: 'Toggle Theme' },\n 'docs': { zh: '文档', en: 'Docs' },\n 'lang': { zh: '切换语言', en: 'Language' },\n 'send': { zh: '发送', en: 'Send' },\n 'conversations': { zh: '对话列表', en: 'Conversations' },\n 'placeholder': { zh: '输入消息... (Enter 发送, Shift+Enter 换行)', en: 'Type a message... (Enter to send, Shift+Enter for new line)' },\n 'settings-title': { zh: '设置', en: 'Settings' },\n 'api-key': { zh: 'API 密钥', en: 'API Key' },\n 'model': { zh: '模型', en: 'Model' },\n 'base-url': { zh: '接口地址', en: 'Base URL' },\n 'python-env': { zh: 'Python 环境路径', en: 'Python Env Path' },\n 'data-dir': { zh: '数据目录', en: 'Data Directory' },\n 'save': { zh: '保存', en: 'Save' },\n 'cancel': { zh: '取消', en: 'Cancel' },\n 'rename': { zh: '重命名', en: 'Rename' },\n 'delete': { zh: '删除', en: 'Delete' },\n 'delete-confirm': { zh: '确定要删除这个对话吗?此操作不可撤销。', en: 'Delete this conversation? This cannot be undone.' },\n 'toggle-sidebar': { zh: '折叠/展开侧栏', en: 'Toggle Sidebar' },\n },\n\n t(key) {\n const entry = this._data[key];\n return entry ? (entry[this._lang] || entry.en) : key;\n },\n\n init() {\n const saved = localStorage.getItem('puppycli-lang');\n if (saved === 'zh' || saved === 'en') {\n this._lang = saved;\n } else {\n this._lang = navigator.language.startsWith('zh') ? 'zh' : 'en';\n }\n this.apply();\n },\n\n toggle() {\n this._lang = this._lang === 'zh' ? 'en' : 'zh';\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n },\n\n set(lang) {\n if (lang === 'zh' || lang === 'en') {\n this._lang = lang;\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n }\n },\n\n apply() {\n // text content\n document.querySelectorAll('[data-i18n]').forEach(el => {\n el.textContent = this.t(el.getAttribute('data-i18n'));\n });\n // title attributes\n document.querySelectorAll('[data-i18n-title]').forEach(el => {\n el.title = this.t(el.getAttribute('data-i18n-title'));\n });\n // placeholder attributes\n document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {\n el.placeholder = this.t(el.getAttribute('data-i18n-placeholder'));\n });\n // Update HTML lang attr\n document.documentElement.lang = this._lang;\n }\n};\n\nconst ICON_SUN = '';\nconst ICON_MOON = '';\n\n// --- State ---\nconst state = {\n currentSessionId: null,\n ws: null,\n isStreaming: false,\n _lastUserMessage: '', // for /retry command\n};\n\n// --- DOM Elements ---\nconst $ = (sel) => document.querySelector(sel);\nconst $$ = (sel) => document.querySelectorAll(sel);\n\nconst elements = {\n messages: $('#messages'),\n userInput: $('#user-input'),\n btnNewChat: $('#btn-new-chat'),\n btnTheme: $('#btn-theme'),\n btnLang: $('#btn-lang'),\n btnSettings: $('#btn-settings'),\n btnSidebarToggle: $('#btn-sidebar-toggle'),\n btnDocs: $('#btn-docs'),\n sessionList: $('#session-list'),\n settingsModal: $('#settings-modal'),\n btnSettingsSave: $('#btn-settings-save'),\n btnSettingsClose: $('#btn-settings-close'),\n};\n\n// --- Initialization ---\ndocument.addEventListener('DOMContentLoaded', () => {\n i18n.init();\n initSidebar();\n loadTheme();\n loadSessions();\n setupEventListeners();\n showEmptyState();\n});\n\n// --- Sidebar Toggle ---\nfunction initSidebar() {\n if (localStorage.getItem('puppycli-sidebar') === 'collapsed') {\n $('#app').classList.add('sidebar-collapsed');\n }\n}\n\nfunction toggleSidebar() {\n const app = $('#app');\n app.classList.toggle('sidebar-collapsed');\n const collapsed = app.classList.contains('sidebar-collapsed');\n localStorage.setItem('puppycli-sidebar', collapsed ? 'collapsed' : 'expanded');\n}\n\n// --- Theme ---\nfunction loadTheme() {\n const theme = localStorage.getItem('puppycli-theme') || 'light';\n document.documentElement.setAttribute('data-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\nfunction toggleTheme() {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('puppycli-theme', next);\n elements.btnTheme.innerHTML = next === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\n// --- Slash Command Autocomplete ---\nconst SLASH_COMMANDS = [\n { cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },\n { cmd: '/clear', descZh: '清除对话', descEn: 'Clear conversation' },\n { cmd: '/new', descZh: '新建对话', descEn: 'New conversation' },\n { cmd: '/theme', descZh: '切换主题', descEn: 'Switch theme' },\n { cmd: '/lang', descZh: '切换语言', descEn: 'Switch language' },\n { cmd: '/settings', descZh: '打开设置', descEn: 'Open settings' },\n { cmd: '/export', descZh: '导出对话', descEn: 'Export conversation' },\n { cmd: '/retry', descZh: '重新生成', descEn: 'Regenerate response' },\n { cmd: '/model', descZh: '切换模型', descEn: 'Switch model' },\n { cmd: '/skill', descZh: '管理技能', descEn: 'Manage skills' },\n { cmd: '/search', descZh: '搜索技能', descEn: 'Search skills' },\n { cmd: '/kb', descZh: '知识库管理', descEn: 'Knowledge base' },\n { cmd: '/pdf', descZh: '处理 PDF', descEn: 'Process PDF' },\n { cmd: '/docs', descZh: '打开文档', descEn: 'Open docs' },\n];\n\nlet _suggIndex = -1;\nconst $sugg = () => $('#slash-suggestions');\n\nfunction updateSuggestions() {\n const val = elements.userInput.value;\n if (!val.startsWith('/') || val.includes(' ')) {\n hideSuggestions();\n return;\n }\n const query = val.slice(1).toLowerCase();\n const matches = SLASH_COMMANDS.filter(s => s.cmd.slice(1).startsWith(query));\n if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === val)) {\n hideSuggestions();\n return;\n }\n const isZh = i18n._lang === 'zh';\n $sugg().innerHTML = matches.map((s, i) =>\n `
${s.cmd}${isZh ? s.descZh : s.descEn}
`\n ).join('');\n $sugg().classList.remove('hidden');\n _suggIndex = -1;\n $sugg().querySelectorAll('.slash-item').forEach(el => {\n el.addEventListener('mousedown', (e) => {\n e.preventDefault();\n const cmd = matches[parseInt(el.dataset.index)].cmd;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n elements.userInput.focus();\n });\n });\n}\n\nfunction hideSuggestions() {\n $sugg().classList.add('hidden');\n $sugg().innerHTML = '';\n _suggIndex = -1;\n}\n\n// --- Local Message Persistence ---\nasync function ensureSession() {\n if (state.currentSessionId) return;\n try {\n const resp = await fetch('/api/sessions', { method: 'POST' });\n const data = await resp.json();\n state.currentSessionId = data.session_id;\n loadSessions();\n } catch (e) {\n console.error('Failed to create session:', e);\n }\n}\n\nasync function saveLocalMessage(role, content) {\n if (!state.currentSessionId || !content) return;\n try {\n await fetch(`/api/sessions/${state.currentSessionId}/messages`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, content }),\n });\n } catch (e) {\n console.error('Failed to save message:', e);\n }\n}\n\nfunction navigateSuggestions(dir) {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0) return;\n items.forEach(el => el.classList.remove('active'));\n _suggIndex = Math.max(0, Math.min(items.length - 1, _suggIndex + dir));\n items[_suggIndex].classList.add('active');\n items[_suggIndex].scrollIntoView({ block: 'nearest' });\n}\n\nfunction selectSuggestion() {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0 || _suggIndex < 0) return false;\n const cmd = items[_suggIndex].querySelector('.slash-cmd').textContent;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n return true;\n}\n\n// --- Event Listeners ---\nfunction setupEventListeners() {\n elements.userInput.addEventListener('keydown', (e) => {\n // Handle suggestion navigation first\n if (!$sugg().classList.contains('hidden')) {\n if (e.key === 'ArrowDown') { e.preventDefault(); navigateSuggestions(1); return; }\n if (e.key === 'ArrowUp') { e.preventDefault(); navigateSuggestions(-1); return; }\n if (e.key === 'Enter' && !e.shiftKey) {\n if (selectSuggestion()) { e.preventDefault(); return; }\n }\n if (e.key === 'Escape') { e.preventDefault(); hideSuggestions(); return; }\n if (e.key === 'Tab') { e.preventDefault(); selectSuggestion(); return; }\n }\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n elements.userInput.addEventListener('input', () => {\n autoResizeTextarea();\n updateSuggestions();\n });\n elements.btnNewChat.addEventListener('click', newChat);\n elements.btnTheme.addEventListener('click', toggleTheme);\n elements.btnLang.addEventListener('click', () => i18n.toggle());\n elements.btnSidebarToggle.addEventListener('click', toggleSidebar);\n elements.btnSettings.addEventListener('click', openSettings);\n elements.btnSettingsClose.addEventListener('click', closeSettings);\n elements.btnSettingsSave.addEventListener('click', saveSettings);\n if (elements.btnDocs) {\n elements.btnDocs.addEventListener('click', () => window.open('/help/', '_blank'));\n }\n}\n\n// --- WebSocket ---\nfunction connectWebSocket() {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) return;\n\n const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';\n const wsUrl = `${protocol}//${location.host}/ws/chat`;\n state.ws = new WebSocket(wsUrl);\n\n state.ws.onmessage = (event) => {\n const data = JSON.parse(event.data);\n handleWSMessage(data);\n };\n\n state.ws.onclose = () => {\n state.ws = null;\n };\n\n state.ws.onerror = (err) => {\n console.error('WebSocket error:', err);\n };\n}\n\nfunction handleWSMessage(data) {\n switch (data.type) {\n case 'session_id':\n state.currentSessionId = data.session_id;\n break;\n case 'token':\n appendToken(data.content);\n break;\n case 'done':\n finishStreaming();\n loadSessions();\n break;\n case 'error':\n showError(data.message);\n break;\n case 'confirm_tool':\n showConfirmCard(data.confirm_id, data.command, data.reason);\n break;\n }\n}\n\nfunction sendMessage() {\n const content = elements.userInput.value.trim();\n hideSuggestions();\n if (!content || state.isStreaming) return;\n\n // Bang commands — execute PowerShell\n if (content.startsWith('!')) {\n execPowerShell(content.slice(1).trim());\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n // Slash commands — handle locally, don't send to AI\n if (content.startsWith('/')) {\n handleSlashCommand(content);\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n state._lastUserMessage = content;\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: content,\n session_id: state.currentSessionId,\n }));\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n\n removeEmptyState();\n appendMessage('user', content);\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\n// --- Slash Commands ---\nfunction handleSlashCommand(input) {\n const parts = input.split(/\\s+/);\n const cmd = parts[0].toLowerCase();\n const arg = parts.slice(1).join(' ');\n\n // Save command as user message (fire and forget)\n ensureSession().then(() => saveLocalMessage('user', input));\n\n switch (cmd) {\n case '/help':\n cmdHelp();\n break;\n case '/clear':\n cmdClear();\n break;\n case '/new':\n cmdNew();\n break;\n case '/theme':\n cmdTheme(arg);\n break;\n case '/lang':\n cmdLang(arg);\n break;\n case '/settings':\n cmdSettings();\n break;\n case '/export':\n cmdExport();\n break;\n case '/retry':\n cmdRetry();\n break;\n case '/model':\n cmdModel(arg);\n break;\n case '/skill':\n cmdSkill(arg);\n break;\n case '/search':\n cmdSkillSearch(arg);\n break;\n case '/kb':\n cmdKnowledge(arg);\n break;\n case '/pdf':\n cmdPdf(arg);\n break;\n case '/docs':\n window.open('/help/', '_blank');\n showSystemMessage(i18n._lang === 'zh'\n ? '文档已在新标签页中打开。'\n : 'Documentation opened in a new tab.');\n break;\n default:\n showSystemMessage(i18n._lang === 'zh'\n ? `未知命令: ${cmd}。输入 /help 查看可用命令。`\n : `Unknown command: ${cmd}. Type /help for available commands.`);\n }\n}\n\nfunction showSystemMessage(text) {\n removeEmptyState();\n saveLocalMessage('assistant', text);\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.fontStyle = 'italic';\n div.style.color = 'var(--text-secondary)';\n div.innerHTML = renderMarkdown(text);\n elements.messages.appendChild(div);\n scrollToBottom();\n}\n\nfunction cmdHelp() {\n const isZh = i18n._lang === 'zh';\n showSystemMessage(isZh\n ? `**可用命令**\n\n| 命令 | 说明 |\n|---------|-------------|\n| \\`/help\\` | 显示此帮助信息 |\n| \\`/clear\\` | 清除当前对话 |\n| \\`/new\\` | 创建新对话 |\n| \\`/theme dark\\\\|light\\` | 切换颜色主题 |\n| \\`/lang zh\\\\|en\\` | 切换界面语言 |\n| \\`/settings\\` | 打开设置面板 |\n| \\`/export\\` | 导出对话为 Markdown |\n| \\`/retry\\` | 重新生成上一次回复 |\n| \\`/model \\` | 快速切换 AI 模型 |\n| \\`/skill list\\` | 列出已安装的技能 |\n| \\`/skill install \\` | 安装技能(路径、GitHub、URL),自动适配前端 |\n| \\`/skill adapt \\` | 对已有技能进行 PuppyCLI 前端适配 |\n| \\`/skill restore \\` | 还原技能为原始版本 |\n| \\`/skill remove \\` | 移除技能 |\n| \\`/search \\` | 在 agentskills.io 上搜索技能 |\n| \\`!\\` | 执行 PowerShell 命令(例如 \\`!Get-Date\\`) |\n| \\`/kb list\\` | 列出知识库条目 |\n| \\`/kb add \\\\|<content>\\` | 添加知识库条目 |\n| \\`/kb search <query>\\` | 搜索知识库 |\n| \\`/kb delete <id>\\` | 删除知识库条目 |\n| \\`/pdf <path>\\` | 处理 PDF 文件并加入知识库 |\n| \\`/docs\\` | 在新标签页中打开文档 |`\n : `**Available Commands**\n\n| Command | Description |\n|---------|-------------|\n| \\`/help\\` | Show this help message |\n| \\`/clear\\` | Clear current conversation |\n| \\`/new\\` | Start a new conversation |\n| \\`/theme dark\\\\|light\\` | Switch color theme |\n| \\`/lang zh\\\\|en\\` | Switch interface language |\n| \\`/settings\\` | Open settings panel |\n| \\`/export\\` | Export conversation as Markdown |\n| \\`/retry\\` | Regenerate last AI response |\n| \\`/model <name>\\` | Quick-switch AI model |\n| \\`/skill list\\` | List installed skills |\n| \\`/skill install <source>\\` | Install a skill (path, GitHub, URL), auto-adapts |\n| \\`/skill adapt <name>\\` | Adapt a skill for PuppyCLI frontend |\n| \\`/skill restore <name>\\` | Restore skill to original version |\n| \\`/skill remove <name>\\` | Remove a skill |\n| \\`/search <query>\\` | Search skills on agentskills.io |\n| \\`!<command>\\` | Execute a PowerShell command (e.g., \\`!Get-Date\\`) |\n| \\`/kb list\\` | List knowledge base entries |\n| \\`/kb add <title>\\\\|<content>\\` | Add to knowledge base |\n| \\`/kb search <query>\\` | Search knowledge base |\n| \\`/kb delete <id>\\` | Delete a knowledge entry |\n| \\`/pdf <path>\\` | Process PDF file → knowledge base |\n| \\`/docs\\` | Open documentation in new tab |`);\n}\n\nfunction cmdClear() {\n elements.messages.innerHTML = '';\n showEmptyState();\n}\n\nfunction cmdNew() {\n newChat();\n}\n\nfunction cmdTheme(arg) {\n const theme = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-theme', theme);\n localStorage.setItem('puppycli-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n showSystemMessage(isZh\n ? `主题已切换至 **${theme === 'dark' ? '深色' : '浅色'}** 模式。`\n : `Theme switched to **${theme}** mode.`);\n } else {\n const current = document.documentElement.getAttribute('data-theme') || 'light';\n showSystemMessage(isZh\n ? `当前主题: **${current}**。用法: \\`/theme dark\\` 或 \\`/theme light\\``\n : `Current theme: **${current}**. Usage: \\`/theme dark\\` or \\`/theme light\\``);\n }\n}\n\nfunction cmdLang(arg) {\n const lang = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (lang === 'zh' || lang === 'en') {\n i18n.set(lang);\n showSystemMessage(isZh\n ? `语言已切换为 **${lang === 'zh' ? '中文' : 'English'}**。`\n : `Language switched to **${lang === 'zh' ? '中文' : 'English'}**.`);\n } else if (!lang) {\n showSystemMessage(isZh\n ? `当前语言: **${i18n._lang === 'zh' ? '中文' : 'English'}**。用法: \\`/lang zh\\` 或 \\`/lang en\\``\n : `Current language: **${i18n._lang === 'zh' ? '中文' : 'English'}**. Usage: \\`/lang zh\\` or \\`/lang en\\``);\n } else {\n showSystemMessage(isZh\n ? `不支持的语言: **${lang}**。请使用 \\`/lang zh\\` 或 \\`/lang en\\``\n : `Unsupported language: **${lang}**. Use \\`/lang zh\\` or \\`/lang en\\``);\n }\n}\n\nfunction cmdSettings() {\n openSettings();\n}\n\nfunction cmdExport() {\n const isZh = i18n._lang === 'zh';\n const messages = elements.messages.querySelectorAll('.message');\n if (messages.length === 0) {\n showSystemMessage(isZh ? '没有可导出的内容。' : 'Nothing to export.');\n return;\n }\n\n let md = `# PuppyCLI Conversation\\n\\n`;\n md += `_Exported at ${new Date().toLocaleString()}_\\n\\n---\\n\\n`;\n\n messages.forEach(msg => {\n if (msg.classList.contains('user')) {\n md += `**You:**\\n${msg.textContent}\\n\\n`;\n } else if (msg.classList.contains('assistant') && !msg.style.fontStyle) {\n const raw = msg.dataset.rawText || msg.textContent;\n md += `**PuppyCLI:**\\n${raw}\\n\\n`;\n }\n });\n\n const blob = new Blob([md], { type: 'text/markdown' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `puppycli-export-${Date.now()}.md`;\n a.click();\n URL.revokeObjectURL(url);\n showSystemMessage(isZh\n ? '对话已导出为 Markdown 文件。'\n : 'Conversation exported as Markdown file.');\n}\n\nfunction cmdRetry() {\n const isZh = i18n._lang === 'zh';\n if (state.isStreaming) return;\n if (!state._lastUserMessage) {\n showSystemMessage(isZh\n ? '没有可重试的消息。请先发送一条消息。'\n : 'Nothing to retry. Send a message first.');\n return;\n }\n\n // Remove last assistant bubble if present\n const msgs = elements.messages.querySelectorAll('.message.assistant');\n if (msgs.length > 0) {\n const lastAssistant = msgs[msgs.length - 1];\n if (!lastAssistant.style.fontStyle) {\n lastAssistant.remove();\n }\n }\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: state._lastUserMessage,\n session_id: state.currentSessionId,\n }));\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\nfunction cmdModel(arg) {\n const isZh = i18n._lang === 'zh';\n const model = arg.trim();\n if (!model) {\n showSystemMessage(isZh\n ? '用法: `/model <name>`。例如: `/model deepseek-chat`'\n : 'Usage: `/model <name>`. Example: `/model deepseek-chat`');\n return;\n }\n fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model }),\n })\n .then(r => r.json())\n .then(() => {\n showSystemMessage(isZh\n ? `模型已切换至 **${model}**(下一条消息生效)。`\n : `Model switched to **${model}** (next message will use it).`);\n })\n .catch(() => {\n showSystemMessage(isZh\n ? '模型切换失败,请检查设置。'\n : 'Failed to switch model. Check settings.');\n });\n}\n\nasync function cmdSkill(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const target = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/skills');\n const skills = await resp.json();\n if (skills.length === 0) {\n showSystemMessage(isZh\n ? '没有已安装的技能。使用 `/skill install <source>` 安装,或 `/search <query>` 搜索技能。'\n : 'No skills installed. Use `/skill install <source>` to install one, or `/search <query>` to find skills.');\n return;\n }\n let md = isZh\n ? '**已安装技能**\\n\\n| 名称 | 描述 | 位置 | 前端适配 |\\n|------|-------------|----------|----------|\\n'\n : '**Installed Skills**\\n\\n| Name | Description | Location | Adapted |\\n|------|-------------|----------|----------|\\n';\n skills.forEach(s => {\n const adapted = s.is_adapted\n ? (isZh ? '✅ 已适配' : '✅ Yes')\n : (isZh ? '⚠️ 未适配' : '⚠️ No');\n md += `| \\`${s.name}\\` | ${(s.description||'').slice(0, 80)} | ${s.location} | ${adapted} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/skill adapt <name>` 适配前端,`/skill restore <name>` 还原,`/skill remove <name>` 移除。'\n : '\\nUse `/skill adapt <name>` to adapt, `/skill restore <name>` to revert, `/skill remove <name>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '获取技能列表失败: ' : 'Failed to list skills: ') + e.message);\n }\n } else if (action === 'install') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill install <github repo | URL | 本地路径>`。例如: `/skill install anthropics/skills`'\n : 'Usage: `/skill install <github repo | URL | local path>`. Example: `/skill install anthropics/skills`');\n return;\n }\n showSystemMessage(isZh ? `正在从 **${target}** 安装技能...` : `Installing skill from **${target}**...`);\n try {\n const resp = await fetch('/api/skills/install', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ source: target }),\n });\n if (resp.ok) {\n const data = await resp.json();\n const adapted = data.skill.is_adapted;\n const adaptMsg = adapted\n ? (isZh ? ' ✅ 已自动进行前端适配' : ' ✅ Auto-adapted for PuppyCLI')\n : (isZh ? ' (未检测到 CLI 特征,无需适配)' : ' (no CLI patterns detected, no adaptation needed)');\n showSystemMessage(isZh\n ? `技能 **${data.skill.name}** 安装成功!${adaptMsg} 输入 /skill list 查看所有技能。`\n : `Skill **${data.skill.name}** installed successfully!${adaptMsg} Type /skill list to see all skills.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '安装失败: ' : 'Install failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '安装技能失败: ' : 'Failed to install skill: ') + e.message);\n }\n } else if (action === 'remove') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill remove <name>`。使用 `/skill list` 查看已安装技能。'\n : 'Usage: `/skill remove <name>`. Use `/skill list` to see installed skills.');\n return;\n }\n try {\n const resp = await fetch(`/api/skills/${target}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `技能 **${target}** 已移除。` : `Skill **${target}** removed.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '移除失败: ' : 'Remove failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '移除技能失败: ' : 'Failed to remove skill: ') + e.message);\n }\n } else if (action === 'adapt') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill adapt <name>`。对已安装技能进行 PuppyCLI 前端适配。'\n : 'Usage: `/skill adapt <name>`. Adapt an installed skill for PuppyCLI frontend.');\n return;\n }\n showSystemMessage(isZh ? `正在适配 **${target}**...` : `Adapting **${target}**...`);\n try {\n const resp = await fetch(`/api/skills/${target}/adapt`, { method: 'POST' });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `技能 **${target}** 前端适配完成。原始文件已备份为 SKILL.md.orig。`\n : `Skill **${target}** adapted for PuppyCLI. Original backed up as SKILL.md.orig.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '适配失败: ' : 'Adapt failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '适配技能失败: ' : 'Failed to adapt skill: ') + e.message);\n }\n } else if (action === 'restore') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill restore <name>`。将技能还原为原始版本。'\n : 'Usage: `/skill restore <name>`. Restore skill to original version.');\n return;\n }\n showSystemMessage(isZh ? `正在还原 **${target}**...` : `Restoring **${target}**...`);\n try {\n const resp = await fetch(`/api/skills/${target}/restore`, { method: 'POST' });\n if (resp.ok) {\n showSystemMessage(isZh\n ? `技能 **${target}** 已还原为原始版本。`\n : `Skill **${target}** restored to original.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '还原失败: ' : 'Restore failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '还原技能失败: ' : 'Failed to restore skill: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的技能操作: **${action}**。请使用 \\`/skill list\\`、\\`/skill install\\` 或 \\`/skill remove\\`。`\n : `Unknown skill action: **${action}**. Use \\`/skill list\\`, \\`/skill install\\`, or \\`/skill remove\\`.`);\n }\n}\n\nasync function cmdSkillSearch(query) {\n const isZh = i18n._lang === 'zh';\n if (!query.trim()) {\n showSystemMessage(isZh\n ? '用法: `/search <query>`。例如: `/search pdf processing`'\n : 'Usage: `/search <query>`. Example: `/search pdf processing`');\n return;\n }\n showSystemMessage(isZh\n ? `正在 agentskills.io 上搜索 **${query}**...`\n : `Searching for **${query}** on agentskills.io...`);\n try {\n const resp = await fetch(`/api/skills/search?q=${encodeURIComponent(query)}&limit=10`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${query}** 相关的技能。请尝试其他关键词。`\n : `No skills found for **${query}**. Try a different keyword.`);\n return;\n }\n let md = isZh\n ? `**搜索 \"${query}\" 的结果**\\n\\n| 名称 | 描述 | 来源 |\\n|------|-------------|--------|\\n`\n : `**Search Results for \"${query}\"**\\n\\n| Name | Description | Source |\\n|------|-------------|--------|\\n`;\n results.forEach(r => {\n const desc = (r.description || '').slice(0, 60);\n const src = r.source ? `[repo](${r.source})` : '-';\n md += `| \\`${r.name}\\` | ${desc} | ${src} |\\n`;\n });\n md += isZh\n ? `\\n安装: \\`/skill install <source>\\` (例如 \\`/skill install ${results[0].source || results[0].name}\\`)`\n : `\\nTo install: \\`/skill install <source>\\` (e.g., \\`/skill install ${results[0].source || results[0].name}\\`)`;\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n}\n\nasync function execPowerShell(command) {\n const isZh = i18n._lang === 'zh';\n if (!command) return;\n\n await ensureSession();\n saveLocalMessage('user', '!' + command);\n\n removeEmptyState();\n appendMessage('user', '!' + command);\n\n const resultDiv = document.createElement('div');\n resultDiv.className = 'message assistant';\n resultDiv.innerHTML = renderMarkdown(\n (isZh ? '运行中: `' : 'Running: `') + escapeHtml(command) + '`...');\n elements.messages.appendChild(resultDiv);\n scrollToBottom();\n\n try {\n const resp = await fetch('/api/powershell', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ command }),\n });\n const data = await resp.json();\n\n let output = '';\n if (data.stdout) {\n output += '```\\n' + data.stdout + '\\n```';\n }\n if (data.stderr) {\n output += '\\n\\n**Stderr:**\\n```\\n' + data.stderr + '\\n```';\n }\n if (data.returncode !== 0 && !data.stderr && !data.stdout) {\n output += '\\n_Exit code: ' + data.returncode + '_';\n }\n if (!output.trim()) {\n const noOutput = isZh ? '(无输出)' : '(no output)';\n output = '_' + noOutput + '_';\n saveLocalMessage('assistant', noOutput);\n } else {\n saveLocalMessage('assistant', output);\n }\n\n resultDiv.innerHTML = renderMarkdown(output);\n } catch (e) {\n const errMsg = (isZh ? '错误: ' : 'Error: ') + e.message;\n saveLocalMessage('assistant', errMsg);\n resultDiv.innerHTML = renderMarkdown('**' + errMsg + '**');\n }\n scrollToBottom();\n}\n\nasync function cmdKnowledge(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const rest = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/kb');\n const entries = await resp.json();\n if (entries.length === 0) {\n showSystemMessage(isZh\n ? '知识库为空。使用 `/kb add <title>|<content>` 添加条目。'\n : 'Knowledge base is empty. Use `/kb add <title>|<content>` to add entries.');\n return;\n }\n let md = isZh\n ? `**知识库** (${entries.length} 条)\\n\\n| ID | 标题 | 标签 | 预览 |\\n|----|-------|------|---------|\\n`\n : `**Knowledge Base** (${entries.length} entries)\\n\\n| ID | Title | Tags | Preview |\\n|----|-------|------|---------|\\n`;\n entries.forEach(e => {\n md += `| \\`${e.id}\\` | ${e.title} | ${(e.tags||[]).join(', ')} | ${(e.preview||'').slice(0, 50)} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/kb search <query>` 搜索,`/kb delete <id>` 删除。'\n : '\\nUse `/kb search <query>` to search, `/kb delete <id>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'add') {\n const pipeIdx = rest.indexOf('|');\n if (pipeIdx < 0) {\n showSystemMessage(isZh\n ? '用法: `/kb add <title>|<content>`。使用竖线 `|` 分隔标题和内容。'\n : 'Usage: `/kb add <title>|<content>`. Use pipe `|` to separate title and content.');\n return;\n }\n const title = rest.slice(0, pipeIdx).trim();\n const content = rest.slice(pipeIdx + 1).trim();\n if (!title || !content) {\n showSystemMessage(isZh ? '标题和内容均为必填。' : 'Both title and content are required.');\n return;\n }\n try {\n const resp = await fetch('/api/kb', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title, content }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `知识条目 **${data.entry.id}** 已添加: _${title}_`\n : `Knowledge entry **${data.entry.id}** added: _${title}_`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'search') {\n if (!rest) {\n showSystemMessage(isZh ? '用法: `/kb search <query>`' : 'Usage: `/kb search <query>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/search?q=${encodeURIComponent(rest)}`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${rest}** 相关的内容。`\n : `Nothing found for **${rest}**.`);\n return;\n }\n let md = (isZh ? `**搜索: \"${rest}\"**\\n\\n` : `**Search: \"${rest}\"**\\n\\n`);\n results.forEach(r => {\n md += `### ${r.title}\\n\\`${r.id}\\` | tags: ${(r.tags||[]).join(', ')}\\n\\n${r.content}\\n\\n---\\n`;\n });\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n } else if (action === 'delete') {\n const entryId = rest.trim();\n if (!entryId) {\n showSystemMessage(isZh ? '用法: `/kb delete <id>`' : 'Usage: `/kb delete <id>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/${entryId}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `条目 **${entryId}** 已删除。` : `Entry **${entryId}** deleted.`);\n } else {\n showSystemMessage(isZh ? '删除失败: 条目未找到。' : 'Delete failed: entry not found.');\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的知识库操作: **${action}**。请使用 \\`/kb list\\`、\\`/kb add\\`、\\`/kb search\\` 或 \\`/kb delete\\`。`\n : `Unknown kb action: **${action}**. Use \\`/kb list\\`, \\`/kb add\\`, \\`/kb search\\`, or \\`/kb delete\\`.`);\n }\n}\n\nasync function cmdPdf(path) {\n const isZh = i18n._lang === 'zh';\n if (!path.trim()) {\n showSystemMessage(isZh\n ? '用法: `/pdf <file_path>`。例如: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`'\n : 'Usage: `/pdf <file_path>`. Example: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`');\n return;\n }\n showSystemMessage(isZh ? `正在处理 PDF: **${path}**...` : `Processing PDF: **${path}**...`);\n try {\n const resp = await fetch('/api/pdf/process', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ file_path: path }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(\n (isZh\n ? `PDF 处理成功 (${data.mode} 模式)。\\n\\n**知识条目:** \\`${data.kb_entry_id}\\`\\n**文件:** ${data.filename}\\n\\n**预览:**\\n${data.preview}...\\n\\n使用 \\`/kb search\\` 在知识库中查找。`\n : `PDF processed successfully (${data.mode} mode).\\n\\n**KB Entry:** \\`${data.kb_entry_id}\\`\\n**File:** ${data.filename}\\n\\n**Preview:**\\n${data.preview}...\\n\\nUse \\`/kb search\\` to find it in your knowledge base.`)\n );\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + e.message);\n }\n}\n\n// --- Messages ---\nfunction appendMessage(role, content) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n if (role === 'assistant') {\n div.innerHTML = renderMarkdown(content);\n } else {\n div.textContent = content;\n }\n elements.messages.appendChild(div);\n scrollToBottom();\n return div;\n}\n\nlet _streamingBubble = null;\n\nfunction createStreamingBubble() {\n _streamingBubble = document.createElement('div');\n _streamingBubble.className = 'message assistant streaming';\n elements.messages.appendChild(_streamingBubble);\n scrollToBottom();\n}\n\nfunction appendToken(token) {\n if (!_streamingBubble) {\n createStreamingBubble();\n }\n const currentText = _streamingBubble.dataset.rawText || '';\n const newText = currentText + token;\n _streamingBubble.dataset.rawText = newText;\n _streamingBubble.innerHTML = renderMarkdown(newText);\n scrollToBottom();\n}\n\nfunction finishStreaming() {\n if (_streamingBubble) {\n _streamingBubble.classList.remove('streaming');\n _streamingBubble = null;\n }\n state.isStreaming = false;\n elements.userInput.focus();\n}\n\nfunction showError(message) {\n const isZh = i18n._lang === 'zh';\n finishStreaming();\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.color = '#e03131';\n div.textContent = (isZh ? '错误: ' : 'Error: ') + message;\n elements.messages.appendChild(div);\n scrollToBottom();\n state.isStreaming = false;\n}\n\n// --- Confirm Card ---\nfunction showConfirmCard(confirmId, command, reason) {\n finishStreaming();\n state.isStreaming = true; // keep input disabled while waiting\n\n const isZh = i18n._lang === 'zh';\n const card = document.createElement('div');\n card.className = 'confirm-card';\n card.id = 'confirm-' + confirmId;\n card.innerHTML =\n `<div class=\"confirm-card-header\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n <span>${isZh ? 'Agent 想要修改本地文件' : 'Agent wants to modify local files'}</span>\n </div>\n <div class=\"confirm-card-body\">\n <div class=\"confirm-card-cmd\"><code>${escapeHtml(command)}</code></div>\n <div class=\"confirm-card-reason\">${escapeHtml(reason)}</div>\n </div>\n <div class=\"confirm-card-actions\">\n <button class=\"confirm-card-allow\" id=\"confirm-allow-${confirmId}\">${isZh ? '✓ 允许执行' : '✓ Allow'}</button>\n <button class=\"confirm-card-deny\" id=\"confirm-deny-${confirmId}\">${isZh ? '✗ 拒绝' : '✗ Deny'}</button>\n </div>`;\n\n removeEmptyState();\n elements.messages.appendChild(card);\n scrollToBottom();\n\n function respond(allowed) {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'confirm_response',\n confirm_id: confirmId,\n allowed: allowed,\n }));\n }\n const resultText = allowed\n ? (isZh ? '✓ 已允许执行' : '✓ Execution allowed')\n : (isZh ? '✗ 已拒绝' : '✗ Execution denied');\n card.innerHTML = `<div class=\"confirm-card-result\">${resultText}</div>`;\n card.classList.add('confirm-card-resolved');\n state.isStreaming = false;\n elements.userInput.focus();\n }\n\n document.getElementById('confirm-allow-' + confirmId).addEventListener('click', () => respond(true));\n document.getElementById('confirm-deny-' + confirmId).addEventListener('click', () => respond(false));\n}\n\n// --- Markdown Rendering ---\nfunction renderMarkdown(text) {\n if (typeof marked.setOptions === 'function') {\n marked.setOptions({\n breaks: true,\n gfm: true,\n highlight: function (code, lang) {\n if (typeof hljs !== 'undefined') {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n }\n return code;\n },\n });\n }\n\n let html = text;\n\n // Preprocess LaTeX blocks $$...$$ before markdown\n const latexBlocks = [];\n html = html.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'block', formula });\n return `%%LATEX_BLOCK_${latexBlocks.length - 1}%%`;\n });\n // Preprocess inline LaTeX $...$\n html = html.replace(/\\$(.*?)\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'inline', formula });\n return `%%LATEX_INLINE_${latexBlocks.length - 1}%%`;\n });\n\n // Render markdown\n html = marked.parse(html);\n\n // Restore LaTeX\n html = html.replace(/<p>%%LATEX_BLOCK_(\\d+)%%<\\/p>/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_BLOCK_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_INLINE_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: false });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n\n return html;\n}\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n// --- Sessions ---\nasync function loadSessions() {\n try {\n const resp = await fetch('/api/sessions');\n const sessions = await resp.json();\n renderSessionList(sessions);\n } catch (e) {\n console.error('Failed to load sessions:', e);\n }\n}\n\nfunction renderSessionList(sessions) {\n elements.sessionList.innerHTML = '';\n sessions.forEach(s => {\n const div = document.createElement('div');\n div.className = 'session-item';\n if (s.id === state.currentSessionId) {\n div.classList.add('active');\n }\n\n const title = document.createElement('span');\n title.className = 'item-title';\n title.textContent = s.title || (i18n._lang === 'zh' ? '未命名' : 'Untitled');\n\n const actions = document.createElement('span');\n actions.className = 'item-actions';\n\n const renameBtn = document.createElement('button');\n renameBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7\"/><path d=\"M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z\"/></svg>';\n renameBtn.title = i18n.t('rename');\n renameBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n startRename(s.id, title, div);\n });\n\n const deleteBtn = document.createElement('button');\n deleteBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>';\n deleteBtn.title = i18n.t('delete');\n deleteBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n deleteSession(s.id);\n });\n\n actions.appendChild(renameBtn);\n actions.appendChild(deleteBtn);\n div.appendChild(title);\n div.appendChild(actions);\n\n div.addEventListener('click', () => loadSession(s.id));\n elements.sessionList.appendChild(div);\n });\n}\n\nasync function loadSession(sessionId) {\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`);\n const data = await resp.json();\n state.currentSessionId = sessionId;\n elements.messages.innerHTML = '';\n removeEmptyState();\n if (!data.messages || data.messages.length === 0) {\n // Show empty state if no messages\n showEmptyState();\n } else {\n data.messages.forEach(msg => {\n appendMessage(msg.role, msg.content);\n });\n }\n loadSessions();\n } catch (e) {\n console.error('Failed to load session:', e);\n }\n}\n\nfunction newChat() {\n state.currentSessionId = null;\n state.isStreaming = false;\n elements.messages.innerHTML = '';\n showEmptyState();\n loadSessions();\n}\n\nasync function startRename(sessionId, titleEl, itemEl) {\n const oldTitle = titleEl.textContent;\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'rename-input';\n input.value = oldTitle;\n itemEl.replaceChild(input, titleEl);\n // Hide actions during rename\n const actions = itemEl.querySelector('.item-actions');\n if (actions) actions.style.display = 'none';\n input.focus();\n input.select();\n\n const finish = async () => {\n const newTitle = input.value.trim() || oldTitle;\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: newTitle }),\n });\n if (!resp.ok) {\n console.error('Rename failed:', resp.status);\n }\n } catch (e) {\n console.error('Rename failed:', e);\n }\n titleEl.textContent = newTitle;\n itemEl.replaceChild(titleEl, input);\n if (actions) actions.style.display = '';\n // Refresh list to reflect server state\n loadSessions();\n };\n\n input.addEventListener('blur', finish);\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n if (e.key === 'Escape') { input.value = oldTitle; input.blur(); }\n });\n}\n\nasync function deleteSession(sessionId) {\n if (!confirm(i18n.t('delete-confirm'))) return;\n try {\n await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });\n if (state.currentSessionId === sessionId) {\n newChat();\n } else {\n loadSessions();\n }\n } catch (e) {\n console.error('Delete failed:', e);\n }\n}\n\nfunction showEmptyState() {\n if (elements.messages.children.length === 0) {\n elements.messages.innerHTML = `\n <div class=\"empty-state\">\n <img src=\"/logo.png\" class=\"logo-icon\" alt=\"PuppyCLI logo\" onerror=\"this.style.display='none';this.nextElementSibling.style.display='block';\"><span class=\"logo-icon-fallback\" style=\"display:none;font-size:64px;\">🐶</span>\n <p>PuppyCLI</p>\n </div>`;\n }\n}\n\nfunction removeEmptyState() {\n const empty = elements.messages.querySelector('.empty-state');\n if (empty) empty.remove();\n}\n\n// --- Settings ---\nfunction openSettings() {\n fetch('/api/config')\n .then(r => r.json())\n .then(config => {\n const keyVal = config.api_key || '';\n $('#cfg-api-key').value = keyVal;\n $('#cfg-api-key').placeholder = keyVal ? '(already set — leave blank to keep)' : 'sk-...';\n $('#cfg-model').value = config.model || 'deepseek-chat';\n $('#cfg-base-url').value = config.base_url || 'https://api.deepseek.com';\n $('#cfg-python-env').value = config.python_env || '';\n $('#cfg-data-dir').value = config.data_dir || '';\n })\n .catch(() => {});\n elements.settingsModal.classList.remove('hidden');\n // Re-apply i18n in case modal was rendered before\n i18n.apply();\n}\n\nfunction closeSettings() {\n elements.settingsModal.classList.add('hidden');\n}\n\nasync function saveSettings() {\n const isZh = i18n._lang === 'zh';\n const updates = {\n api_key: $('#cfg-api-key').value.trim(),\n model: $('#cfg-model').value.trim(),\n base_url: $('#cfg-base-url').value.trim(),\n python_env: $('#cfg-python-env').value.trim(),\n data_dir: $('#cfg-data-dir').value.trim(),\n };\n try {\n await fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(updates),\n });\n closeSettings();\n } catch (e) {\n alert((isZh ? '保存设置失败: ' : 'Failed to save settings: ') + e.message);\n }\n}\n\n// --- Helpers ---\nfunction autoResizeTextarea() {\n const el = elements.userInput;\n el.style.height = 'auto';\n el.style.height = Math.min(el.scrollHeight, 240) + 'px';\n}\n\nfunction scrollToBottom() {\n elements.messages.scrollTop = elements.messages.scrollHeight;\n}\n"}, "ff6de6daab528104337f8252dac1de06486a5897": {"t": 1785766914.8372018, "n": 51742, "h": "37b13c9149d43fea", "c": "/* ========================================\n PuppyCLI — Frontend Application Logic\n ======================================== */\n\n// --- i18n ---\nconst i18n = {\n _lang: 'en',\n _data: {\n 'new-chat': { zh: '新建对话', en: 'New Chat' },\n 'settings': { zh: '设置', en: 'Settings' },\n 'theme': { zh: '切换主题', en: 'Toggle Theme' },\n 'docs': { zh: '文档', en: 'Docs' },\n 'lang': { zh: '切换语言', en: 'Language' },\n 'send': { zh: '发送', en: 'Send' },\n 'conversations': { zh: '对话列表', en: 'Conversations' },\n 'placeholder': { zh: '输入消息... (Enter 发送, Shift+Enter 换行)', en: 'Type a message... (Enter to send, Shift+Enter for new line)' },\n 'settings-title': { zh: '设置', en: 'Settings' },\n 'api-key': { zh: 'API 密钥', en: 'API Key' },\n 'model': { zh: '模型', en: 'Model' },\n 'base-url': { zh: '接口地址', en: 'Base URL' },\n 'python-env': { zh: 'Python 环境路径', en: 'Python Env Path' },\n 'data-dir': { zh: '数据目录', en: 'Data Directory' },\n 'save': { zh: '保存', en: 'Save' },\n 'cancel': { zh: '取消', en: 'Cancel' },\n 'rename': { zh: '重命名', en: 'Rename' },\n 'delete': { zh: '删除', en: 'Delete' },\n 'delete-confirm': { zh: '确定要删除这个对话吗?此操作不可撤销。', en: 'Delete this conversation? This cannot be undone.' },\n 'toggle-sidebar': { zh: '折叠/展开侧栏', en: 'Toggle Sidebar' },\n },\n\n t(key) {\n const entry = this._data[key];\n return entry ? (entry[this._lang] || entry.en) : key;\n },\n\n init() {\n const saved = localStorage.getItem('puppycli-lang');\n if (saved === 'zh' || saved === 'en') {\n this._lang = saved;\n } else {\n this._lang = navigator.language.startsWith('zh') ? 'zh' : 'en';\n }\n this.apply();\n },\n\n toggle() {\n this._lang = this._lang === 'zh' ? 'en' : 'zh';\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n },\n\n set(lang) {\n if (lang === 'zh' || lang === 'en') {\n this._lang = lang;\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n }\n },\n\n apply() {\n // text content\n document.querySelectorAll('[data-i18n]').forEach(el => {\n el.textContent = this.t(el.getAttribute('data-i18n'));\n });\n // title attributes\n document.querySelectorAll('[data-i18n-title]').forEach(el => {\n el.title = this.t(el.getAttribute('data-i18n-title'));\n });\n // placeholder attributes\n document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {\n el.placeholder = this.t(el.getAttribute('data-i18n-placeholder'));\n });\n // Update HTML lang attr\n document.documentElement.lang = this._lang;\n }\n};\n\nconst ICON_SUN = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>';\nconst ICON_MOON = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z\"/></svg>';\n\n// --- State ---\nconst state = {\n currentSessionId: null,\n ws: null,\n isStreaming: false,\n _lastUserMessage: '', // for /retry command\n};\n\n// --- DOM Elements ---\nconst $ = (sel) => document.querySelector(sel);\nconst $$ = (sel) => document.querySelectorAll(sel);\n\nconst elements = {\n messages: $('#messages'),\n userInput: $('#user-input'),\n btnNewChat: $('#btn-new-chat'),\n btnTheme: $('#btn-theme'),\n btnLang: $('#btn-lang'),\n btnSettings: $('#btn-settings'),\n btnSidebarToggle: $('#btn-sidebar-toggle'),\n btnDocs: $('#btn-docs'),\n sessionList: $('#session-list'),\n settingsModal: $('#settings-modal'),\n btnSettingsSave: $('#btn-settings-save'),\n btnSettingsClose: $('#btn-settings-close'),\n};\n\n// --- Initialization ---\ndocument.addEventListener('DOMContentLoaded', () => {\n i18n.init();\n initSidebar();\n loadTheme();\n loadSessions();\n setupEventListeners();\n showEmptyState();\n});\n\n// --- Sidebar Toggle ---\nfunction initSidebar() {\n if (localStorage.getItem('puppycli-sidebar') === 'collapsed') {\n $('#app').classList.add('sidebar-collapsed');\n }\n}\n\nfunction toggleSidebar() {\n const app = $('#app');\n app.classList.toggle('sidebar-collapsed');\n const collapsed = app.classList.contains('sidebar-collapsed');\n localStorage.setItem('puppycli-sidebar', collapsed ? 'collapsed' : 'expanded');\n}\n\n// --- Theme ---\nfunction loadTheme() {\n const theme = localStorage.getItem('puppycli-theme') || 'light';\n document.documentElement.setAttribute('data-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\nfunction toggleTheme() {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('puppycli-theme', next);\n elements.btnTheme.innerHTML = next === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\n// --- Slash Command Autocomplete ---\nconst SLASH_COMMANDS = [\n { cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },\n { cmd: '/clear', descZh: '清除对话', descEn: 'Clear conversation' },\n { cmd: '/new', descZh: '新建对话', descEn: 'New conversation' },\n { cmd: '/theme', descZh: '切换主题', descEn: 'Switch theme' },\n { cmd: '/lang', descZh: '切换语言', descEn: 'Switch language' },\n { cmd: '/settings', descZh: '打开设置', descEn: 'Open settings' },\n { cmd: '/export', descZh: '导出对话', descEn: 'Export conversation' },\n { cmd: '/retry', descZh: '重新生成', descEn: 'Regenerate response' },\n { cmd: '/model', descZh: '切换模型', descEn: 'Switch model' },\n { cmd: '/skill', descZh: '管理技能', descEn: 'Manage skills' },\n { cmd: '/search', descZh: '搜索技能', descEn: 'Search skills' },\n { cmd: '/kb', descZh: '知识库管理', descEn: 'Knowledge base' },\n { cmd: '/pdf', descZh: '处理 PDF', descEn: 'Process PDF' },\n { cmd: '/docs', descZh: '打开文档', descEn: 'Open docs' },\n];\n\nlet _suggIndex = -1;\nconst $sugg = () => $('#slash-suggestions');\n\nfunction updateSuggestions() {\n const val = elements.userInput.value;\n if (!val.startsWith('/') || val.includes(' ')) {\n hideSuggestions();\n return;\n }\n const query = val.slice(1).toLowerCase();\n const matches = SLASH_COMMANDS.filter(s => s.cmd.slice(1).startsWith(query));\n if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === val)) {\n hideSuggestions();\n return;\n }\n const isZh = i18n._lang === 'zh';\n $sugg().innerHTML = matches.map((s, i) =>\n `<div class=\"slash-item\" data-index=\"${i}\"><span class=\"slash-cmd\">${s.cmd}</span><span class=\"slash-desc\">${isZh ? s.descZh : s.descEn}</span></div>`\n ).join('');\n $sugg().classList.remove('hidden');\n _suggIndex = -1;\n $sugg().querySelectorAll('.slash-item').forEach(el => {\n el.addEventListener('mousedown', (e) => {\n e.preventDefault();\n const cmd = matches[parseInt(el.dataset.index)].cmd;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n elements.userInput.focus();\n });\n });\n}\n\nfunction hideSuggestions() {\n $sugg().classList.add('hidden');\n $sugg().innerHTML = '';\n _suggIndex = -1;\n}\n\n// --- Local Message Persistence ---\nasync function ensureSession() {\n if (state.currentSessionId) return;\n try {\n const resp = await fetch('/api/sessions', { method: 'POST' });\n const data = await resp.json();\n state.currentSessionId = data.session_id;\n loadSessions();\n } catch (e) {\n console.error('Failed to create session:', e);\n }\n}\n\nasync function saveLocalMessage(role, content) {\n if (!state.currentSessionId || !content) return;\n try {\n await fetch(`/api/sessions/${state.currentSessionId}/messages`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, content }),\n });\n } catch (e) {\n console.error('Failed to save message:', e);\n }\n}\n\nfunction navigateSuggestions(dir) {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0) return;\n items.forEach(el => el.classList.remove('active'));\n _suggIndex = Math.max(0, Math.min(items.length - 1, _suggIndex + dir));\n items[_suggIndex].classList.add('active');\n items[_suggIndex].scrollIntoView({ block: 'nearest' });\n}\n\nfunction selectSuggestion() {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0 || _suggIndex < 0) return false;\n const cmd = items[_suggIndex].querySelector('.slash-cmd').textContent;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n return true;\n}\n\n// --- Event Listeners ---\nfunction setupEventListeners() {\n elements.userInput.addEventListener('keydown', (e) => {\n // Handle suggestion navigation first\n if (!$sugg().classList.contains('hidden')) {\n if (e.key === 'ArrowDown') { e.preventDefault(); navigateSuggestions(1); return; }\n if (e.key === 'ArrowUp') { e.preventDefault(); navigateSuggestions(-1); return; }\n if (e.key === 'Enter' && !e.shiftKey) {\n if (selectSuggestion()) { e.preventDefault(); return; }\n }\n if (e.key === 'Escape') { e.preventDefault(); hideSuggestions(); return; }\n if (e.key === 'Tab') { e.preventDefault(); selectSuggestion(); return; }\n }\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n elements.userInput.addEventListener('input', () => {\n autoResizeTextarea();\n updateSuggestions();\n });\n elements.btnNewChat.addEventListener('click', newChat);\n elements.btnTheme.addEventListener('click', toggleTheme);\n elements.btnLang.addEventListener('click', () => i18n.toggle());\n elements.btnSidebarToggle.addEventListener('click', toggleSidebar);\n elements.btnSettings.addEventListener('click', openSettings);\n elements.btnSettingsClose.addEventListener('click', closeSettings);\n elements.btnSettingsSave.addEventListener('click', saveSettings);\n if (elements.btnDocs) {\n elements.btnDocs.addEventListener('click', () => window.open('/help/', '_blank'));\n }\n}\n\n// --- WebSocket ---\nfunction connectWebSocket() {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) return;\n\n const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';\n const wsUrl = `${protocol}//${location.host}/ws/chat`;\n state.ws = new WebSocket(wsUrl);\n\n state.ws.onmessage = (event) => {\n const data = JSON.parse(event.data);\n handleWSMessage(data);\n };\n\n state.ws.onclose = () => {\n state.ws = null;\n };\n\n state.ws.onerror = (err) => {\n console.error('WebSocket error:', err);\n };\n}\n\nfunction handleWSMessage(data) {\n switch (data.type) {\n case 'session_id':\n state.currentSessionId = data.session_id;\n break;\n case 'token':\n appendToken(data.content);\n break;\n case 'done':\n finishStreaming();\n loadSessions();\n break;\n case 'error':\n showError(data.message);\n break;\n case 'confirm_tool':\n showConfirmCard(data.confirm_id, data.command, data.reason);\n break;\n }\n}\n\nfunction sendMessage() {\n const content = elements.userInput.value.trim();\n hideSuggestions();\n if (!content || state.isStreaming) return;\n\n // Bang commands — execute PowerShell\n if (content.startsWith('!')) {\n execPowerShell(content.slice(1).trim());\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n // Slash commands — handle locally, don't send to AI\n if (content.startsWith('/')) {\n handleSlashCommand(content);\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n state._lastUserMessage = content;\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: content,\n session_id: state.currentSessionId,\n }));\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n\n removeEmptyState();\n appendMessage('user', content);\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\n// --- Slash Commands ---\nfunction handleSlashCommand(input) {\n const parts = input.split(/\\s+/);\n const cmd = parts[0].toLowerCase();\n const arg = parts.slice(1).join(' ');\n\n // Save command as user message (fire and forget)\n ensureSession().then(() => saveLocalMessage('user', input));\n\n switch (cmd) {\n case '/help':\n cmdHelp();\n break;\n case '/clear':\n cmdClear();\n break;\n case '/new':\n cmdNew();\n break;\n case '/theme':\n cmdTheme(arg);\n break;\n case '/lang':\n cmdLang(arg);\n break;\n case '/settings':\n cmdSettings();\n break;\n case '/export':\n cmdExport();\n break;\n case '/retry':\n cmdRetry();\n break;\n case '/model':\n cmdModel(arg);\n break;\n case '/skill':\n cmdSkill(arg);\n break;\n case '/search':\n cmdSkillSearch(arg);\n break;\n case '/kb':\n cmdKnowledge(arg);\n break;\n case '/pdf':\n cmdPdf(arg);\n break;\n case '/docs':\n window.open('/help/', '_blank');\n showSystemMessage(i18n._lang === 'zh'\n ? '文档已在新标签页中打开。'\n : 'Documentation opened in a new tab.');\n break;\n default:\n showSystemMessage(i18n._lang === 'zh'\n ? `未知命令: ${cmd}。输入 /help 查看可用命令。`\n : `Unknown command: ${cmd}. Type /help for available commands.`);\n }\n}\n\nfunction showSystemMessage(text) {\n removeEmptyState();\n saveLocalMessage('assistant', text);\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.fontStyle = 'italic';\n div.style.color = 'var(--text-secondary)';\n div.innerHTML = renderMarkdown(text);\n elements.messages.appendChild(div);\n scrollToBottom();\n}\n\nfunction cmdHelp() {\n const isZh = i18n._lang === 'zh';\n showSystemMessage(isZh\n ? `**可用命令**\n\n| 命令 | 说明 |\n|---------|-------------|\n| \\`/help\\` | 显示此帮助信息 |\n| \\`/clear\\` | 清除当前对话 |\n| \\`/new\\` | 创建新对话 |\n| \\`/theme dark\\\\|light\\` | 切换颜色主题 |\n| \\`/lang zh\\\\|en\\` | 切换界面语言 |\n| \\`/settings\\` | 打开设置面板 |\n| \\`/export\\` | 导出对话为 Markdown |\n| \\`/retry\\` | 重新生成上一次回复 |\n| \\`/model <name>\\` | 快速切换 AI 模型 |\n| \\`/skill list\\` | 列出已安装的技能 |\n| \\`/skill install <source>\\` | 安装技能(路径、GitHub、URL),自动适配前端 |\n| \\`/skill adapt <name>\\` | 对已有技能进行 PuppyCLI 前端适配 |\n| \\`/skill restore <name>\\` | 还原技能为原始版本 |\n| \\`/skill remove <name>\\` | 移除技能 |\n| \\`/search <query>\\` | 在 agentskills.io 上搜索技能 |\n| \\`!<command>\\` | 执行 PowerShell 命令(例如 \\`!Get-Date\\`) |\n| \\`/kb list\\` | 列出知识库条目 |\n| \\`/kb add <title>\\\\|<content>\\` | 添加知识库条目 |\n| \\`/kb search <query>\\` | 搜索知识库 |\n| \\`/kb delete <id>\\` | 删除知识库条目 |\n| \\`/pdf <path>\\` | 处理 PDF 文件并加入知识库 |\n| \\`/docs\\` | 在新标签页中打开文档 |`\n : `**Available Commands**\n\n| Command | Description |\n|---------|-------------|\n| \\`/help\\` | Show this help message |\n| \\`/clear\\` | Clear current conversation |\n| \\`/new\\` | Start a new conversation |\n| \\`/theme dark\\\\|light\\` | Switch color theme |\n| \\`/lang zh\\\\|en\\` | Switch interface language |\n| \\`/settings\\` | Open settings panel |\n| \\`/export\\` | Export conversation as Markdown |\n| \\`/retry\\` | Regenerate last AI response |\n| \\`/model <name>\\` | Quick-switch AI model |\n| \\`/skill list\\` | List installed skills |\n| \\`/skill install <source>\\` | Install a skill (path, GitHub, URL), auto-adapts |\n| \\`/skill adapt <name>\\` | Adapt a skill for PuppyCLI frontend |\n| \\`/skill restore <name>\\` | Restore skill to original version |\n| \\`/skill remove <name>\\` | Remove a skill |\n| \\`/search <query>\\` | Search skills on agentskills.io |\n| \\`!<command>\\` | Execute a PowerShell command (e.g., \\`!Get-Date\\`) |\n| \\`/kb list\\` | List knowledge base entries |\n| \\`/kb add <title>\\\\|<content>\\` | Add to knowledge base |\n| \\`/kb search <query>\\` | Search knowledge base |\n| \\`/kb delete <id>\\` | Delete a knowledge entry |\n| \\`/pdf <path>\\` | Process PDF file → knowledge base |\n| \\`/docs\\` | Open documentation in new tab |`);\n}\n\nfunction cmdClear() {\n elements.messages.innerHTML = '';\n showEmptyState();\n}\n\nfunction cmdNew() {\n newChat();\n}\n\nfunction cmdTheme(arg) {\n const theme = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-theme', theme);\n localStorage.setItem('puppycli-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n showSystemMessage(isZh\n ? `主题已切换至 **${theme === 'dark' ? '深色' : '浅色'}** 模式。`\n : `Theme switched to **${theme}** mode.`);\n } else {\n const current = document.documentElement.getAttribute('data-theme') || 'light';\n showSystemMessage(isZh\n ? `当前主题: **${current}**。用法: \\`/theme dark\\` 或 \\`/theme light\\``\n : `Current theme: **${current}**. Usage: \\`/theme dark\\` or \\`/theme light\\``);\n }\n}\n\nfunction cmdLang(arg) {\n const lang = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (lang === 'zh' || lang === 'en') {\n i18n.set(lang);\n showSystemMessage(isZh\n ? `语言已切换为 **${lang === 'zh' ? '中文' : 'English'}**。`\n : `Language switched to **${lang === 'zh' ? '中文' : 'English'}**.`);\n } else if (!lang) {\n showSystemMessage(isZh\n ? `当前语言: **${i18n._lang === 'zh' ? '中文' : 'English'}**。用法: \\`/lang zh\\` 或 \\`/lang en\\``\n : `Current language: **${i18n._lang === 'zh' ? '中文' : 'English'}**. Usage: \\`/lang zh\\` or \\`/lang en\\``);\n } else {\n showSystemMessage(isZh\n ? `不支持的语言: **${lang}**。请使用 \\`/lang zh\\` 或 \\`/lang en\\``\n : `Unsupported language: **${lang}**. Use \\`/lang zh\\` or \\`/lang en\\``);\n }\n}\n\nfunction cmdSettings() {\n openSettings();\n}\n\nfunction cmdExport() {\n const isZh = i18n._lang === 'zh';\n const messages = elements.messages.querySelectorAll('.message');\n if (messages.length === 0) {\n showSystemMessage(isZh ? '没有可导出的内容。' : 'Nothing to export.');\n return;\n }\n\n let md = `# PuppyCLI Conversation\\n\\n`;\n md += `_Exported at ${new Date().toLocaleString()}_\\n\\n---\\n\\n`;\n\n messages.forEach(msg => {\n if (msg.classList.contains('user')) {\n md += `**You:**\\n${msg.textContent}\\n\\n`;\n } else if (msg.classList.contains('assistant') && !msg.style.fontStyle) {\n const raw = msg.dataset.rawText || msg.textContent;\n md += `**PuppyCLI:**\\n${raw}\\n\\n`;\n }\n });\n\n const blob = new Blob([md], { type: 'text/markdown' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `puppycli-export-${Date.now()}.md`;\n a.click();\n URL.revokeObjectURL(url);\n showSystemMessage(isZh\n ? '对话已导出为 Markdown 文件。'\n : 'Conversation exported as Markdown file.');\n}\n\nfunction cmdRetry() {\n const isZh = i18n._lang === 'zh';\n if (state.isStreaming) return;\n if (!state._lastUserMessage) {\n showSystemMessage(isZh\n ? '没有可重试的消息。请先发送一条消息。'\n : 'Nothing to retry. Send a message first.');\n return;\n }\n\n // Remove last assistant bubble if present\n const msgs = elements.messages.querySelectorAll('.message.assistant');\n if (msgs.length > 0) {\n const lastAssistant = msgs[msgs.length - 1];\n if (!lastAssistant.style.fontStyle) {\n lastAssistant.remove();\n }\n }\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: state._lastUserMessage,\n session_id: state.currentSessionId,\n }));\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\nfunction cmdModel(arg) {\n const isZh = i18n._lang === 'zh';\n const model = arg.trim();\n if (!model) {\n showSystemMessage(isZh\n ? '用法: `/model <name>`。例如: `/model deepseek-chat`'\n : 'Usage: `/model <name>`. Example: `/model deepseek-chat`');\n return;\n }\n fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model }),\n })\n .then(r => r.json())\n .then(() => {\n showSystemMessage(isZh\n ? `模型已切换至 **${model}**(下一条消息生效)。`\n : `Model switched to **${model}** (next message will use it).`);\n })\n .catch(() => {\n showSystemMessage(isZh\n ? '模型切换失败,请检查设置。'\n : 'Failed to switch model. Check settings.');\n });\n}\n\nasync function cmdSkill(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const target = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/skills');\n const skills = await resp.json();\n if (skills.length === 0) {\n showSystemMessage(isZh\n ? '没有已安装的技能。使用 `/skill install <source>` 安装,或 `/search <query>` 搜索技能。'\n : 'No skills installed. Use `/skill install <source>` to install one, or `/search <query>` to find skills.');\n return;\n }\n let md = isZh\n ? '**已安装技能**\\n\\n| 名称 | 描述 | 位置 | 前端适配 |\\n|------|-------------|----------|----------|\\n'\n : '**Installed Skills**\\n\\n| Name | Description | Location | Adapted |\\n|------|-------------|----------|----------|\\n';\n skills.forEach(s => {\n const adapted = s.is_adapted\n ? (isZh ? '✅ 已适配' : '✅ Yes')\n : (isZh ? '⚠️ 未适配' : '⚠️ No');\n md += `| \\`${s.name}\\` | ${(s.description||'').slice(0, 80)} | ${s.location} | ${adapted} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/skill adapt <name>` 适配前端,`/skill restore <name>` 还原,`/skill remove <name>` 移除。'\n : '\\nUse `/skill adapt <name>` to adapt, `/skill restore <name>` to revert, `/skill remove <name>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '获取技能列表失败: ' : 'Failed to list skills: ') + e.message);\n }\n } else if (action === 'install') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill install <github repo | URL | 本地路径>`。例如: `/skill install anthropics/skills`'\n : 'Usage: `/skill install <github repo | URL | local path>`. Example: `/skill install anthropics/skills`');\n return;\n }\n showSystemMessage(isZh ? `正在从 **${target}** 安装技能...` : `Installing skill from **${target}**...`);\n try {\n const resp = await fetch('/api/skills/install', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ source: target }),\n });\n if (resp.ok) {\n const data = await resp.json();\n const adapted = data.skill.is_adapted;\n const adaptMsg = adapted\n ? (isZh ? ' ✅ 已自动进行前端适配' : ' ✅ Auto-adapted for PuppyCLI')\n : (isZh ? ' (未检测到 CLI 特征,无需适配)' : ' (no CLI patterns detected, no adaptation needed)');\n showSystemMessage(isZh\n ? `技能 **${data.skill.name}** 安装成功!${adaptMsg} 输入 /skill list 查看所有技能。`\n : `Skill **${data.skill.name}** installed successfully!${adaptMsg} Type /skill list to see all skills.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '安装失败: ' : 'Install failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '安装技能失败: ' : 'Failed to install skill: ') + e.message);\n }\n } else if (action === 'remove') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill remove <name>`。使用 `/skill list` 查看已安装技能。'\n : 'Usage: `/skill remove <name>`. Use `/skill list` to see installed skills.');\n return;\n }\n try {\n const resp = await fetch(`/api/skills/${target}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `技能 **${target}** 已移除。` : `Skill **${target}** removed.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '移除失败: ' : 'Remove failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '移除技能失败: ' : 'Failed to remove skill: ') + e.message);\n }\n } else if (action === 'adapt') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill adapt <name>`。对已安装技能进行 PuppyCLI 前端适配。'\n : 'Usage: `/skill adapt <name>`. Adapt an installed skill for PuppyCLI frontend.');\n return;\n }\n showSystemMessage(isZh ? `正在适配 **${target}**...` : `Adapting **${target}**...`);\n try {\n const resp = await fetch(`/api/skills/${target}/adapt`, { method: 'POST' });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `技能 **${target}** 前端适配完成。原始文件已备份为 SKILL.md.orig。`\n : `Skill **${target}** adapted for PuppyCLI. Original backed up as SKILL.md.orig.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '适配失败: ' : 'Adapt failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '适配技能失败: ' : 'Failed to adapt skill: ') + e.message);\n }\n } else if (action === 'restore') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill restore <name>`。将技能还原为原始版本。'\n : 'Usage: `/skill restore <name>`. Restore skill to original version.');\n return;\n }\n showSystemMessage(isZh ? `正在还原 **${target}**...` : `Restoring **${target}**...`);\n try {\n const resp = await fetch(`/api/skills/${target}/restore`, { method: 'POST' });\n if (resp.ok) {\n showSystemMessage(isZh\n ? `技能 **${target}** 已还原为原始版本。`\n : `Skill **${target}** restored to original.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '还原失败: ' : 'Restore failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '还原技能失败: ' : 'Failed to restore skill: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的技能操作: **${action}**。请使用 \\`/skill list\\`、\\`/skill install\\` 或 \\`/skill remove\\`。`\n : `Unknown skill action: **${action}**. Use \\`/skill list\\`, \\`/skill install\\`, or \\`/skill remove\\`.`);\n }\n}\n\nasync function cmdSkillSearch(query) {\n const isZh = i18n._lang === 'zh';\n if (!query.trim()) {\n showSystemMessage(isZh\n ? '用法: `/search <query>`。例如: `/search pdf processing`'\n : 'Usage: `/search <query>`. Example: `/search pdf processing`');\n return;\n }\n showSystemMessage(isZh\n ? `正在 agentskills.io 上搜索 **${query}**...`\n : `Searching for **${query}** on agentskills.io...`);\n try {\n const resp = await fetch(`/api/skills/search?q=${encodeURIComponent(query)}&limit=10`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${query}** 相关的技能。请尝试其他关键词。`\n : `No skills found for **${query}**. Try a different keyword.`);\n return;\n }\n let md = isZh\n ? `**搜索 \"${query}\" 的结果**\\n\\n| 名称 | 描述 | 来源 |\\n|------|-------------|--------|\\n`\n : `**Search Results for \"${query}\"**\\n\\n| Name | Description | Source |\\n|------|-------------|--------|\\n`;\n results.forEach(r => {\n const desc = (r.description || '').slice(0, 60);\n const src = r.source ? `[repo](${r.source})` : '-';\n md += `| \\`${r.name}\\` | ${desc} | ${src} |\\n`;\n });\n md += isZh\n ? `\\n安装: \\`/skill install <source>\\` (例如 \\`/skill install ${results[0].source || results[0].name}\\`)`\n : `\\nTo install: \\`/skill install <source>\\` (e.g., \\`/skill install ${results[0].source || results[0].name}\\`)`;\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n}\n\nasync function execPowerShell(command) {\n const isZh = i18n._lang === 'zh';\n if (!command) return;\n\n await ensureSession();\n saveLocalMessage('user', '!' + command);\n\n removeEmptyState();\n appendMessage('user', '!' + command);\n\n const resultDiv = document.createElement('div');\n resultDiv.className = 'message assistant';\n resultDiv.innerHTML = renderMarkdown(\n (isZh ? '运行中: `' : 'Running: `') + escapeHtml(command) + '`...');\n elements.messages.appendChild(resultDiv);\n scrollToBottom();\n\n try {\n const resp = await fetch('/api/powershell', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ command }),\n });\n const data = await resp.json();\n\n let output = '';\n if (data.stdout) {\n output += '```\\n' + data.stdout + '\\n```';\n }\n if (data.stderr) {\n output += '\\n\\n**Stderr:**\\n```\\n' + data.stderr + '\\n```';\n }\n if (data.returncode !== 0 && !data.stderr && !data.stdout) {\n output += '\\n_Exit code: ' + data.returncode + '_';\n }\n if (!output.trim()) {\n const noOutput = isZh ? '(无输出)' : '(no output)';\n output = '_' + noOutput + '_';\n saveLocalMessage('assistant', noOutput);\n } else {\n saveLocalMessage('assistant', output);\n }\n\n resultDiv.innerHTML = renderMarkdown(output);\n } catch (e) {\n const errMsg = (isZh ? '错误: ' : 'Error: ') + e.message;\n saveLocalMessage('assistant', errMsg);\n resultDiv.innerHTML = renderMarkdown('**' + errMsg + '**');\n }\n scrollToBottom();\n}\n\nasync function cmdKnowledge(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const rest = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/kb');\n const entries = await resp.json();\n if (entries.length === 0) {\n showSystemMessage(isZh\n ? '知识库为空。使用 `/kb add <title>|<content>` 添加条目。'\n : 'Knowledge base is empty. Use `/kb add <title>|<content>` to add entries.');\n return;\n }\n let md = isZh\n ? `**知识库** (${entries.length} 条)\\n\\n| ID | 标题 | 标签 | 预览 |\\n|----|-------|------|---------|\\n`\n : `**Knowledge Base** (${entries.length} entries)\\n\\n| ID | Title | Tags | Preview |\\n|----|-------|------|---------|\\n`;\n entries.forEach(e => {\n md += `| \\`${e.id}\\` | ${e.title} | ${(e.tags||[]).join(', ')} | ${(e.preview||'').slice(0, 50)} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/kb search <query>` 搜索,`/kb delete <id>` 删除。'\n : '\\nUse `/kb search <query>` to search, `/kb delete <id>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'add') {\n const pipeIdx = rest.indexOf('|');\n if (pipeIdx < 0) {\n showSystemMessage(isZh\n ? '用法: `/kb add <title>|<content>`。使用竖线 `|` 分隔标题和内容。'\n : 'Usage: `/kb add <title>|<content>`. Use pipe `|` to separate title and content.');\n return;\n }\n const title = rest.slice(0, pipeIdx).trim();\n const content = rest.slice(pipeIdx + 1).trim();\n if (!title || !content) {\n showSystemMessage(isZh ? '标题和内容均为必填。' : 'Both title and content are required.');\n return;\n }\n try {\n const resp = await fetch('/api/kb', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title, content }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `知识条目 **${data.entry.id}** 已添加: _${title}_`\n : `Knowledge entry **${data.entry.id}** added: _${title}_`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'search') {\n if (!rest) {\n showSystemMessage(isZh ? '用法: `/kb search <query>`' : 'Usage: `/kb search <query>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/search?q=${encodeURIComponent(rest)}`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${rest}** 相关的内容。`\n : `Nothing found for **${rest}**.`);\n return;\n }\n let md = (isZh ? `**搜索: \"${rest}\"**\\n\\n` : `**Search: \"${rest}\"**\\n\\n`);\n results.forEach(r => {\n md += `### ${r.title}\\n\\`${r.id}\\` | tags: ${(r.tags||[]).join(', ')}\\n\\n${r.content}\\n\\n---\\n`;\n });\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n } else if (action === 'delete') {\n const entryId = rest.trim();\n if (!entryId) {\n showSystemMessage(isZh ? '用法: `/kb delete <id>`' : 'Usage: `/kb delete <id>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/${entryId}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `条目 **${entryId}** 已删除。` : `Entry **${entryId}** deleted.`);\n } else {\n showSystemMessage(isZh ? '删除失败: 条目未找到。' : 'Delete failed: entry not found.');\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的知识库操作: **${action}**。请使用 \\`/kb list\\`、\\`/kb add\\`、\\`/kb search\\` 或 \\`/kb delete\\`。`\n : `Unknown kb action: **${action}**. Use \\`/kb list\\`, \\`/kb add\\`, \\`/kb search\\`, or \\`/kb delete\\`.`);\n }\n}\n\nasync function cmdPdf(path) {\n const isZh = i18n._lang === 'zh';\n if (!path.trim()) {\n showSystemMessage(isZh\n ? '用法: `/pdf <file_path>`。例如: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`'\n : 'Usage: `/pdf <file_path>`. Example: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`');\n return;\n }\n showSystemMessage(isZh ? `正在处理 PDF: **${path}**...` : `Processing PDF: **${path}**...`);\n try {\n const resp = await fetch('/api/pdf/process', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ file_path: path }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(\n (isZh\n ? `PDF 处理成功 (${data.mode} 模式)。\\n\\n**知识条目:** \\`${data.kb_entry_id}\\`\\n**文件:** ${data.filename}\\n\\n**预览:**\\n${data.preview}...\\n\\n使用 \\`/kb search\\` 在知识库中查找。`\n : `PDF processed successfully (${data.mode} mode).\\n\\n**KB Entry:** \\`${data.kb_entry_id}\\`\\n**File:** ${data.filename}\\n\\n**Preview:**\\n${data.preview}...\\n\\nUse \\`/kb search\\` to find it in your knowledge base.`)\n );\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + e.message);\n }\n}\n\n// --- Messages ---\nfunction appendMessage(role, content) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n if (role === 'assistant') {\n div.innerHTML = renderMarkdown(content);\n } else {\n div.textContent = content;\n }\n elements.messages.appendChild(div);\n scrollToBottom();\n return div;\n}\n\nlet _streamingBubble = null;\n\nfunction createStreamingBubble() {\n _streamingBubble = document.createElement('div');\n _streamingBubble.className = 'message assistant streaming';\n elements.messages.appendChild(_streamingBubble);\n scrollToBottom();\n}\n\nfunction appendToken(token) {\n if (!_streamingBubble) {\n createStreamingBubble();\n }\n const currentText = _streamingBubble.dataset.rawText || '';\n const newText = currentText + token;\n _streamingBubble.dataset.rawText = newText;\n _streamingBubble.innerHTML = renderMarkdown(newText);\n scrollToBottom();\n}\n\nfunction finishStreaming() {\n if (_streamingBubble) {\n _streamingBubble.classList.remove('streaming');\n _streamingBubble = null;\n }\n state.isStreaming = false;\n elements.userInput.focus();\n}\n\nfunction showError(message) {\n const isZh = i18n._lang === 'zh';\n finishStreaming();\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.color = '#e03131';\n div.textContent = (isZh ? '错误: ' : 'Error: ') + message;\n elements.messages.appendChild(div);\n scrollToBottom();\n state.isStreaming = false;\n}\n\n// --- Confirm Card ---\nfunction showConfirmCard(confirmId, command, reason) {\n finishStreaming();\n state.isStreaming = true; // keep input disabled while waiting\n\n const isZh = i18n._lang === 'zh';\n const card = document.createElement('div');\n card.className = 'confirm-card';\n card.id = 'confirm-' + confirmId;\n card.innerHTML =\n `<div class=\"confirm-card-header\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n <span>${isZh ? 'Agent 想要修改本地文件' : 'Agent wants to modify local files'}</span>\n </div>\n <div class=\"confirm-card-body\">\n <div class=\"confirm-card-cmd\"><code>${escapeHtml(command)}</code></div>\n <div class=\"confirm-card-reason\">${escapeHtml(reason)}</div>\n </div>\n <div class=\"confirm-card-actions\">\n <button class=\"confirm-card-allow\" id=\"confirm-allow-${confirmId}\">${isZh ? '✓ 允许执行' : '✓ Allow'}</button>\n <button class=\"confirm-card-deny\" id=\"confirm-deny-${confirmId}\">${isZh ? '✗ 拒绝' : '✗ Deny'}</button>\n </div>`;\n\n removeEmptyState();\n elements.messages.appendChild(card);\n scrollToBottom();\n\n function respond(allowed) {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'confirm_response',\n confirm_id: confirmId,\n allowed: allowed,\n }));\n }\n const resultText = allowed\n ? (isZh ? '✓ 已允许执行' : '✓ Execution allowed')\n : (isZh ? '✗ 已拒绝' : '✗ Execution denied');\n card.innerHTML = `<div class=\"confirm-card-result\">${resultText}</div>`;\n card.classList.add('confirm-card-resolved');\n state.isStreaming = false;\n elements.userInput.focus();\n }\n\n document.getElementById('confirm-allow-' + confirmId).addEventListener('click', () => respond(true));\n document.getElementById('confirm-deny-' + confirmId).addEventListener('click', () => respond(false));\n}\n\n// --- Markdown Rendering ---\nfunction renderMarkdown(text) {\n if (typeof marked.setOptions === 'function') {\n marked.setOptions({\n breaks: true,\n gfm: true,\n highlight: function (code, lang) {\n if (typeof hljs !== 'undefined') {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n }\n return code;\n },\n });\n }\n\n let html = text;\n\n // Preprocess LaTeX blocks $$...$$ before markdown\n const latexBlocks = [];\n html = html.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'block', formula });\n return `%%LATEX_BLOCK_${latexBlocks.length - 1}%%`;\n });\n // Preprocess inline LaTeX $...$\n html = html.replace(/\\$(.*?)\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'inline', formula });\n return `%%LATEX_INLINE_${latexBlocks.length - 1}%%`;\n });\n\n // Render markdown\n html = marked.parse(html);\n\n // Restore LaTeX\n html = html.replace(/<p>%%LATEX_BLOCK_(\\d+)%%<\\/p>/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_BLOCK_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_INLINE_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: false });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n\n return html;\n}\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n// --- Sessions ---\nasync function loadSessions() {\n try {\n const resp = await fetch('/api/sessions');\n const sessions = await resp.json();\n renderSessionList(sessions);\n } catch (e) {\n console.error('Failed to load sessions:', e);\n }\n}\n\nfunction renderSessionList(sessions) {\n elements.sessionList.innerHTML = '';\n sessions.forEach(s => {\n const div = document.createElement('div');\n div.className = 'session-item';\n if (s.id === state.currentSessionId) {\n div.classList.add('active');\n }\n\n const title = document.createElement('span');\n title.className = 'item-title';\n title.textContent = s.title || (i18n._lang === 'zh' ? '未命名' : 'Untitled');\n\n const actions = document.createElement('span');\n actions.className = 'item-actions';\n\n const renameBtn = document.createElement('button');\n renameBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7\"/><path d=\"M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z\"/></svg>';\n renameBtn.title = i18n.t('rename');\n renameBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n startRename(s.id, title, div);\n });\n\n const deleteBtn = document.createElement('button');\n deleteBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>';\n deleteBtn.title = i18n.t('delete');\n deleteBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n deleteSession(s.id);\n });\n\n actions.appendChild(renameBtn);\n actions.appendChild(deleteBtn);\n div.appendChild(title);\n div.appendChild(actions);\n\n div.addEventListener('click', () => loadSession(s.id));\n elements.sessionList.appendChild(div);\n });\n}\n\nasync function loadSession(sessionId) {\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`);\n const data = await resp.json();\n state.currentSessionId = sessionId;\n elements.messages.innerHTML = '';\n removeEmptyState();\n if (!data.messages || data.messages.length === 0) {\n // Show empty state if no messages\n showEmptyState();\n } else {\n data.messages.forEach(msg => {\n appendMessage(msg.role, msg.content);\n });\n }\n loadSessions();\n } catch (e) {\n console.error('Failed to load session:', e);\n }\n}\n\nfunction newChat() {\n state.currentSessionId = null;\n state.isStreaming = false;\n elements.messages.innerHTML = '';\n showEmptyState();\n loadSessions();\n}\n\nasync function startRename(sessionId, titleEl, itemEl) {\n const oldTitle = titleEl.textContent;\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'rename-input';\n input.value = oldTitle;\n itemEl.replaceChild(input, titleEl);\n // Hide actions during rename\n const actions = itemEl.querySelector('.item-actions');\n if (actions) actions.style.display = 'none';\n input.focus();\n input.select();\n\n const finish = async () => {\n const newTitle = input.value.trim() || oldTitle;\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: newTitle }),\n });\n if (!resp.ok) {\n console.error('Rename failed:', resp.status);\n }\n } catch (e) {\n console.error('Rename failed:', e);\n }\n titleEl.textContent = newTitle;\n itemEl.replaceChild(titleEl, input);\n if (actions) actions.style.display = '';\n // Refresh list to reflect server state\n loadSessions();\n };\n\n input.addEventListener('blur', finish);\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n if (e.key === 'Escape') { input.value = oldTitle; input.blur(); }\n });\n}\n\nasync function deleteSession(sessionId) {\n if (!confirm(i18n.t('delete-confirm'))) return;\n try {\n await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });\n if (state.currentSessionId === sessionId) {\n newChat();\n } else {\n loadSessions();\n }\n } catch (e) {\n console.error('Delete failed:', e);\n }\n}\n\nfunction showEmptyState() {\n if (elements.messages.children.length === 0) {\n elements.messages.innerHTML = `\n <div class=\"empty-state\">\n <img src=\"/logo.png\" class=\"logo-icon\" alt=\"PuppyCLI logo\" onerror=\"this.style.display='none';this.nextElementSibling.style.display='block';\"><span class=\"logo-icon-fallback\" style=\"display:none;font-size:64px;\">🐶</span>\n <p>PuppyCLI</p>\n </div>`;\n }\n}\n\nfunction removeEmptyState() {\n const empty = elements.messages.querySelector('.empty-state');\n if (empty) empty.remove();\n}\n\n// --- Settings ---\nfunction openSettings() {\n fetch('/api/config')\n .then(r => r.json())\n .then(config => {\n const keyVal = config.api_key || '';\n $('#cfg-api-key').value = keyVal;\n $('#cfg-api-key').placeholder = keyVal ? '(already set — leave blank to keep)' : 'sk-...';\n $('#cfg-model').value = config.model || 'deepseek-chat';\n $('#cfg-base-url').value = config.base_url || 'https://api.deepseek.com';\n $('#cfg-python-env').value = config.python_env || '';\n $('#cfg-data-dir').value = config.data_dir || '';\n })\n .catch(() => {});\n elements.settingsModal.classList.remove('hidden');\n // Re-apply i18n in case modal was rendered before\n i18n.apply();\n}\n\nfunction closeSettings() {\n elements.settingsModal.classList.add('hidden');\n}\n\nasync function saveSettings() {\n const isZh = i18n._lang === 'zh';\n const updates = {\n api_key: $('#cfg-api-key').value.trim(),\n model: $('#cfg-model').value.trim(),\n base_url: $('#cfg-base-url').value.trim(),\n python_env: $('#cfg-python-env').value.trim(),\n data_dir: $('#cfg-data-dir').value.trim(),\n };\n try {\n await fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(updates),\n });\n closeSettings();\n } catch (e) {\n alert((isZh ? '保存设置失败: ' : 'Failed to save settings: ') + e.message);\n }\n}\n\n// --- Helpers ---\nfunction autoResizeTextarea() {\n const el = elements.userInput;\n el.style.height = 'auto';\n el.style.height = Math.min(el.scrollHeight, 240) + 'px';\n}\n\nfunction scrollToBottom() {\n elements.messages.scrollTop = elements.messages.scrollHeight;\n}\n"}, "61168b4feb7f0cce272196fa2767953138420462": {"t": 1785766916.4471216, "n": 48812, "h": "bb94bdff79dcf438", "c": "/* ========================================\n PuppyCLI — Frontend Application Logic\n ======================================== */\n\n// --- i18n ---\nconst i18n = {\n _lang: 'en',\n _data: {\n 'new-chat': { zh: '新建对话', en: 'New Chat' },\n 'settings': { zh: '设置', en: 'Settings' },\n 'theme': { zh: '切换主题', en: 'Toggle Theme' },\n 'docs': { zh: '文档', en: 'Docs' },\n 'lang': { zh: '切换语言', en: 'Language' },\n 'send': { zh: '发送', en: 'Send' },\n 'conversations': { zh: '对话列表', en: 'Conversations' },\n 'placeholder': { zh: '输入消息... (Enter 发送, Shift+Enter 换行)', en: 'Type a message... (Enter to send, Shift+Enter for new line)' },\n 'settings-title': { zh: '设置', en: 'Settings' },\n 'api-key': { zh: 'API 密钥', en: 'API Key' },\n 'model': { zh: '模型', en: 'Model' },\n 'base-url': { zh: '接口地址', en: 'Base URL' },\n 'python-env': { zh: 'Python 环境路径', en: 'Python Env Path' },\n 'data-dir': { zh: '数据目录', en: 'Data Directory' },\n 'save': { zh: '保存', en: 'Save' },\n 'cancel': { zh: '取消', en: 'Cancel' },\n 'rename': { zh: '重命名', en: 'Rename' },\n 'delete': { zh: '删除', en: 'Delete' },\n 'delete-confirm': { zh: '确定要删除这个对话吗?此操作不可撤销。', en: 'Delete this conversation? This cannot be undone.' },\n 'toggle-sidebar': { zh: '折叠/展开侧栏', en: 'Toggle Sidebar' },\n },\n\n t(key) {\n const entry = this._data[key];\n return entry ? (entry[this._lang] || entry.en) : key;\n },\n\n init() {\n const saved = localStorage.getItem('puppycli-lang');\n if (saved === 'zh' || saved === 'en') {\n this._lang = saved;\n } else {\n this._lang = navigator.language.startsWith('zh') ? 'zh' : 'en';\n }\n this.apply();\n },\n\n toggle() {\n this._lang = this._lang === 'zh' ? 'en' : 'zh';\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n },\n\n set(lang) {\n if (lang === 'zh' || lang === 'en') {\n this._lang = lang;\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n }\n },\n\n apply() {\n // text content\n document.querySelectorAll('[data-i18n]').forEach(el => {\n el.textContent = this.t(el.getAttribute('data-i18n'));\n });\n // title attributes\n document.querySelectorAll('[data-i18n-title]').forEach(el => {\n el.title = this.t(el.getAttribute('data-i18n-title'));\n });\n // placeholder attributes\n document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {\n el.placeholder = this.t(el.getAttribute('data-i18n-placeholder'));\n });\n // Update HTML lang attr\n document.documentElement.lang = this._lang;\n }\n};\n\nconst ICON_SUN = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>';\nconst ICON_MOON = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z\"/></svg>';\n\n// --- State ---\nconst state = {\n currentSessionId: null,\n ws: null,\n isStreaming: false,\n _lastUserMessage: '', // for /retry command\n};\n\n// --- DOM Elements ---\nconst $ = (sel) => document.querySelector(sel);\nconst $$ = (sel) => document.querySelectorAll(sel);\n\nconst elements = {\n messages: $('#messages'),\n userInput: $('#user-input'),\n btnNewChat: $('#btn-new-chat'),\n btnTheme: $('#btn-theme'),\n btnLang: $('#btn-lang'),\n btnSettings: $('#btn-settings'),\n btnSidebarToggle: $('#btn-sidebar-toggle'),\n btnDocs: $('#btn-docs'),\n sessionList: $('#session-list'),\n settingsModal: $('#settings-modal'),\n btnSettingsSave: $('#btn-settings-save'),\n btnSettingsClose: $('#btn-settings-close'),\n};\n\n// --- Initialization ---\ndocument.addEventListener('DOMContentLoaded', () => {\n i18n.init();\n initSidebar();\n loadTheme();\n loadSessions();\n setupEventListeners();\n showEmptyState();\n});\n\n// --- Sidebar Toggle ---\nfunction initSidebar() {\n if (localStorage.getItem('puppycli-sidebar') === 'collapsed') {\n $('#app').classList.add('sidebar-collapsed');\n }\n}\n\nfunction toggleSidebar() {\n const app = $('#app');\n app.classList.toggle('sidebar-collapsed');\n const collapsed = app.classList.contains('sidebar-collapsed');\n localStorage.setItem('puppycli-sidebar', collapsed ? 'collapsed' : 'expanded');\n}\n\n// --- Theme ---\nfunction loadTheme() {\n const theme = localStorage.getItem('puppycli-theme') || 'light';\n document.documentElement.setAttribute('data-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\nfunction toggleTheme() {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('puppycli-theme', next);\n elements.btnTheme.innerHTML = next === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\n// --- Slash Command Autocomplete ---\nconst SLASH_COMMANDS = [\n { cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },\n { cmd: '/clear', descZh: '清除对话', descEn: 'Clear conversation' },\n { cmd: '/new', descZh: '新建对话', descEn: 'New conversation' },\n { cmd: '/theme', descZh: '切换主题', descEn: 'Switch theme' },\n { cmd: '/lang', descZh: '切换语言', descEn: 'Switch language' },\n { cmd: '/settings', descZh: '打开设置', descEn: 'Open settings' },\n { cmd: '/export', descZh: '导出对话', descEn: 'Export conversation' },\n { cmd: '/retry', descZh: '重新生成', descEn: 'Regenerate response' },\n { cmd: '/model', descZh: '切换模型', descEn: 'Switch model' },\n { cmd: '/skill', descZh: '管理技能', descEn: 'Manage skills' },\n { cmd: '/search', descZh: '搜索技能', descEn: 'Search skills' },\n { cmd: '/kb', descZh: '知识库管理', descEn: 'Knowledge base' },\n { cmd: '/pdf', descZh: '处理 PDF', descEn: 'Process PDF' },\n { cmd: '/docs', descZh: '打开文档', descEn: 'Open docs' },\n];\n\nlet _suggIndex = -1;\nconst $sugg = () => $('#slash-suggestions');\n\nfunction updateSuggestions() {\n const val = elements.userInput.value;\n if (!val.startsWith('/') || val.includes(' ')) {\n hideSuggestions();\n return;\n }\n const query = val.slice(1).toLowerCase();\n const matches = SLASH_COMMANDS.filter(s => s.cmd.slice(1).startsWith(query));\n if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === val)) {\n hideSuggestions();\n return;\n }\n const isZh = i18n._lang === 'zh';\n $sugg().innerHTML = matches.map((s, i) =>\n `<div class=\"slash-item\" data-index=\"${i}\"><span class=\"slash-cmd\">${s.cmd}</span><span class=\"slash-desc\">${isZh ? s.descZh : s.descEn}</span></div>`\n ).join('');\n $sugg().classList.remove('hidden');\n _suggIndex = -1;\n $sugg().querySelectorAll('.slash-item').forEach(el => {\n el.addEventListener('mousedown', (e) => {\n e.preventDefault();\n const cmd = matches[parseInt(el.dataset.index)].cmd;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n elements.userInput.focus();\n });\n });\n}\n\nfunction hideSuggestions() {\n $sugg().classList.add('hidden');\n $sugg().innerHTML = '';\n _suggIndex = -1;\n}\n\n// --- Local Message Persistence ---\nasync function ensureSession() {\n if (state.currentSessionId) return;\n try {\n const resp = await fetch('/api/sessions', { method: 'POST' });\n const data = await resp.json();\n state.currentSessionId = data.session_id;\n loadSessions();\n } catch (e) {\n console.error('Failed to create session:', e);\n }\n}\n\nasync function saveLocalMessage(role, content) {\n if (!state.currentSessionId || !content) return;\n try {\n await fetch(`/api/sessions/${state.currentSessionId}/messages`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, content }),\n });\n } catch (e) {\n console.error('Failed to save message:', e);\n }\n}\n\nfunction navigateSuggestions(dir) {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0) return;\n items.forEach(el => el.classList.remove('active'));\n _suggIndex = Math.max(0, Math.min(items.length - 1, _suggIndex + dir));\n items[_suggIndex].classList.add('active');\n items[_suggIndex].scrollIntoView({ block: 'nearest' });\n}\n\nfunction selectSuggestion() {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0 || _suggIndex < 0) return false;\n const cmd = items[_suggIndex].querySelector('.slash-cmd').textContent;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n return true;\n}\n\n// --- Event Listeners ---\nfunction setupEventListeners() {\n elements.userInput.addEventListener('keydown', (e) => {\n // Handle suggestion navigation first\n if (!$sugg().classList.contains('hidden')) {\n if (e.key === 'ArrowDown') { e.preventDefault(); navigateSuggestions(1); return; }\n if (e.key === 'ArrowUp') { e.preventDefault(); navigateSuggestions(-1); return; }\n if (e.key === 'Enter' && !e.shiftKey) {\n if (selectSuggestion()) { e.preventDefault(); return; }\n }\n if (e.key === 'Escape') { e.preventDefault(); hideSuggestions(); return; }\n if (e.key === 'Tab') { e.preventDefault(); selectSuggestion(); return; }\n }\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n elements.userInput.addEventListener('input', () => {\n autoResizeTextarea();\n updateSuggestions();\n });\n elements.btnNewChat.addEventListener('click', newChat);\n elements.btnTheme.addEventListener('click', toggleTheme);\n elements.btnLang.addEventListener('click', () => i18n.toggle());\n elements.btnSidebarToggle.addEventListener('click', toggleSidebar);\n elements.btnSettings.addEventListener('click', openSettings);\n elements.btnSettingsClose.addEventListener('click', closeSettings);\n elements.btnSettingsSave.addEventListener('click', saveSettings);\n if (elements.btnDocs) {\n elements.btnDocs.addEventListener('click', () => window.open('/help/', '_blank'));\n }\n}\n\n// --- WebSocket ---\nfunction connectWebSocket() {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) return;\n\n const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';\n const wsUrl = `${protocol}//${location.host}/ws/chat`;\n state.ws = new WebSocket(wsUrl);\n\n state.ws.onmessage = (event) => {\n const data = JSON.parse(event.data);\n handleWSMessage(data);\n };\n\n state.ws.onclose = () => {\n state.ws = null;\n };\n\n state.ws.onerror = (err) => {\n console.error('WebSocket error:', err);\n };\n}\n\nfunction handleWSMessage(data) {\n switch (data.type) {\n case 'session_id':\n state.currentSessionId = data.session_id;\n break;\n case 'token':\n appendToken(data.content);\n break;\n case 'done':\n finishStreaming();\n loadSessions();\n break;\n case 'error':\n showError(data.message);\n break;\n case 'confirm_tool':\n showConfirmCard(data.confirm_id, data.command, data.reason);\n break;\n }\n}\n\nfunction sendMessage() {\n const content = elements.userInput.value.trim();\n hideSuggestions();\n if (!content || state.isStreaming) return;\n\n // Bang commands — execute PowerShell\n if (content.startsWith('!')) {\n execPowerShell(content.slice(1).trim());\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n // Slash commands — handle locally, don't send to AI\n if (content.startsWith('/')) {\n handleSlashCommand(content);\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n state._lastUserMessage = content;\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: content,\n session_id: state.currentSessionId,\n }));\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n\n removeEmptyState();\n appendMessage('user', content);\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\n// --- Slash Commands ---\nfunction handleSlashCommand(input) {\n const parts = input.split(/\\s+/);\n const cmd = parts[0].toLowerCase();\n const arg = parts.slice(1).join(' ');\n\n // Save command as user message (fire and forget)\n ensureSession().then(() => saveLocalMessage('user', input));\n\n switch (cmd) {\n case '/help':\n cmdHelp();\n break;\n case '/clear':\n cmdClear();\n break;\n case '/new':\n cmdNew();\n break;\n case '/theme':\n cmdTheme(arg);\n break;\n case '/lang':\n cmdLang(arg);\n break;\n case '/settings':\n cmdSettings();\n break;\n case '/export':\n cmdExport();\n break;\n case '/retry':\n cmdRetry();\n break;\n case '/model':\n cmdModel(arg);\n break;\n case '/skill':\n cmdSkill(arg);\n break;\n case '/search':\n cmdSkillSearch(arg);\n break;\n case '/kb':\n cmdKnowledge(arg);\n break;\n case '/pdf':\n cmdPdf(arg);\n break;\n case '/docs':\n window.open('/help/', '_blank');\n showSystemMessage(i18n._lang === 'zh'\n ? '文档已在新标签页中打开。'\n : 'Documentation opened in a new tab.');\n break;\n default:\n showSystemMessage(i18n._lang === 'zh'\n ? `未知命令: ${cmd}。输入 /help 查看可用命令。`\n : `Unknown command: ${cmd}. Type /help for available commands.`);\n }\n}\n\nfunction showSystemMessage(text) {\n removeEmptyState();\n saveLocalMessage('assistant', text);\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.fontStyle = 'italic';\n div.style.color = 'var(--text-secondary)';\n div.innerHTML = renderMarkdown(text);\n elements.messages.appendChild(div);\n scrollToBottom();\n}\n\nfunction cmdHelp() {\n const isZh = i18n._lang === 'zh';\n showSystemMessage(isZh\n ? `**可用命令**\n\n| 命令 | 说明 |\n|---------|-------------|\n| \\`/help\\` | 显示此帮助信息 |\n| \\`/clear\\` | 清除当前对话 |\n| \\`/new\\` | 创建新对话 |\n| \\`/theme dark\\\\|light\\` | 切换颜色主题 |\n| \\`/lang zh\\\\|en\\` | 切换界面语言 |\n| \\`/settings\\` | 打开设置面板 |\n| \\`/export\\` | 导出对话为 Markdown |\n| \\`/retry\\` | 重新生成上一次回复 |\n| \\`/model <name>\\` | 快速切换 AI 模型 |\n| \\`/skill list\\` | 列出已安装的技能 |\n| \\`/skill install <source>\\` | 安装技能(路径、GitHub、URL) |\n| \\`/skill remove <name>\\` | 移除技能 |\n| \\`/search <query>\\` | 在 agentskills.io 上搜索技能 |\n| \\`!<command>\\` | 执行 PowerShell 命令(例如 \\`!Get-Date\\`) |\n| \\`/kb list\\` | 列出知识库条目 |\n| \\`/kb add <title>\\\\|<content>\\` | 添加知识库条目 |\n| \\`/kb search <query>\\` | 搜索知识库 |\n| \\`/kb delete <id>\\` | 删除知识库条目 |\n| \\`/pdf <path>\\` | 处理 PDF 文件并加入知识库 |\n| \\`/docs\\` | 在新标签页中打开文档 |`\n : `**Available Commands**\n\n| Command | Description |\n|---------|-------------|\n| \\`/help\\` | Show this help message |\n| \\`/clear\\` | Clear current conversation |\n| \\`/new\\` | Start a new conversation |\n| \\`/theme dark\\\\|light\\` | Switch color theme |\n| \\`/lang zh\\\\|en\\` | Switch interface language |\n| \\`/settings\\` | Open settings panel |\n| \\`/export\\` | Export conversation as Markdown |\n| \\`/retry\\` | Regenerate last AI response |\n| \\`/model <name>\\` | Quick-switch AI model |\n| \\`/skill list\\` | List installed skills |\n| \\`/skill install <source>\\` | Install a skill (path, GitHub, URL) |\n| \\`/skill remove <name>\\` | Remove a skill |\n| \\`/search <query>\\` | Search skills on agentskills.io |\n| \\`!<command>\\` | Execute a PowerShell command (e.g., \\`!Get-Date\\`) |\n| \\`/kb list\\` | List knowledge base entries |\n| \\`/kb add <title>\\\\|<content>\\` | Add to knowledge base |\n| \\`/kb search <query>\\` | Search knowledge base |\n| \\`/kb delete <id>\\` | Delete a knowledge entry |\n| \\`/pdf <path>\\` | Process PDF file → knowledge base |\n| \\`/docs\\` | Open documentation in new tab |`);\n}\n\nfunction cmdClear() {\n elements.messages.innerHTML = '';\n showEmptyState();\n}\n\nfunction cmdNew() {\n newChat();\n}\n\nfunction cmdTheme(arg) {\n const theme = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-theme', theme);\n localStorage.setItem('puppycli-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n showSystemMessage(isZh\n ? `主题已切换至 **${theme === 'dark' ? '深色' : '浅色'}** 模式。`\n : `Theme switched to **${theme}** mode.`);\n } else {\n const current = document.documentElement.getAttribute('data-theme') || 'light';\n showSystemMessage(isZh\n ? `当前主题: **${current}**。用法: \\`/theme dark\\` 或 \\`/theme light\\``\n : `Current theme: **${current}**. Usage: \\`/theme dark\\` or \\`/theme light\\``);\n }\n}\n\nfunction cmdLang(arg) {\n const lang = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (lang === 'zh' || lang === 'en') {\n i18n.set(lang);\n showSystemMessage(isZh\n ? `语言已切换为 **${lang === 'zh' ? '中文' : 'English'}**。`\n : `Language switched to **${lang === 'zh' ? '中文' : 'English'}**.`);\n } else if (!lang) {\n showSystemMessage(isZh\n ? `当前语言: **${i18n._lang === 'zh' ? '中文' : 'English'}**。用法: \\`/lang zh\\` 或 \\`/lang en\\``\n : `Current language: **${i18n._lang === 'zh' ? '中文' : 'English'}**. Usage: \\`/lang zh\\` or \\`/lang en\\``);\n } else {\n showSystemMessage(isZh\n ? `不支持的语言: **${lang}**。请使用 \\`/lang zh\\` 或 \\`/lang en\\``\n : `Unsupported language: **${lang}**. Use \\`/lang zh\\` or \\`/lang en\\``);\n }\n}\n\nfunction cmdSettings() {\n openSettings();\n}\n\nfunction cmdExport() {\n const isZh = i18n._lang === 'zh';\n const messages = elements.messages.querySelectorAll('.message');\n if (messages.length === 0) {\n showSystemMessage(isZh ? '没有可导出的内容。' : 'Nothing to export.');\n return;\n }\n\n let md = `# PuppyCLI Conversation\\n\\n`;\n md += `_Exported at ${new Date().toLocaleString()}_\\n\\n---\\n\\n`;\n\n messages.forEach(msg => {\n if (msg.classList.contains('user')) {\n md += `**You:**\\n${msg.textContent}\\n\\n`;\n } else if (msg.classList.contains('assistant') && !msg.style.fontStyle) {\n const raw = msg.dataset.rawText || msg.textContent;\n md += `**PuppyCLI:**\\n${raw}\\n\\n`;\n }\n });\n\n const blob = new Blob([md], { type: 'text/markdown' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `puppycli-export-${Date.now()}.md`;\n a.click();\n URL.revokeObjectURL(url);\n showSystemMessage(isZh\n ? '对话已导出为 Markdown 文件。'\n : 'Conversation exported as Markdown file.');\n}\n\nfunction cmdRetry() {\n const isZh = i18n._lang === 'zh';\n if (state.isStreaming) return;\n if (!state._lastUserMessage) {\n showSystemMessage(isZh\n ? '没有可重试的消息。请先发送一条消息。'\n : 'Nothing to retry. Send a message first.');\n return;\n }\n\n // Remove last assistant bubble if present\n const msgs = elements.messages.querySelectorAll('.message.assistant');\n if (msgs.length > 0) {\n const lastAssistant = msgs[msgs.length - 1];\n if (!lastAssistant.style.fontStyle) {\n lastAssistant.remove();\n }\n }\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: state._lastUserMessage,\n session_id: state.currentSessionId,\n }));\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\nfunction cmdModel(arg) {\n const isZh = i18n._lang === 'zh';\n const model = arg.trim();\n if (!model) {\n showSystemMessage(isZh\n ? '用法: `/model <name>`。例如: `/model deepseek-chat`'\n : 'Usage: `/model <name>`. Example: `/model deepseek-chat`');\n return;\n }\n fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model }),\n })\n .then(r => r.json())\n .then(() => {\n showSystemMessage(isZh\n ? `模型已切换至 **${model}**(下一条消息生效)。`\n : `Model switched to **${model}** (next message will use it).`);\n })\n .catch(() => {\n showSystemMessage(isZh\n ? '模型切换失败,请检查设置。'\n : 'Failed to switch model. Check settings.');\n });\n}\n\nasync function cmdSkill(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const target = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/skills');\n const skills = await resp.json();\n if (skills.length === 0) {\n showSystemMessage(isZh\n ? '没有已安装的技能。使用 `/skill install <source>` 安装,或 `/search <query>` 搜索技能。'\n : 'No skills installed. Use `/skill install <source>` to install one, or `/search <query>` to find skills.');\n return;\n }\n let md = isZh\n ? '**已安装技能**\\n\\n| 名称 | 描述 | 位置 |\\n|------|-------------|----------|\\n'\n : '**Installed Skills**\\n\\n| Name | Description | Location |\\n|------|-------------|----------|\\n';\n skills.forEach(s => {\n md += `| \\`${s.name}\\` | ${s.description.slice(0, 80)} | ${s.location} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/skill remove <name>` 移除技能。'\n : '\\nUse `/skill remove <name>` to remove a skill.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '获取技能列表失败: ' : 'Failed to list skills: ') + e.message);\n }\n } else if (action === 'install') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill install <github repo | URL | 本地路径>`。例如: `/skill install anthropics/skills`'\n : 'Usage: `/skill install <github repo | URL | local path>`. Example: `/skill install anthropics/skills`');\n return;\n }\n showSystemMessage(isZh ? `正在从 **${target}** 安装技能...` : `Installing skill from **${target}**...`);\n try {\n const resp = await fetch('/api/skills/install', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ source: target }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `技能 **${data.skill.name}** 安装成功!输入 /skill list 查看所有技能。`\n : `Skill **${data.skill.name}** installed successfully! Type /skill list to see all skills.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '安装失败: ' : 'Install failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '安装技能失败: ' : 'Failed to install skill: ') + e.message);\n }\n } else if (action === 'remove') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill remove <name>`。使用 `/skill list` 查看已安装技能。'\n : 'Usage: `/skill remove <name>`. Use `/skill list` to see installed skills.');\n return;\n }\n try {\n const resp = await fetch(`/api/skills/${target}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `技能 **${target}** 已移除。` : `Skill **${target}** removed.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '移除失败: ' : 'Remove failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '移除技能失败: ' : 'Failed to remove skill: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的技能操作: **${action}**。请使用 \\`/skill list\\`、\\`/skill install\\` 或 \\`/skill remove\\`。`\n : `Unknown skill action: **${action}**. Use \\`/skill list\\`, \\`/skill install\\`, or \\`/skill remove\\`.`);\n }\n}\n\nasync function cmdSkillSearch(query) {\n const isZh = i18n._lang === 'zh';\n if (!query.trim()) {\n showSystemMessage(isZh\n ? '用法: `/search <query>`。例如: `/search pdf processing`'\n : 'Usage: `/search <query>`. Example: `/search pdf processing`');\n return;\n }\n showSystemMessage(isZh\n ? `正在 agentskills.io 上搜索 **${query}**...`\n : `Searching for **${query}** on agentskills.io...`);\n try {\n const resp = await fetch(`/api/skills/search?q=${encodeURIComponent(query)}&limit=10`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${query}** 相关的技能。请尝试其他关键词。`\n : `No skills found for **${query}**. Try a different keyword.`);\n return;\n }\n let md = isZh\n ? `**搜索 \"${query}\" 的结果**\\n\\n| 名称 | 描述 | 来源 |\\n|------|-------------|--------|\\n`\n : `**Search Results for \"${query}\"**\\n\\n| Name | Description | Source |\\n|------|-------------|--------|\\n`;\n results.forEach(r => {\n const desc = (r.description || '').slice(0, 60);\n const src = r.source ? `[repo](${r.source})` : '-';\n md += `| \\`${r.name}\\` | ${desc} | ${src} |\\n`;\n });\n md += isZh\n ? `\\n安装: \\`/skill install <source>\\` (例如 \\`/skill install ${results[0].source || results[0].name}\\`)`\n : `\\nTo install: \\`/skill install <source>\\` (e.g., \\`/skill install ${results[0].source || results[0].name}\\`)`;\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n}\n\nasync function execPowerShell(command) {\n const isZh = i18n._lang === 'zh';\n if (!command) return;\n\n await ensureSession();\n saveLocalMessage('user', '!' + command);\n\n removeEmptyState();\n appendMessage('user', '!' + command);\n\n const resultDiv = document.createElement('div');\n resultDiv.className = 'message assistant';\n resultDiv.innerHTML = renderMarkdown(\n (isZh ? '运行中: `' : 'Running: `') + escapeHtml(command) + '`...');\n elements.messages.appendChild(resultDiv);\n scrollToBottom();\n\n try {\n const resp = await fetch('/api/powershell', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ command }),\n });\n const data = await resp.json();\n\n let output = '';\n if (data.stdout) {\n output += '```\\n' + data.stdout + '\\n```';\n }\n if (data.stderr) {\n output += '\\n\\n**Stderr:**\\n```\\n' + data.stderr + '\\n```';\n }\n if (data.returncode !== 0 && !data.stderr && !data.stdout) {\n output += '\\n_Exit code: ' + data.returncode + '_';\n }\n if (!output.trim()) {\n const noOutput = isZh ? '(无输出)' : '(no output)';\n output = '_' + noOutput + '_';\n saveLocalMessage('assistant', noOutput);\n } else {\n saveLocalMessage('assistant', output);\n }\n\n resultDiv.innerHTML = renderMarkdown(output);\n } catch (e) {\n const errMsg = (isZh ? '错误: ' : 'Error: ') + e.message;\n saveLocalMessage('assistant', errMsg);\n resultDiv.innerHTML = renderMarkdown('**' + errMsg + '**');\n }\n scrollToBottom();\n}\n\nasync function cmdKnowledge(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const rest = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/kb');\n const entries = await resp.json();\n if (entries.length === 0) {\n showSystemMessage(isZh\n ? '知识库为空。使用 `/kb add <title>|<content>` 添加条目。'\n : 'Knowledge base is empty. Use `/kb add <title>|<content>` to add entries.');\n return;\n }\n let md = isZh\n ? `**知识库** (${entries.length} 条)\\n\\n| ID | 标题 | 标签 | 预览 |\\n|----|-------|------|---------|\\n`\n : `**Knowledge Base** (${entries.length} entries)\\n\\n| ID | Title | Tags | Preview |\\n|----|-------|------|---------|\\n`;\n entries.forEach(e => {\n md += `| \\`${e.id}\\` | ${e.title} | ${(e.tags||[]).join(', ')} | ${(e.preview||'').slice(0, 50)} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/kb search <query>` 搜索,`/kb delete <id>` 删除。'\n : '\\nUse `/kb search <query>` to search, `/kb delete <id>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'add') {\n const pipeIdx = rest.indexOf('|');\n if (pipeIdx < 0) {\n showSystemMessage(isZh\n ? '用法: `/kb add <title>|<content>`。使用竖线 `|` 分隔标题和内容。'\n : 'Usage: `/kb add <title>|<content>`. Use pipe `|` to separate title and content.');\n return;\n }\n const title = rest.slice(0, pipeIdx).trim();\n const content = rest.slice(pipeIdx + 1).trim();\n if (!title || !content) {\n showSystemMessage(isZh ? '标题和内容均为必填。' : 'Both title and content are required.');\n return;\n }\n try {\n const resp = await fetch('/api/kb', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title, content }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `知识条目 **${data.entry.id}** 已添加: _${title}_`\n : `Knowledge entry **${data.entry.id}** added: _${title}_`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'search') {\n if (!rest) {\n showSystemMessage(isZh ? '用法: `/kb search <query>`' : 'Usage: `/kb search <query>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/search?q=${encodeURIComponent(rest)}`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${rest}** 相关的内容。`\n : `Nothing found for **${rest}**.`);\n return;\n }\n let md = (isZh ? `**搜索: \"${rest}\"**\\n\\n` : `**Search: \"${rest}\"**\\n\\n`);\n results.forEach(r => {\n md += `### ${r.title}\\n\\`${r.id}\\` | tags: ${(r.tags||[]).join(', ')}\\n\\n${r.content}\\n\\n---\\n`;\n });\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n } else if (action === 'delete') {\n const entryId = rest.trim();\n if (!entryId) {\n showSystemMessage(isZh ? '用法: `/kb delete <id>`' : 'Usage: `/kb delete <id>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/${entryId}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `条目 **${entryId}** 已删除。` : `Entry **${entryId}** deleted.`);\n } else {\n showSystemMessage(isZh ? '删除失败: 条目未找到。' : 'Delete failed: entry not found.');\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的知识库操作: **${action}**。请使用 \\`/kb list\\`、\\`/kb add\\`、\\`/kb search\\` 或 \\`/kb delete\\`。`\n : `Unknown kb action: **${action}**. Use \\`/kb list\\`, \\`/kb add\\`, \\`/kb search\\`, or \\`/kb delete\\`.`);\n }\n}\n\nasync function cmdPdf(path) {\n const isZh = i18n._lang === 'zh';\n if (!path.trim()) {\n showSystemMessage(isZh\n ? '用法: `/pdf <file_path>`。例如: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`'\n : 'Usage: `/pdf <file_path>`. Example: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`');\n return;\n }\n showSystemMessage(isZh ? `正在处理 PDF: **${path}**...` : `Processing PDF: **${path}**...`);\n try {\n const resp = await fetch('/api/pdf/process', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ file_path: path }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(\n (isZh\n ? `PDF 处理成功 (${data.mode} 模式)。\\n\\n**知识条目:** \\`${data.kb_entry_id}\\`\\n**文件:** ${data.filename}\\n\\n**预览:**\\n${data.preview}...\\n\\n使用 \\`/kb search\\` 在知识库中查找。`\n : `PDF processed successfully (${data.mode} mode).\\n\\n**KB Entry:** \\`${data.kb_entry_id}\\`\\n**File:** ${data.filename}\\n\\n**Preview:**\\n${data.preview}...\\n\\nUse \\`/kb search\\` to find it in your knowledge base.`)\n );\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + e.message);\n }\n}\n\n// --- Messages ---\nfunction appendMessage(role, content) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n if (role === 'assistant') {\n div.innerHTML = renderMarkdown(content);\n } else {\n div.textContent = content;\n }\n elements.messages.appendChild(div);\n scrollToBottom();\n return div;\n}\n\nlet _streamingBubble = null;\n\nfunction createStreamingBubble() {\n _streamingBubble = document.createElement('div');\n _streamingBubble.className = 'message assistant streaming';\n elements.messages.appendChild(_streamingBubble);\n scrollToBottom();\n}\n\nfunction appendToken(token) {\n if (!_streamingBubble) {\n createStreamingBubble();\n }\n const currentText = _streamingBubble.dataset.rawText || '';\n const newText = currentText + token;\n _streamingBubble.dataset.rawText = newText;\n _streamingBubble.innerHTML = renderMarkdown(newText);\n scrollToBottom();\n}\n\nfunction finishStreaming() {\n if (_streamingBubble) {\n _streamingBubble.classList.remove('streaming');\n _streamingBubble = null;\n }\n state.isStreaming = false;\n elements.userInput.focus();\n}\n\nfunction showError(message) {\n const isZh = i18n._lang === 'zh';\n finishStreaming();\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.color = '#e03131';\n div.textContent = (isZh ? '错误: ' : 'Error: ') + message;\n elements.messages.appendChild(div);\n scrollToBottom();\n state.isStreaming = false;\n}\n\n// --- Confirm Card ---\nfunction showConfirmCard(confirmId, command, reason) {\n finishStreaming();\n state.isStreaming = true; // keep input disabled while waiting\n\n const isZh = i18n._lang === 'zh';\n const card = document.createElement('div');\n card.className = 'confirm-card';\n card.id = 'confirm-' + confirmId;\n card.innerHTML =\n `<div class=\"confirm-card-header\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z\"/>\n <line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/>\n <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>\n </svg>\n <span>${isZh ? 'Agent 想要修改本地文件' : 'Agent wants to modify local files'}</span>\n </div>\n <div class=\"confirm-card-body\">\n <div class=\"confirm-card-cmd\"><code>${escapeHtml(command)}</code></div>\n <div class=\"confirm-card-reason\">${escapeHtml(reason)}</div>\n </div>\n <div class=\"confirm-card-actions\">\n <button class=\"confirm-card-allow\" id=\"confirm-allow-${confirmId}\">${isZh ? '✓ 允许执行' : '✓ Allow'}</button>\n <button class=\"confirm-card-deny\" id=\"confirm-deny-${confirmId}\">${isZh ? '✗ 拒绝' : '✗ Deny'}</button>\n </div>`;\n\n removeEmptyState();\n elements.messages.appendChild(card);\n scrollToBottom();\n\n function respond(allowed) {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'confirm_response',\n confirm_id: confirmId,\n allowed: allowed,\n }));\n }\n const resultText = allowed\n ? (isZh ? '✓ 已允许执行' : '✓ Execution allowed')\n : (isZh ? '✗ 已拒绝' : '✗ Execution denied');\n card.innerHTML = `<div class=\"confirm-card-result\">${resultText}</div>`;\n card.classList.add('confirm-card-resolved');\n state.isStreaming = false;\n elements.userInput.focus();\n }\n\n document.getElementById('confirm-allow-' + confirmId).addEventListener('click', () => respond(true));\n document.getElementById('confirm-deny-' + confirmId).addEventListener('click', () => respond(false));\n}\n\n// --- Markdown Rendering ---\nfunction renderMarkdown(text) {\n if (typeof marked.setOptions === 'function') {\n marked.setOptions({\n breaks: true,\n gfm: true,\n highlight: function (code, lang) {\n if (typeof hljs !== 'undefined') {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n }\n return code;\n },\n });\n }\n\n let html = text;\n\n // Preprocess LaTeX blocks $$...$$ before markdown\n const latexBlocks = [];\n html = html.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'block', formula });\n return `%%LATEX_BLOCK_${latexBlocks.length - 1}%%`;\n });\n // Preprocess inline LaTeX $...$\n html = html.replace(/\\$(.*?)\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'inline', formula });\n return `%%LATEX_INLINE_${latexBlocks.length - 1}%%`;\n });\n\n // Render markdown\n html = marked.parse(html);\n\n // Restore LaTeX\n html = html.replace(/<p>%%LATEX_BLOCK_(\\d+)%%<\\/p>/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_BLOCK_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_INLINE_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: false });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n\n return html;\n}\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n// --- Sessions ---\nasync function loadSessions() {\n try {\n const resp = await fetch('/api/sessions');\n const sessions = await resp.json();\n renderSessionList(sessions);\n } catch (e) {\n console.error('Failed to load sessions:', e);\n }\n}\n\nfunction renderSessionList(sessions) {\n elements.sessionList.innerHTML = '';\n sessions.forEach(s => {\n const div = document.createElement('div');\n div.className = 'session-item';\n if (s.id === state.currentSessionId) {\n div.classList.add('active');\n }\n\n const title = document.createElement('span');\n title.className = 'item-title';\n title.textContent = s.title || (i18n._lang === 'zh' ? '未命名' : 'Untitled');\n\n const actions = document.createElement('span');\n actions.className = 'item-actions';\n\n const renameBtn = document.createElement('button');\n renameBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7\"/><path d=\"M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z\"/></svg>';\n renameBtn.title = i18n.t('rename');\n renameBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n startRename(s.id, title, div);\n });\n\n const deleteBtn = document.createElement('button');\n deleteBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>';\n deleteBtn.title = i18n.t('delete');\n deleteBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n deleteSession(s.id);\n });\n\n actions.appendChild(renameBtn);\n actions.appendChild(deleteBtn);\n div.appendChild(title);\n div.appendChild(actions);\n\n div.addEventListener('click', () => loadSession(s.id));\n elements.sessionList.appendChild(div);\n });\n}\n\nasync function loadSession(sessionId) {\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`);\n const data = await resp.json();\n state.currentSessionId = sessionId;\n elements.messages.innerHTML = '';\n removeEmptyState();\n if (!data.messages || data.messages.length === 0) {\n // Show empty state if no messages\n showEmptyState();\n } else {\n data.messages.forEach(msg => {\n appendMessage(msg.role, msg.content);\n });\n }\n loadSessions();\n } catch (e) {\n console.error('Failed to load session:', e);\n }\n}\n\nfunction newChat() {\n state.currentSessionId = null;\n state.isStreaming = false;\n elements.messages.innerHTML = '';\n showEmptyState();\n loadSessions();\n}\n\nasync function startRename(sessionId, titleEl, itemEl) {\n const oldTitle = titleEl.textContent;\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'rename-input';\n input.value = oldTitle;\n itemEl.replaceChild(input, titleEl);\n // Hide actions during rename\n const actions = itemEl.querySelector('.item-actions');\n if (actions) actions.style.display = 'none';\n input.focus();\n input.select();\n\n const finish = async () => {\n const newTitle = input.value.trim() || oldTitle;\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: newTitle }),\n });\n if (!resp.ok) {\n console.error('Rename failed:', resp.status);\n }\n } catch (e) {\n console.error('Rename failed:', e);\n }\n titleEl.textContent = newTitle;\n itemEl.replaceChild(titleEl, input);\n if (actions) actions.style.display = '';\n // Refresh list to reflect server state\n loadSessions();\n };\n\n input.addEventListener('blur', finish);\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n if (e.key === 'Escape') { input.value = oldTitle; input.blur(); }\n });\n}\n\nasync function deleteSession(sessionId) {\n if (!confirm(i18n.t('delete-confirm'))) return;\n try {\n await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });\n if (state.currentSessionId === sessionId) {\n newChat();\n } else {\n loadSessions();\n }\n } catch (e) {\n console.error('Delete failed:', e);\n }\n}\n\nfunction showEmptyState() {\n if (elements.messages.children.length === 0) {\n elements.messages.innerHTML = `\n <div class=\"empty-state\">\n <img src=\"/logo.png\" class=\"logo-icon\" alt=\"PuppyCLI logo\" onerror=\"this.style.display='none';this.nextElementSibling.style.display='block';\"><span class=\"logo-icon-fallback\" style=\"display:none;font-size:64px;\">🐶</span>\n <p>PuppyCLI</p>\n </div>`;\n }\n}\n\nfunction removeEmptyState() {\n const empty = elements.messages.querySelector('.empty-state');\n if (empty) empty.remove();\n}\n\n// --- Settings ---\nfunction openSettings() {\n fetch('/api/config')\n .then(r => r.json())\n .then(config => {\n const keyVal = config.api_key || '';\n $('#cfg-api-key').value = keyVal;\n $('#cfg-api-key').placeholder = keyVal ? '(already set — leave blank to keep)' : 'sk-...';\n $('#cfg-model').value = config.model || 'deepseek-chat';\n $('#cfg-base-url').value = config.base_url || 'https://api.deepseek.com';\n $('#cfg-python-env').value = config.python_env || '';\n $('#cfg-data-dir').value = config.data_dir || '';\n })\n .catch(() => {});\n elements.settingsModal.classList.remove('hidden');\n // Re-apply i18n in case modal was rendered before\n i18n.apply();\n}\n\nfunction closeSettings() {\n elements.settingsModal.classList.add('hidden');\n}\n\nasync function saveSettings() {\n const isZh = i18n._lang === 'zh';\n const updates = {\n api_key: $('#cfg-api-key').value.trim(),\n model: $('#cfg-model').value.trim(),\n base_url: $('#cfg-base-url').value.trim(),\n python_env: $('#cfg-python-env').value.trim(),\n data_dir: $('#cfg-data-dir').value.trim(),\n };\n try {\n await fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(updates),\n });\n closeSettings();\n } catch (e) {\n alert((isZh ? '保存设置失败: ' : 'Failed to save settings: ') + e.message);\n }\n}\n\n// --- Helpers ---\nfunction autoResizeTextarea() {\n const el = elements.userInput;\n el.style.height = 'auto';\n el.style.height = Math.min(el.scrollHeight, 240) + 'px';\n}\n\nfunction scrollToBottom() {\n elements.messages.scrollTop = elements.messages.scrollHeight;\n}\n"}, "0f08d5d556dc198b0c8912d40b433e2f597cbd63": {"t": 1785766917.9726148, "n": 46335, "h": "d7c334a12de6523a", "c": "/* ========================================\n PuppyCLI — Frontend Application Logic\n ======================================== */\n\n// --- i18n ---\nconst i18n = {\n _lang: 'en',\n _data: {\n 'new-chat': { zh: '新建对话', en: 'New Chat' },\n 'settings': { zh: '设置', en: 'Settings' },\n 'theme': { zh: '切换主题', en: 'Toggle Theme' },\n 'docs': { zh: '文档', en: 'Docs' },\n 'lang': { zh: '切换语言', en: 'Language' },\n 'send': { zh: '发送', en: 'Send' },\n 'conversations': { zh: '对话列表', en: 'Conversations' },\n 'placeholder': { zh: '输入消息... (Enter 发送, Shift+Enter 换行)', en: 'Type a message... (Enter to send, Shift+Enter for new line)' },\n 'settings-title': { zh: '设置', en: 'Settings' },\n 'api-key': { zh: 'API 密钥', en: 'API Key' },\n 'model': { zh: '模型', en: 'Model' },\n 'base-url': { zh: '接口地址', en: 'Base URL' },\n 'python-env': { zh: 'Python 环境路径', en: 'Python Env Path' },\n 'data-dir': { zh: '数据目录', en: 'Data Directory' },\n 'save': { zh: '保存', en: 'Save' },\n 'cancel': { zh: '取消', en: 'Cancel' },\n 'rename': { zh: '重命名', en: 'Rename' },\n 'delete': { zh: '删除', en: 'Delete' },\n 'delete-confirm': { zh: '确定要删除这个对话吗?此操作不可撤销。', en: 'Delete this conversation? This cannot be undone.' },\n 'toggle-sidebar': { zh: '折叠/展开侧栏', en: 'Toggle Sidebar' },\n },\n\n t(key) {\n const entry = this._data[key];\n return entry ? (entry[this._lang] || entry.en) : key;\n },\n\n init() {\n const saved = localStorage.getItem('puppycli-lang');\n if (saved === 'zh' || saved === 'en') {\n this._lang = saved;\n } else {\n this._lang = navigator.language.startsWith('zh') ? 'zh' : 'en';\n }\n this.apply();\n },\n\n toggle() {\n this._lang = this._lang === 'zh' ? 'en' : 'zh';\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n },\n\n set(lang) {\n if (lang === 'zh' || lang === 'en') {\n this._lang = lang;\n localStorage.setItem('puppycli-lang', this._lang);\n this.apply();\n }\n },\n\n apply() {\n // text content\n document.querySelectorAll('[data-i18n]').forEach(el => {\n el.textContent = this.t(el.getAttribute('data-i18n'));\n });\n // title attributes\n document.querySelectorAll('[data-i18n-title]').forEach(el => {\n el.title = this.t(el.getAttribute('data-i18n-title'));\n });\n // placeholder attributes\n document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {\n el.placeholder = this.t(el.getAttribute('data-i18n-placeholder'));\n });\n // Update HTML lang attr\n document.documentElement.lang = this._lang;\n }\n};\n\nconst ICON_SUN = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>';\nconst ICON_MOON = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z\"/></svg>';\n\n// --- State ---\nconst state = {\n currentSessionId: null,\n ws: null,\n isStreaming: false,\n _lastUserMessage: '', // for /retry command\n};\n\n// --- DOM Elements ---\nconst $ = (sel) => document.querySelector(sel);\nconst $$ = (sel) => document.querySelectorAll(sel);\n\nconst elements = {\n messages: $('#messages'),\n userInput: $('#user-input'),\n btnNewChat: $('#btn-new-chat'),\n btnTheme: $('#btn-theme'),\n btnLang: $('#btn-lang'),\n btnSettings: $('#btn-settings'),\n btnSidebarToggle: $('#btn-sidebar-toggle'),\n btnDocs: $('#btn-docs'),\n sessionList: $('#session-list'),\n settingsModal: $('#settings-modal'),\n btnSettingsSave: $('#btn-settings-save'),\n btnSettingsClose: $('#btn-settings-close'),\n};\n\n// --- Initialization ---\ndocument.addEventListener('DOMContentLoaded', () => {\n i18n.init();\n initSidebar();\n loadTheme();\n loadSessions();\n setupEventListeners();\n showEmptyState();\n});\n\n// --- Sidebar Toggle ---\nfunction initSidebar() {\n if (localStorage.getItem('puppycli-sidebar') === 'collapsed') {\n $('#app').classList.add('sidebar-collapsed');\n }\n}\n\nfunction toggleSidebar() {\n const app = $('#app');\n app.classList.toggle('sidebar-collapsed');\n const collapsed = app.classList.contains('sidebar-collapsed');\n localStorage.setItem('puppycli-sidebar', collapsed ? 'collapsed' : 'expanded');\n}\n\n// --- Theme ---\nfunction loadTheme() {\n const theme = localStorage.getItem('puppycli-theme') || 'light';\n document.documentElement.setAttribute('data-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\nfunction toggleTheme() {\n const current = document.documentElement.getAttribute('data-theme');\n const next = current === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n localStorage.setItem('puppycli-theme', next);\n elements.btnTheme.innerHTML = next === 'dark' ? ICON_SUN : ICON_MOON;\n}\n\n// --- Slash Command Autocomplete ---\nconst SLASH_COMMANDS = [\n { cmd: '/help', descZh: '显示帮助', descEn: 'Show help' },\n { cmd: '/clear', descZh: '清除对话', descEn: 'Clear conversation' },\n { cmd: '/new', descZh: '新建对话', descEn: 'New conversation' },\n { cmd: '/theme', descZh: '切换主题', descEn: 'Switch theme' },\n { cmd: '/lang', descZh: '切换语言', descEn: 'Switch language' },\n { cmd: '/settings', descZh: '打开设置', descEn: 'Open settings' },\n { cmd: '/export', descZh: '导出对话', descEn: 'Export conversation' },\n { cmd: '/retry', descZh: '重新生成', descEn: 'Regenerate response' },\n { cmd: '/model', descZh: '切换模型', descEn: 'Switch model' },\n { cmd: '/skill', descZh: '管理技能', descEn: 'Manage skills' },\n { cmd: '/search', descZh: '搜索技能', descEn: 'Search skills' },\n { cmd: '/kb', descZh: '知识库管理', descEn: 'Knowledge base' },\n { cmd: '/pdf', descZh: '处理 PDF', descEn: 'Process PDF' },\n { cmd: '/docs', descZh: '打开文档', descEn: 'Open docs' },\n];\n\nlet _suggIndex = -1;\nconst $sugg = () => $('#slash-suggestions');\n\nfunction updateSuggestions() {\n const val = elements.userInput.value;\n if (!val.startsWith('/') || val.includes(' ')) {\n hideSuggestions();\n return;\n }\n const query = val.slice(1).toLowerCase();\n const matches = SLASH_COMMANDS.filter(s => s.cmd.slice(1).startsWith(query));\n if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === val)) {\n hideSuggestions();\n return;\n }\n const isZh = i18n._lang === 'zh';\n $sugg().innerHTML = matches.map((s, i) =>\n `<div class=\"slash-item\" data-index=\"${i}\"><span class=\"slash-cmd\">${s.cmd}</span><span class=\"slash-desc\">${isZh ? s.descZh : s.descEn}</span></div>`\n ).join('');\n $sugg().classList.remove('hidden');\n _suggIndex = -1;\n $sugg().querySelectorAll('.slash-item').forEach(el => {\n el.addEventListener('mousedown', (e) => {\n e.preventDefault();\n const cmd = matches[parseInt(el.dataset.index)].cmd;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n elements.userInput.focus();\n });\n });\n}\n\nfunction hideSuggestions() {\n $sugg().classList.add('hidden');\n $sugg().innerHTML = '';\n _suggIndex = -1;\n}\n\n// --- Local Message Persistence ---\nasync function ensureSession() {\n if (state.currentSessionId) return;\n try {\n const resp = await fetch('/api/sessions', { method: 'POST' });\n const data = await resp.json();\n state.currentSessionId = data.session_id;\n loadSessions();\n } catch (e) {\n console.error('Failed to create session:', e);\n }\n}\n\nasync function saveLocalMessage(role, content) {\n if (!state.currentSessionId || !content) return;\n try {\n await fetch(`/api/sessions/${state.currentSessionId}/messages`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ role, content }),\n });\n } catch (e) {\n console.error('Failed to save message:', e);\n }\n}\n\nfunction navigateSuggestions(dir) {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0) return;\n items.forEach(el => el.classList.remove('active'));\n _suggIndex = Math.max(0, Math.min(items.length - 1, _suggIndex + dir));\n items[_suggIndex].classList.add('active');\n items[_suggIndex].scrollIntoView({ block: 'nearest' });\n}\n\nfunction selectSuggestion() {\n const items = $sugg().querySelectorAll('.slash-item');\n if (items.length === 0 || _suggIndex < 0) return false;\n const cmd = items[_suggIndex].querySelector('.slash-cmd').textContent;\n elements.userInput.value = cmd + ' ';\n hideSuggestions();\n return true;\n}\n\n// --- Event Listeners ---\nfunction setupEventListeners() {\n elements.userInput.addEventListener('keydown', (e) => {\n // Handle suggestion navigation first\n if (!$sugg().classList.contains('hidden')) {\n if (e.key === 'ArrowDown') { e.preventDefault(); navigateSuggestions(1); return; }\n if (e.key === 'ArrowUp') { e.preventDefault(); navigateSuggestions(-1); return; }\n if (e.key === 'Enter' && !e.shiftKey) {\n if (selectSuggestion()) { e.preventDefault(); return; }\n }\n if (e.key === 'Escape') { e.preventDefault(); hideSuggestions(); return; }\n if (e.key === 'Tab') { e.preventDefault(); selectSuggestion(); return; }\n }\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n });\n elements.userInput.addEventListener('input', () => {\n autoResizeTextarea();\n updateSuggestions();\n });\n elements.btnNewChat.addEventListener('click', newChat);\n elements.btnTheme.addEventListener('click', toggleTheme);\n elements.btnLang.addEventListener('click', () => i18n.toggle());\n elements.btnSidebarToggle.addEventListener('click', toggleSidebar);\n elements.btnSettings.addEventListener('click', openSettings);\n elements.btnSettingsClose.addEventListener('click', closeSettings);\n elements.btnSettingsSave.addEventListener('click', saveSettings);\n if (elements.btnDocs) {\n elements.btnDocs.addEventListener('click', () => window.open('/help/', '_blank'));\n }\n}\n\n// --- WebSocket ---\nfunction connectWebSocket() {\n if (state.ws && state.ws.readyState === WebSocket.OPEN) return;\n\n const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';\n const wsUrl = `${protocol}//${location.host}/ws/chat`;\n state.ws = new WebSocket(wsUrl);\n\n state.ws.onmessage = (event) => {\n const data = JSON.parse(event.data);\n handleWSMessage(data);\n };\n\n state.ws.onclose = () => {\n state.ws = null;\n };\n\n state.ws.onerror = (err) => {\n console.error('WebSocket error:', err);\n };\n}\n\nfunction handleWSMessage(data) {\n switch (data.type) {\n case 'session_id':\n state.currentSessionId = data.session_id;\n break;\n case 'token':\n appendToken(data.content);\n break;\n case 'done':\n finishStreaming();\n loadSessions();\n break;\n case 'error':\n showError(data.message);\n break;\n }\n}\n\nfunction sendMessage() {\n const content = elements.userInput.value.trim();\n hideSuggestions();\n if (!content || state.isStreaming) return;\n\n // Bang commands — execute PowerShell\n if (content.startsWith('!')) {\n execPowerShell(content.slice(1).trim());\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n // Slash commands — handle locally, don't send to AI\n if (content.startsWith('/')) {\n handleSlashCommand(content);\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n return;\n }\n\n state._lastUserMessage = content;\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: content,\n session_id: state.currentSessionId,\n }));\n elements.userInput.value = '';\n elements.userInput.style.height = 'auto';\n\n removeEmptyState();\n appendMessage('user', content);\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\n// --- Slash Commands ---\nfunction handleSlashCommand(input) {\n const parts = input.split(/\\s+/);\n const cmd = parts[0].toLowerCase();\n const arg = parts.slice(1).join(' ');\n\n // Save command as user message (fire and forget)\n ensureSession().then(() => saveLocalMessage('user', input));\n\n switch (cmd) {\n case '/help':\n cmdHelp();\n break;\n case '/clear':\n cmdClear();\n break;\n case '/new':\n cmdNew();\n break;\n case '/theme':\n cmdTheme(arg);\n break;\n case '/lang':\n cmdLang(arg);\n break;\n case '/settings':\n cmdSettings();\n break;\n case '/export':\n cmdExport();\n break;\n case '/retry':\n cmdRetry();\n break;\n case '/model':\n cmdModel(arg);\n break;\n case '/skill':\n cmdSkill(arg);\n break;\n case '/search':\n cmdSkillSearch(arg);\n break;\n case '/kb':\n cmdKnowledge(arg);\n break;\n case '/pdf':\n cmdPdf(arg);\n break;\n case '/docs':\n window.open('/help/', '_blank');\n showSystemMessage(i18n._lang === 'zh'\n ? '文档已在新标签页中打开。'\n : 'Documentation opened in a new tab.');\n break;\n default:\n showSystemMessage(i18n._lang === 'zh'\n ? `未知命令: ${cmd}。输入 /help 查看可用命令。`\n : `Unknown command: ${cmd}. Type /help for available commands.`);\n }\n}\n\nfunction showSystemMessage(text) {\n removeEmptyState();\n saveLocalMessage('assistant', text);\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.fontStyle = 'italic';\n div.style.color = 'var(--text-secondary)';\n div.innerHTML = renderMarkdown(text);\n elements.messages.appendChild(div);\n scrollToBottom();\n}\n\nfunction cmdHelp() {\n const isZh = i18n._lang === 'zh';\n showSystemMessage(isZh\n ? `**可用命令**\n\n| 命令 | 说明 |\n|---------|-------------|\n| \\`/help\\` | 显示此帮助信息 |\n| \\`/clear\\` | 清除当前对话 |\n| \\`/new\\` | 创建新对话 |\n| \\`/theme dark\\\\|light\\` | 切换颜色主题 |\n| \\`/lang zh\\\\|en\\` | 切换界面语言 |\n| \\`/settings\\` | 打开设置面板 |\n| \\`/export\\` | 导出对话为 Markdown |\n| \\`/retry\\` | 重新生成上一次回复 |\n| \\`/model <name>\\` | 快速切换 AI 模型 |\n| \\`/skill list\\` | 列出已安装的技能 |\n| \\`/skill install <source>\\` | 安装技能(路径、GitHub、URL) |\n| \\`/skill remove <name>\\` | 移除技能 |\n| \\`/search <query>\\` | 在 agentskills.io 上搜索技能 |\n| \\`!<command>\\` | 执行 PowerShell 命令(例如 \\`!Get-Date\\`) |\n| \\`/kb list\\` | 列出知识库条目 |\n| \\`/kb add <title>\\\\|<content>\\` | 添加知识库条目 |\n| \\`/kb search <query>\\` | 搜索知识库 |\n| \\`/kb delete <id>\\` | 删除知识库条目 |\n| \\`/pdf <path>\\` | 处理 PDF 文件并加入知识库 |\n| \\`/docs\\` | 在新标签页中打开文档 |`\n : `**Available Commands**\n\n| Command | Description |\n|---------|-------------|\n| \\`/help\\` | Show this help message |\n| \\`/clear\\` | Clear current conversation |\n| \\`/new\\` | Start a new conversation |\n| \\`/theme dark\\\\|light\\` | Switch color theme |\n| \\`/lang zh\\\\|en\\` | Switch interface language |\n| \\`/settings\\` | Open settings panel |\n| \\`/export\\` | Export conversation as Markdown |\n| \\`/retry\\` | Regenerate last AI response |\n| \\`/model <name>\\` | Quick-switch AI model |\n| \\`/skill list\\` | List installed skills |\n| \\`/skill install <source>\\` | Install a skill (path, GitHub, URL) |\n| \\`/skill remove <name>\\` | Remove a skill |\n| \\`/search <query>\\` | Search skills on agentskills.io |\n| \\`!<command>\\` | Execute a PowerShell command (e.g., \\`!Get-Date\\`) |\n| \\`/kb list\\` | List knowledge base entries |\n| \\`/kb add <title>\\\\|<content>\\` | Add to knowledge base |\n| \\`/kb search <query>\\` | Search knowledge base |\n| \\`/kb delete <id>\\` | Delete a knowledge entry |\n| \\`/pdf <path>\\` | Process PDF file → knowledge base |\n| \\`/docs\\` | Open documentation in new tab |`);\n}\n\nfunction cmdClear() {\n elements.messages.innerHTML = '';\n showEmptyState();\n}\n\nfunction cmdNew() {\n newChat();\n}\n\nfunction cmdTheme(arg) {\n const theme = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-theme', theme);\n localStorage.setItem('puppycli-theme', theme);\n elements.btnTheme.innerHTML = theme === 'dark' ? ICON_SUN : ICON_MOON;\n showSystemMessage(isZh\n ? `主题已切换至 **${theme === 'dark' ? '深色' : '浅色'}** 模式。`\n : `Theme switched to **${theme}** mode.`);\n } else {\n const current = document.documentElement.getAttribute('data-theme') || 'light';\n showSystemMessage(isZh\n ? `当前主题: **${current}**。用法: \\`/theme dark\\` 或 \\`/theme light\\``\n : `Current theme: **${current}**. Usage: \\`/theme dark\\` or \\`/theme light\\``);\n }\n}\n\nfunction cmdLang(arg) {\n const lang = arg.toLowerCase();\n const isZh = i18n._lang === 'zh';\n if (lang === 'zh' || lang === 'en') {\n i18n.set(lang);\n showSystemMessage(isZh\n ? `语言已切换为 **${lang === 'zh' ? '中文' : 'English'}**。`\n : `Language switched to **${lang === 'zh' ? '中文' : 'English'}**.`);\n } else if (!lang) {\n showSystemMessage(isZh\n ? `当前语言: **${i18n._lang === 'zh' ? '中文' : 'English'}**。用法: \\`/lang zh\\` 或 \\`/lang en\\``\n : `Current language: **${i18n._lang === 'zh' ? '中文' : 'English'}**. Usage: \\`/lang zh\\` or \\`/lang en\\``);\n } else {\n showSystemMessage(isZh\n ? `不支持的语言: **${lang}**。请使用 \\`/lang zh\\` 或 \\`/lang en\\``\n : `Unsupported language: **${lang}**. Use \\`/lang zh\\` or \\`/lang en\\``);\n }\n}\n\nfunction cmdSettings() {\n openSettings();\n}\n\nfunction cmdExport() {\n const isZh = i18n._lang === 'zh';\n const messages = elements.messages.querySelectorAll('.message');\n if (messages.length === 0) {\n showSystemMessage(isZh ? '没有可导出的内容。' : 'Nothing to export.');\n return;\n }\n\n let md = `# PuppyCLI Conversation\\n\\n`;\n md += `_Exported at ${new Date().toLocaleString()}_\\n\\n---\\n\\n`;\n\n messages.forEach(msg => {\n if (msg.classList.contains('user')) {\n md += `**You:**\\n${msg.textContent}\\n\\n`;\n } else if (msg.classList.contains('assistant') && !msg.style.fontStyle) {\n const raw = msg.dataset.rawText || msg.textContent;\n md += `**PuppyCLI:**\\n${raw}\\n\\n`;\n }\n });\n\n const blob = new Blob([md], { type: 'text/markdown' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `puppycli-export-${Date.now()}.md`;\n a.click();\n URL.revokeObjectURL(url);\n showSystemMessage(isZh\n ? '对话已导出为 Markdown 文件。'\n : 'Conversation exported as Markdown file.');\n}\n\nfunction cmdRetry() {\n const isZh = i18n._lang === 'zh';\n if (state.isStreaming) return;\n if (!state._lastUserMessage) {\n showSystemMessage(isZh\n ? '没有可重试的消息。请先发送一条消息。'\n : 'Nothing to retry. Send a message first.');\n return;\n }\n\n // Remove last assistant bubble if present\n const msgs = elements.messages.querySelectorAll('.message.assistant');\n if (msgs.length > 0) {\n const lastAssistant = msgs[msgs.length - 1];\n if (!lastAssistant.style.fontStyle) {\n lastAssistant.remove();\n }\n }\n\n connectWebSocket();\n\n const send = () => {\n if (state.ws.readyState === WebSocket.OPEN) {\n state.ws.send(JSON.stringify({\n type: 'message',\n content: state._lastUserMessage,\n session_id: state.currentSessionId,\n }));\n createStreamingBubble();\n state.isStreaming = true;\n } else if (state.ws.readyState === WebSocket.CONNECTING) {\n setTimeout(send, 50);\n }\n };\n send();\n}\n\nfunction cmdModel(arg) {\n const isZh = i18n._lang === 'zh';\n const model = arg.trim();\n if (!model) {\n showSystemMessage(isZh\n ? '用法: `/model <name>`。例如: `/model deepseek-chat`'\n : 'Usage: `/model <name>`. Example: `/model deepseek-chat`');\n return;\n }\n fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model }),\n })\n .then(r => r.json())\n .then(() => {\n showSystemMessage(isZh\n ? `模型已切换至 **${model}**(下一条消息生效)。`\n : `Model switched to **${model}** (next message will use it).`);\n })\n .catch(() => {\n showSystemMessage(isZh\n ? '模型切换失败,请检查设置。'\n : 'Failed to switch model. Check settings.');\n });\n}\n\nasync function cmdSkill(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const target = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/skills');\n const skills = await resp.json();\n if (skills.length === 0) {\n showSystemMessage(isZh\n ? '没有已安装的技能。使用 `/skill install <source>` 安装,或 `/search <query>` 搜索技能。'\n : 'No skills installed. Use `/skill install <source>` to install one, or `/search <query>` to find skills.');\n return;\n }\n let md = isZh\n ? '**已安装技能**\\n\\n| 名称 | 描述 | 位置 |\\n|------|-------------|----------|\\n'\n : '**Installed Skills**\\n\\n| Name | Description | Location |\\n|------|-------------|----------|\\n';\n skills.forEach(s => {\n md += `| \\`${s.name}\\` | ${s.description.slice(0, 80)} | ${s.location} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/skill remove <name>` 移除技能。'\n : '\\nUse `/skill remove <name>` to remove a skill.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '获取技能列表失败: ' : 'Failed to list skills: ') + e.message);\n }\n } else if (action === 'install') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill install <github repo | URL | 本地路径>`。例如: `/skill install anthropics/skills`'\n : 'Usage: `/skill install <github repo | URL | local path>`. Example: `/skill install anthropics/skills`');\n return;\n }\n showSystemMessage(isZh ? `正在从 **${target}** 安装技能...` : `Installing skill from **${target}**...`);\n try {\n const resp = await fetch('/api/skills/install', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ source: target }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `技能 **${data.skill.name}** 安装成功!输入 /skill list 查看所有技能。`\n : `Skill **${data.skill.name}** installed successfully! Type /skill list to see all skills.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '安装失败: ' : 'Install failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '安装技能失败: ' : 'Failed to install skill: ') + e.message);\n }\n } else if (action === 'remove') {\n if (!target) {\n showSystemMessage(isZh\n ? '用法: `/skill remove <name>`。使用 `/skill list` 查看已安装技能。'\n : 'Usage: `/skill remove <name>`. Use `/skill list` to see installed skills.');\n return;\n }\n try {\n const resp = await fetch(`/api/skills/${target}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `技能 **${target}** 已移除。` : `Skill **${target}** removed.`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '移除失败: ' : 'Remove failed: ') + (err.detail || resp.statusText));\n }\n } catch (e) {\n showSystemMessage((isZh ? '移除技能失败: ' : 'Failed to remove skill: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的技能操作: **${action}**。请使用 \\`/skill list\\`、\\`/skill install\\` 或 \\`/skill remove\\`。`\n : `Unknown skill action: **${action}**. Use \\`/skill list\\`, \\`/skill install\\`, or \\`/skill remove\\`.`);\n }\n}\n\nasync function cmdSkillSearch(query) {\n const isZh = i18n._lang === 'zh';\n if (!query.trim()) {\n showSystemMessage(isZh\n ? '用法: `/search <query>`。例如: `/search pdf processing`'\n : 'Usage: `/search <query>`. Example: `/search pdf processing`');\n return;\n }\n showSystemMessage(isZh\n ? `正在 agentskills.io 上搜索 **${query}**...`\n : `Searching for **${query}** on agentskills.io...`);\n try {\n const resp = await fetch(`/api/skills/search?q=${encodeURIComponent(query)}&limit=10`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${query}** 相关的技能。请尝试其他关键词。`\n : `No skills found for **${query}**. Try a different keyword.`);\n return;\n }\n let md = isZh\n ? `**搜索 \"${query}\" 的结果**\\n\\n| 名称 | 描述 | 来源 |\\n|------|-------------|--------|\\n`\n : `**Search Results for \"${query}\"**\\n\\n| Name | Description | Source |\\n|------|-------------|--------|\\n`;\n results.forEach(r => {\n const desc = (r.description || '').slice(0, 60);\n const src = r.source ? `[repo](${r.source})` : '-';\n md += `| \\`${r.name}\\` | ${desc} | ${src} |\\n`;\n });\n md += isZh\n ? `\\n安装: \\`/skill install <source>\\` (例如 \\`/skill install ${results[0].source || results[0].name}\\`)`\n : `\\nTo install: \\`/skill install <source>\\` (e.g., \\`/skill install ${results[0].source || results[0].name}\\`)`;\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n}\n\nasync function execPowerShell(command) {\n const isZh = i18n._lang === 'zh';\n if (!command) return;\n\n await ensureSession();\n saveLocalMessage('user', '!' + command);\n\n removeEmptyState();\n appendMessage('user', '!' + command);\n\n const resultDiv = document.createElement('div');\n resultDiv.className = 'message assistant';\n resultDiv.innerHTML = renderMarkdown(\n (isZh ? '运行中: `' : 'Running: `') + escapeHtml(command) + '`...');\n elements.messages.appendChild(resultDiv);\n scrollToBottom();\n\n try {\n const resp = await fetch('/api/powershell', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ command }),\n });\n const data = await resp.json();\n\n let output = '';\n if (data.stdout) {\n output += '```\\n' + data.stdout + '\\n```';\n }\n if (data.stderr) {\n output += '\\n\\n**Stderr:**\\n```\\n' + data.stderr + '\\n```';\n }\n if (data.returncode !== 0 && !data.stderr && !data.stdout) {\n output += '\\n_Exit code: ' + data.returncode + '_';\n }\n if (!output.trim()) {\n const noOutput = isZh ? '(无输出)' : '(no output)';\n output = '_' + noOutput + '_';\n saveLocalMessage('assistant', noOutput);\n } else {\n saveLocalMessage('assistant', output);\n }\n\n resultDiv.innerHTML = renderMarkdown(output);\n } catch (e) {\n const errMsg = (isZh ? '错误: ' : 'Error: ') + e.message;\n saveLocalMessage('assistant', errMsg);\n resultDiv.innerHTML = renderMarkdown('**' + errMsg + '**');\n }\n scrollToBottom();\n}\n\nasync function cmdKnowledge(arg) {\n const isZh = i18n._lang === 'zh';\n const parts = arg.split(/\\s+/);\n const action = parts[0]?.toLowerCase();\n const rest = parts.slice(1).join(' ');\n\n if (!action || action === 'list') {\n try {\n const resp = await fetch('/api/kb');\n const entries = await resp.json();\n if (entries.length === 0) {\n showSystemMessage(isZh\n ? '知识库为空。使用 `/kb add <title>|<content>` 添加条目。'\n : 'Knowledge base is empty. Use `/kb add <title>|<content>` to add entries.');\n return;\n }\n let md = isZh\n ? `**知识库** (${entries.length} 条)\\n\\n| ID | 标题 | 标签 | 预览 |\\n|----|-------|------|---------|\\n`\n : `**Knowledge Base** (${entries.length} entries)\\n\\n| ID | Title | Tags | Preview |\\n|----|-------|------|---------|\\n`;\n entries.forEach(e => {\n md += `| \\`${e.id}\\` | ${e.title} | ${(e.tags||[]).join(', ')} | ${(e.preview||'').slice(0, 50)} |\\n`;\n });\n md += isZh\n ? '\\n使用 `/kb search <query>` 搜索,`/kb delete <id>` 删除。'\n : '\\nUse `/kb search <query>` to search, `/kb delete <id>` to remove.';\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'add') {\n const pipeIdx = rest.indexOf('|');\n if (pipeIdx < 0) {\n showSystemMessage(isZh\n ? '用法: `/kb add <title>|<content>`。使用竖线 `|` 分隔标题和内容。'\n : 'Usage: `/kb add <title>|<content>`. Use pipe `|` to separate title and content.');\n return;\n }\n const title = rest.slice(0, pipeIdx).trim();\n const content = rest.slice(pipeIdx + 1).trim();\n if (!title || !content) {\n showSystemMessage(isZh ? '标题和内容均为必填。' : 'Both title and content are required.');\n return;\n }\n try {\n const resp = await fetch('/api/kb', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title, content }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(isZh\n ? `知识条目 **${data.entry.id}** 已添加: _${title}_`\n : `Knowledge entry **${data.entry.id}** added: _${title}_`);\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else if (action === 'search') {\n if (!rest) {\n showSystemMessage(isZh ? '用法: `/kb search <query>`' : 'Usage: `/kb search <query>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/search?q=${encodeURIComponent(rest)}`);\n const results = await resp.json();\n if (results.length === 0) {\n showSystemMessage(isZh\n ? `未找到与 **${rest}** 相关的内容。`\n : `Nothing found for **${rest}**.`);\n return;\n }\n let md = (isZh ? `**搜索: \"${rest}\"**\\n\\n` : `**Search: \"${rest}\"**\\n\\n`);\n results.forEach(r => {\n md += `### ${r.title}\\n\\`${r.id}\\` | tags: ${(r.tags||[]).join(', ')}\\n\\n${r.content}\\n\\n---\\n`;\n });\n showSystemMessage(md);\n } catch (e) {\n showSystemMessage((isZh ? '搜索失败: ' : 'Search failed: ') + e.message);\n }\n } else if (action === 'delete') {\n const entryId = rest.trim();\n if (!entryId) {\n showSystemMessage(isZh ? '用法: `/kb delete <id>`' : 'Usage: `/kb delete <id>`');\n return;\n }\n try {\n const resp = await fetch(`/api/kb/${entryId}`, { method: 'DELETE' });\n if (resp.ok) {\n showSystemMessage(isZh ? `条目 **${entryId}** 已删除。` : `Entry **${entryId}** deleted.`);\n } else {\n showSystemMessage(isZh ? '删除失败: 条目未找到。' : 'Delete failed: entry not found.');\n }\n } catch (e) {\n showSystemMessage((isZh ? '失败: ' : 'Failed: ') + e.message);\n }\n } else {\n showSystemMessage(isZh\n ? `未知的知识库操作: **${action}**。请使用 \\`/kb list\\`、\\`/kb add\\`、\\`/kb search\\` 或 \\`/kb delete\\`。`\n : `Unknown kb action: **${action}**. Use \\`/kb list\\`, \\`/kb add\\`, \\`/kb search\\`, or \\`/kb delete\\`.`);\n }\n}\n\nasync function cmdPdf(path) {\n const isZh = i18n._lang === 'zh';\n if (!path.trim()) {\n showSystemMessage(isZh\n ? '用法: `/pdf <file_path>`。例如: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`'\n : 'Usage: `/pdf <file_path>`. Example: `/pdf C:\\\\Users\\\\me\\\\doc.pdf`');\n return;\n }\n showSystemMessage(isZh ? `正在处理 PDF: **${path}**...` : `Processing PDF: **${path}**...`);\n try {\n const resp = await fetch('/api/pdf/process', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ file_path: path }),\n });\n if (resp.ok) {\n const data = await resp.json();\n showSystemMessage(\n (isZh\n ? `PDF 处理成功 (${data.mode} 模式)。\\n\\n**知识条目:** \\`${data.kb_entry_id}\\`\\n**文件:** ${data.filename}\\n\\n**预览:**\\n${data.preview}...\\n\\n使用 \\`/kb search\\` 在知识库中查找。`\n : `PDF processed successfully (${data.mode} mode).\\n\\n**KB Entry:** \\`${data.kb_entry_id}\\`\\n**File:** ${data.filename}\\n\\n**Preview:**\\n${data.preview}...\\n\\nUse \\`/kb search\\` to find it in your knowledge base.`)\n );\n } else {\n const err = await resp.json();\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + err.detail);\n }\n } catch (e) {\n showSystemMessage((isZh ? 'PDF 处理失败: ' : 'PDF processing failed: ') + e.message);\n }\n}\n\n// --- Messages ---\nfunction appendMessage(role, content) {\n const div = document.createElement('div');\n div.className = `message ${role}`;\n if (role === 'assistant') {\n div.innerHTML = renderMarkdown(content);\n } else {\n div.textContent = content;\n }\n elements.messages.appendChild(div);\n scrollToBottom();\n return div;\n}\n\nlet _streamingBubble = null;\n\nfunction createStreamingBubble() {\n _streamingBubble = document.createElement('div');\n _streamingBubble.className = 'message assistant streaming';\n elements.messages.appendChild(_streamingBubble);\n scrollToBottom();\n}\n\nfunction appendToken(token) {\n if (!_streamingBubble) {\n createStreamingBubble();\n }\n const currentText = _streamingBubble.dataset.rawText || '';\n const newText = currentText + token;\n _streamingBubble.dataset.rawText = newText;\n _streamingBubble.innerHTML = renderMarkdown(newText);\n scrollToBottom();\n}\n\nfunction finishStreaming() {\n if (_streamingBubble) {\n _streamingBubble.classList.remove('streaming');\n _streamingBubble = null;\n }\n state.isStreaming = false;\n elements.userInput.focus();\n}\n\nfunction showError(message) {\n const isZh = i18n._lang === 'zh';\n finishStreaming();\n const div = document.createElement('div');\n div.className = 'message assistant';\n div.style.color = '#e03131';\n div.textContent = (isZh ? '错误: ' : 'Error: ') + message;\n elements.messages.appendChild(div);\n scrollToBottom();\n state.isStreaming = false;\n}\n\n// --- Markdown Rendering ---\nfunction renderMarkdown(text) {\n if (typeof marked.setOptions === 'function') {\n marked.setOptions({\n breaks: true,\n gfm: true,\n highlight: function (code, lang) {\n if (typeof hljs !== 'undefined') {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n }\n return code;\n },\n });\n }\n\n let html = text;\n\n // Preprocess LaTeX blocks $$...$$ before markdown\n const latexBlocks = [];\n html = html.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'block', formula });\n return `%%LATEX_BLOCK_${latexBlocks.length - 1}%%`;\n });\n // Preprocess inline LaTeX $...$\n html = html.replace(/\\$(.*?)\\$/g, (_, formula) => {\n latexBlocks.push({ type: 'inline', formula });\n return `%%LATEX_INLINE_${latexBlocks.length - 1}%%`;\n });\n\n // Render markdown\n html = marked.parse(html);\n\n // Restore LaTeX\n html = html.replace(/<p>%%LATEX_BLOCK_(\\d+)%%<\\/p>/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_BLOCK_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: true });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n html = html.replace(/%%LATEX_INLINE_(\\d+)%%/g, (_, idx) => {\n const item = latexBlocks[parseInt(idx)];\n try {\n return katex.renderToString(item.formula, { displayMode: false });\n } catch (e) {\n return `<code>${escapeHtml(item.formula)}</code>`;\n }\n });\n\n return html;\n}\n\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n// --- Sessions ---\nasync function loadSessions() {\n try {\n const resp = await fetch('/api/sessions');\n const sessions = await resp.json();\n renderSessionList(sessions);\n } catch (e) {\n console.error('Failed to load sessions:', e);\n }\n}\n\nfunction renderSessionList(sessions) {\n elements.sessionList.innerHTML = '';\n sessions.forEach(s => {\n const div = document.createElement('div');\n div.className = 'session-item';\n if (s.id === state.currentSessionId) {\n div.classList.add('active');\n }\n\n const title = document.createElement('span');\n title.className = 'item-title';\n title.textContent = s.title || (i18n._lang === 'zh' ? '未命名' : 'Untitled');\n\n const actions = document.createElement('span');\n actions.className = 'item-actions';\n\n const renameBtn = document.createElement('button');\n renameBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7\"/><path d=\"M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z\"/></svg>';\n renameBtn.title = i18n.t('rename');\n renameBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n startRename(s.id, title, div);\n });\n\n const deleteBtn = document.createElement('button');\n deleteBtn.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>';\n deleteBtn.title = i18n.t('delete');\n deleteBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n deleteSession(s.id);\n });\n\n actions.appendChild(renameBtn);\n actions.appendChild(deleteBtn);\n div.appendChild(title);\n div.appendChild(actions);\n\n div.addEventListener('click', () => loadSession(s.id));\n elements.sessionList.appendChild(div);\n });\n}\n\nasync function loadSession(sessionId) {\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`);\n const data = await resp.json();\n state.currentSessionId = sessionId;\n elements.messages.innerHTML = '';\n removeEmptyState();\n if (!data.messages || data.messages.length === 0) {\n // Show empty state if no messages\n showEmptyState();\n } else {\n data.messages.forEach(msg => {\n appendMessage(msg.role, msg.content);\n });\n }\n loadSessions();\n } catch (e) {\n console.error('Failed to load session:', e);\n }\n}\n\nfunction newChat() {\n state.currentSessionId = null;\n state.isStreaming = false;\n elements.messages.innerHTML = '';\n showEmptyState();\n loadSessions();\n}\n\nasync function startRename(sessionId, titleEl, itemEl) {\n const oldTitle = titleEl.textContent;\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'rename-input';\n input.value = oldTitle;\n itemEl.replaceChild(input, titleEl);\n // Hide actions during rename\n const actions = itemEl.querySelector('.item-actions');\n if (actions) actions.style.display = 'none';\n input.focus();\n input.select();\n\n const finish = async () => {\n const newTitle = input.value.trim() || oldTitle;\n try {\n const resp = await fetch(`/api/sessions/${sessionId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: newTitle }),\n });\n if (!resp.ok) {\n console.error('Rename failed:', resp.status);\n }\n } catch (e) {\n console.error('Rename failed:', e);\n }\n titleEl.textContent = newTitle;\n itemEl.replaceChild(titleEl, input);\n if (actions) actions.style.display = '';\n // Refresh list to reflect server state\n loadSessions();\n };\n\n input.addEventListener('blur', finish);\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n if (e.key === 'Escape') { input.value = oldTitle; input.blur(); }\n });\n}\n\nasync function deleteSession(sessionId) {\n if (!confirm(i18n.t('delete-confirm'))) return;\n try {\n await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });\n if (state.currentSessionId === sessionId) {\n newChat();\n } else {\n loadSessions();\n }\n } catch (e) {\n console.error('Delete failed:', e);\n }\n}\n\nfunction showEmptyState() {\n if (elements.messages.children.length === 0) {\n elements.messages.innerHTML = `\n <div class=\"empty-state\">\n <img src=\"/logo.png\" class=\"logo-icon\" alt=\"PuppyCLI logo\" onerror=\"this.style.display='none';this.nextElementSibling.style.display='block';\"><span class=\"logo-icon-fallback\" style=\"display:none;font-size:64px;\">🐶</span>\n <p>PuppyCLI</p>\n </div>`;\n }\n}\n\nfunction removeEmptyState() {\n const empty = elements.messages.querySelector('.empty-state');\n if (empty) empty.remove();\n}\n\n// --- Settings ---\nfunction openSettings() {\n fetch('/api/config')\n .then(r => r.json())\n .then(config => {\n const keyVal = config.api_key || '';\n $('#cfg-api-key').value = keyVal;\n $('#cfg-api-key').placeholder = keyVal ? '(already set — leave blank to keep)' : 'sk-...';\n $('#cfg-model').value = config.model || 'deepseek-chat';\n $('#cfg-base-url').value = config.base_url || 'https://api.deepseek.com';\n $('#cfg-python-env').value = config.python_env || '';\n $('#cfg-data-dir').value = config.data_dir || '';\n })\n .catch(() => {});\n elements.settingsModal.classList.remove('hidden');\n // Re-apply i18n in case modal was rendered before\n i18n.apply();\n}\n\nfunction closeSettings() {\n elements.settingsModal.classList.add('hidden');\n}\n\nasync function saveSettings() {\n const isZh = i18n._lang === 'zh';\n const updates = {\n api_key: $('#cfg-api-key').value.trim(),\n model: $('#cfg-model').value.trim(),\n base_url: $('#cfg-base-url').value.trim(),\n python_env: $('#cfg-python-env').value.trim(),\n data_dir: $('#cfg-data-dir').value.trim(),\n };\n try {\n await fetch('/api/config', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(updates),\n });\n closeSettings();\n } catch (e) {\n alert((isZh ? '保存设置失败: ' : 'Failed to save settings: ') + e.message);\n }\n}\n\n// --- Helpers ---\nfunction autoResizeTextarea() {\n const el = elements.userInput;\n el.style.height = 'auto';\n el.style.height = Math.min(el.scrollHeight, 240) + 'px';\n}\n\nfunction scrollToBottom() {\n elements.messages.scrollTop = elements.messages.scrollHeight;\n}\n"}}}}