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:
晴天
2026-04-06 00:14:18 +08:00
parent 42aeb8b077
commit 9ff91f74d8
11 changed files with 273 additions and 8 deletions

View File

@@ -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"/>',

View File

@@ -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'),

View File

@@ -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', '使用', '使用', '사용'),
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -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;