mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-04 07:09:43 +08:00
feat: IME-aware chat input, message copy button, Git path scanning
- Fix IME composition issue: Enter during Chinese/Japanese/Korean input method composition no longer prematurely sends messages (assistant.js) Uses e.isComposing + keyCode 229 guard on keydown handler - Add one-click copy button to chat message bubbles (both chat.js and assistant.js), with hover-reveal animation and checkmark feedback - Add 'copy' icon to SVG icon library (Lucide style) - Add CSS for msg-copy-btn in chat.css and assistant.css - Implement scan_git_paths Rust command: scans common Git installation locations on Windows/macOS/Linux (Program Files, Scoop, Chocolatey, GitHub Desktop, VS Code, MSYS2, Homebrew, Xcode CLT, etc.) - Register scan_git_paths in lib.rs, tauri-api.js, dev-api.js - Add scan button + results UI in settings.js Git path section - Add i18n keys: gitScan, gitScanning, gitScanEmpty, gitScanUse
This commit is contained in:
@@ -22,6 +22,7 @@ const PATHS = {
|
||||
'bar-chart': '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>',
|
||||
'clipboard': '<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>',
|
||||
'file': '<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
|
||||
@@ -287,6 +287,7 @@ export const api = {
|
||||
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node', 'get_services_status'); invoke('invalidate_path_cache').catch(() => {}); return r }),
|
||||
invalidatePathCache: () => invoke('invalidate_path_cache'),
|
||||
checkGit: () => cachedInvoke('check_git', {}, 60000),
|
||||
scanGitPaths: () => invoke('scan_git_paths'),
|
||||
autoInstallGit: () => invoke('auto_install_git'),
|
||||
configureGitHttps: () => invoke('configure_git_https'),
|
||||
getDeployConfig: () => cachedInvoke('get_deploy_config'),
|
||||
|
||||
@@ -82,4 +82,8 @@ export default {
|
||||
gitPathSaved: _('Git 路径已保存', 'Git path saved', 'Git 路徑已儲存', 'Git パス保存済み', 'Git 경로 저장됨'),
|
||||
gitPathCleared: _('已恢复 Git 自动检测', 'Git auto-detect restored', '已恢復 Git 自動檢測', 'Git 自動検出に戻しました', 'Git 자동 감지로 복원됨'),
|
||||
gitPathInvalid: _('指定的 Git 路径不存在', 'The specified Git path does not exist', '指定的 Git 路徑不存在', '指定された Git パスが存在しません'),
|
||||
gitScan: _('扫描', 'Scan', '掃描', 'スキャン', '스캔'),
|
||||
gitScanning: _('正在扫描…', 'Scanning…', '正在掃描…', 'スキャン中…', '스캔 중…'),
|
||||
gitScanEmpty: _('未找到 Git 安装', 'No Git installations found', '未找到 Git 安裝', 'Git インストールが見つかりません', 'Git 설치를 찾을 수 없습니다'),
|
||||
gitScanUse: _('使用', 'Use', '使用', '使用', '사용'),
|
||||
}
|
||||
|
||||
@@ -2431,10 +2431,10 @@ function renderMessages() {
|
||||
? `<img class="ast-msg-img" src="${img.dataUrl}" alt="${escHtml(img.name)}" style="max-width:${Math.min(img.width || 300, 300)}px" loading="lazy"/>`
|
||||
: `<div class="ast-msg-img-loading" data-db-id="${img.dbId || ''}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>${escHtml(img.name || t('assistant.image'))}</span></div>`
|
||||
).join('')}</div>` : ''
|
||||
return `<div class="ast-msg ast-msg-user" data-msg-idx="${idx}"><div class="ast-msg-bubble ast-msg-bubble-user">${imagesHtml}${textPart ? escHtml(textPart) : ''}</div></div>`
|
||||
return `<div class="ast-msg ast-msg-user" data-msg-idx="${idx}"><div class="ast-msg-bubble ast-msg-bubble-user">${imagesHtml}${textPart ? escHtml(textPart) : ''}</div><div class="ast-msg-meta"><button class="msg-copy-btn" title="${t('common.copy')}">${icon('copy', 12)}</button></div></div>`
|
||||
} else if (m.role === 'assistant') {
|
||||
const toolHtml = renderToolBlocks(m.toolHistory)
|
||||
return `<div class="ast-msg ast-msg-ai" data-msg-idx="${idx}">${toolHtml}<div class="ast-msg-bubble ast-msg-bubble-ai">${renderMarkdown(m.content)}</div></div>`
|
||||
return `<div class="ast-msg ast-msg-ai" data-msg-idx="${idx}">${toolHtml}<div class="ast-msg-bubble ast-msg-bubble-ai">${renderMarkdown(m.content)}</div><div class="ast-msg-meta"><button class="msg-copy-btn" title="${t('common.copy')}">${icon('copy', 12)}</button></div></div>`
|
||||
}
|
||||
return ''
|
||||
}).join('')
|
||||
@@ -4067,6 +4067,23 @@ export async function render() {
|
||||
|
||||
// ── 事件绑定 ──
|
||||
|
||||
// 复制按钮(事件委托)
|
||||
_messagesEl.addEventListener('click', (e) => {
|
||||
const copyBtn = e.target.closest('.msg-copy-btn')
|
||||
if (!copyBtn) return
|
||||
e.stopPropagation()
|
||||
const msgWrap = copyBtn.closest('.ast-msg')
|
||||
const bubble = msgWrap?.querySelector('.ast-msg-bubble')
|
||||
if (bubble) {
|
||||
const text = bubble.innerText || bubble.textContent || ''
|
||||
navigator.clipboard.writeText(text.trim()).then(() => {
|
||||
copyBtn.classList.add('copied')
|
||||
copyBtn.innerHTML = icon('check', 12)
|
||||
setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = icon('copy', 12) }, 1500)
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// 右键调试菜单(事件委托)
|
||||
_messagesEl.addEventListener('contextmenu', (e) => {
|
||||
const msgEl = e.target.closest('[data-msg-idx]')
|
||||
@@ -4084,9 +4101,9 @@ export async function render() {
|
||||
}
|
||||
})
|
||||
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
// Enter 发送,Shift+Enter 换行;IME 选词中不发送
|
||||
_textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
||||
e.preventDefault()
|
||||
if (!_textarea.value.trim() && _pendingImages.length === 0) return
|
||||
sendMessage(_textarea.value)
|
||||
|
||||
@@ -394,7 +394,7 @@ function bindEvents(page) {
|
||||
})
|
||||
|
||||
_textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Escape') hideCmdPanel()
|
||||
})
|
||||
|
||||
@@ -536,7 +536,24 @@ function bindEvents(page) {
|
||||
_autoScrollEnabled = true
|
||||
scrollToBottom(true)
|
||||
})
|
||||
_messagesEl.addEventListener('click', () => hideCmdPanel())
|
||||
_messagesEl.addEventListener('click', (e) => {
|
||||
const copyBtn = e.target.closest('.msg-copy-btn')
|
||||
if (copyBtn) {
|
||||
e.stopPropagation()
|
||||
const msgWrap = copyBtn.closest('.msg')
|
||||
const bubble = msgWrap?.querySelector('.msg-bubble')
|
||||
if (bubble) {
|
||||
const text = bubble.innerText || bubble.textContent || ''
|
||||
navigator.clipboard.writeText(text.trim()).then(() => {
|
||||
copyBtn.classList.add('copied')
|
||||
copyBtn.innerHTML = svgIcon('check', 12)
|
||||
setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = svgIcon('copy', 12) }, 1500)
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
hideCmdPanel()
|
||||
})
|
||||
}
|
||||
|
||||
async function loadModelOptions(showToast = false) {
|
||||
@@ -1733,6 +1750,7 @@ function handleChatEvent(payload) {
|
||||
parts.push(`<span class="meta-sep">·</span><span class="msg-tokens">${tokenStr}</span>`)
|
||||
}
|
||||
}
|
||||
parts.push(`<button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`)
|
||||
meta.innerHTML = parts.join('')
|
||||
wrapper.appendChild(meta)
|
||||
}
|
||||
@@ -2335,7 +2353,7 @@ function appendUserMessage(text, attachments = [], msgTime) {
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'msg-meta'
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
|
||||
|
||||
wrap.appendChild(bubble)
|
||||
wrap.appendChild(meta)
|
||||
@@ -2362,7 +2380,7 @@ function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'msg-meta'
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
|
||||
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
|
||||
|
||||
wrap.appendChild(bubble)
|
||||
wrap.appendChild(meta)
|
||||
|
||||
@@ -471,6 +471,13 @@ function bindEvents(page) {
|
||||
case 'reset-git-path':
|
||||
await handleResetGitPath(page)
|
||||
break
|
||||
case 'scan-git-paths':
|
||||
await handleScanGitPaths(page)
|
||||
break
|
||||
case 'use-scanned-git':
|
||||
page.querySelector('[data-name="git-path"]').value = btn.dataset.gitPath || ''
|
||||
await handleSaveGitPath(page)
|
||||
break
|
||||
case 'bind-cli':
|
||||
await handleBindCli(page, btn.dataset.path)
|
||||
break
|
||||
@@ -587,7 +594,9 @@ async function loadGitPath(page) {
|
||||
<input class="input" data-name="git-path" value="${escapeHtml(customValue)}" placeholder="${t('settings.gitPathPlaceholder')}" style="flex:1;min-width:200px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-git-path">${t('common.save')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="reset-git-path">${t('settings.resetDefault')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="scan-git-paths">${t('settings.gitScan')}</button>
|
||||
</div>
|
||||
<div id="git-scan-results"></div>
|
||||
</div>`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div class="stat-card" style="padding:16px;color:var(--error)">${e}</div>`
|
||||
@@ -613,6 +622,29 @@ async function handleSaveGitPath(page) {
|
||||
await loadGitPath(page)
|
||||
}
|
||||
|
||||
async function handleScanGitPaths(page) {
|
||||
const container = page.querySelector('#git-scan-results')
|
||||
if (!container) return
|
||||
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--text-secondary)">${t('settings.gitScanning')}</div>`
|
||||
try {
|
||||
const results = await api.scanGitPaths()
|
||||
if (!results || results.length === 0) {
|
||||
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--text-tertiary)">${t('settings.gitScanEmpty')}</div>`
|
||||
return
|
||||
}
|
||||
container.innerHTML = `<div style="margin-top:10px;display:flex;flex-direction:column;gap:6px">${results.map(r =>
|
||||
`<div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:6px 8px;background:var(--bg-tertiary);border-radius:var(--radius-sm)">
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(r.path)}">${escapeHtml(r.path)}</span>
|
||||
<span style="color:var(--text-tertiary);flex-shrink:0">${escapeHtml(r.version || '')}</span>
|
||||
<span class="badge" style="font-size:10px;flex-shrink:0">${escapeHtml(r.source)}</span>
|
||||
<button class="btn btn-primary btn-sm" style="padding:2px 8px;font-size:11px" data-action="use-scanned-git" data-git-path="${escapeHtml(r.path)}">${t('settings.gitScanUse')}</button>
|
||||
</div>`
|
||||
).join('')}</div>`
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="margin-top:10px;font-size:12px;color:var(--error)">${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetGitPath(page) {
|
||||
const cfg = await api.readPanelConfig()
|
||||
delete cfg.gitPath
|
||||
|
||||
@@ -283,6 +283,37 @@
|
||||
.ast-msg-bubble-ai h2 { font-size: 16px; }
|
||||
.ast-msg-bubble-ai h3 { font-size: 14px; }
|
||||
|
||||
/* 消息元信息 + 复制按钮 */
|
||||
.ast-msg-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ast-msg-user .ast-msg-meta { justify-content: flex-end; }
|
||||
.ast-msg-ai .ast-msg-meta { justify-content: flex-start; }
|
||||
|
||||
.ast-msg .msg-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.ast-msg:hover .msg-copy-btn,
|
||||
.ast-msg .msg-copy-btn:focus { opacity: 1; }
|
||||
.ast-msg .msg-copy-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.ast-msg .msg-copy-btn.copied { opacity: 1; color: var(--success); }
|
||||
|
||||
/* 流式光标 */
|
||||
.ast-cursor {
|
||||
animation: ast-blink 1s infinite;
|
||||
|
||||
@@ -1229,6 +1229,25 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.msg-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.msg:hover .msg-copy-btn,
|
||||
.msg-copy-btn:focus { opacity: 1; }
|
||||
.msg-copy-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.msg-copy-btn.copied { opacity: 1; color: var(--success); }
|
||||
|
||||
/* 消息内图片 */
|
||||
.msg-img {
|
||||
max-width: 200px;
|
||||
|
||||
Reference in New Issue
Block a user