Files

1 line
208 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "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 = '<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"}, "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"}}}}