From 9ff91f74d8b1c1da5cb5706a389cbac69d077fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 6 Apr 2026 00:14:18 +0800 Subject: [PATCH] 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 --- scripts/dev-api.js | 21 ++++++ src-tauri/src/commands/config.rs | 120 +++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/icons.js | 1 + src/lib/tauri-api.js | 1 + src/locales/modules/settings.js | 4 ++ src/pages/assistant.js | 25 +++++-- src/pages/chat.js | 26 +++++-- src/pages/settings.js | 32 +++++++++ src/style/assistant.css | 31 ++++++++ src/style/chat.css | 19 +++++ 11 files changed, 273 insertions(+), 8 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b4693ce..bfd9816 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4409,6 +4409,27 @@ const handlers = { } }, + scan_git_paths() { + const candidates = [ + ['/usr/bin/git', 'SYSTEM'], + ['/usr/local/bin/git', 'SYSTEM'], + ['/opt/homebrew/bin/git', 'BREW'], + ['/Library/Developer/CommandLineTools/usr/bin/git', 'XCODE_CLT'], + ['/snap/bin/git', 'SNAP'], + ] + const found = [] + const seen = new Set() + for (const [p, source] of candidates) { + if (!fs.existsSync(p) || seen.has(p)) continue + seen.add(p) + try { + const ver = cp.execSync(`"${p}" --version`, { timeout: 5000 }).toString().trim() + found.push({ path: p, version: ver, source }) + } catch {} + } + return found + }, + auto_install_git() { // Web 模式下不自动安装系统软件,返回指引 throw new Error('Web 部署模式下请手动安装 Git:\n- Ubuntu/Debian: sudo apt install git\n- CentOS/RHEL: sudo yum install git\n- macOS: xcode-select --install') diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 08164aa..8ff096e 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -5359,6 +5359,126 @@ pub fn check_git() -> Result { Ok(Value::Object(result)) } +/// 扫描常见路径,返回所有找到的 Git 安装 +#[tauri::command] +pub fn scan_git_paths() -> Result { + let mut found: Vec = vec![]; + let mut candidates: Vec<(String, String)> = vec![]; // (path, source) + + #[cfg(target_os = "windows")] + { + let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into()); + let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into()); + let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + // 标准安装路径 + candidates.push((format!(r"{}\Git\cmd\git.exe", pf), "SYSTEM".into())); + candidates.push((format!(r"{}\Git\cmd\git.exe", pf86), "SYSTEM".into())); + + // 常见盘符 + for drive in &["C", "D", "E", "F", "G"] { + candidates.push((format!(r"{}:\Git\cmd\git.exe", drive), "MANUAL".into())); + candidates.push((format!(r"{}:\Program Files\Git\cmd\git.exe", drive), "SYSTEM".into())); + // 工具目录 + for sub in &["Tools", "Dev", "AI", "Apps", "Software"] { + candidates.push((format!(r"{}:\{}\Git\cmd\git.exe", drive, sub), "MANUAL".into())); + } + } + + // 自定义应用目录(如 D:\Data\exeApp\Git) + for drive in &["C", "D", "E", "F"] { + candidates.push((format!(r"{}:\Data\exeApp\Git\cmd\git.exe", drive), "MANUAL".into())); + } + + // GitHub Desktop 内置 Git + if !localappdata.is_empty() { + let gh_dir = std::path::Path::new(&localappdata).join("GitHubDesktop"); + if gh_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&gh_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + let git_exe = p.join("resources").join("app").join("git").join("cmd").join("git.exe"); + if git_exe.exists() { + candidates.push((git_exe.to_string_lossy().to_string(), "GITHUB_DESKTOP".into())); + } + } + } + } + } + } + + // VS Code 内置 Git + if !localappdata.is_empty() { + let vscode_git = std::path::Path::new(&localappdata).join(r"Programs\Microsoft VS Code\resources\app\node_modules.asar.unpacked\vscode-git\git\cmd\git.exe"); + if vscode_git.exists() { + candidates.push((vscode_git.to_string_lossy().to_string(), "VSCODE".into())); + } + } + + // MinGW / MSYS2 / Git Bash + candidates.push((format!(r"{}\Git\mingw64\bin\git.exe", pf), "MINGW".into())); + for drive in &["C", "D"] { + candidates.push((format!(r"{}:\msys64\usr\bin\git.exe", drive), "MSYS2".into())); + candidates.push((format!(r"{}:\msys2\usr\bin\git.exe", drive), "MSYS2".into())); + } + + // Scoop + let home = dirs::home_dir().unwrap_or_default(); + candidates.push((format!(r"{}\scoop\apps\git\current\cmd\git.exe", home.display()), "SCOOP".into())); + candidates.push((format!(r"{}\scoop\shims\git.exe", home.display()), "SCOOP".into())); + + // Chocolatey + let choco_dir = std::env::var("ChocolateyInstall").unwrap_or_else(|_| r"C:\ProgramData\chocolatey".into()); + candidates.push((format!(r"{}\bin\git.exe", choco_dir), "CHOCOLATEY".into())); + } + + #[cfg(not(target_os = "windows"))] + { + candidates.push(("/usr/bin/git".into(), "SYSTEM".into())); + candidates.push(("/usr/local/bin/git".into(), "SYSTEM".into())); + candidates.push(("/opt/homebrew/bin/git".into(), "BREW".into())); + // Xcode + candidates.push(("/Library/Developer/CommandLineTools/usr/bin/git".into(), "XCODE_CLT".into())); + candidates.push(("/Applications/Xcode.app/Contents/Developer/usr/bin/git".into(), "XCODE".into())); + // Snap / Flatpak + candidates.push(("/snap/bin/git".into(), "SNAP".into())); + // Nix + let home = dirs::home_dir().unwrap_or_default(); + candidates.push((format!("{}/.nix-profile/bin/git", home.display()), "NIX".into())); + // Linuxbrew + candidates.push((format!("{}/.linuxbrew/bin/git", home.display()), "BREW".into())); + candidates.push(("/home/linuxbrew/.linuxbrew/bin/git".into(), "BREW".into())); + } + + // 去重并检测 + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for (path, source) in &candidates { + let p = std::path::Path::new(path); + if !p.exists() { continue; } + let canonical = p.to_string_lossy().to_string(); + if seen.contains(&canonical) { continue; } + seen.insert(canonical.clone()); + + let mut cmd = Command::new(path); + cmd.arg("--version"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + if let Ok(o) = cmd.output() { + if o.status.success() { + let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); + let mut entry = serde_json::Map::new(); + entry.insert("path".into(), Value::String(canonical)); + entry.insert("version".into(), Value::String(ver)); + entry.insert("source".into(), Value::String(source.clone())); + found.push(Value::Object(entry)); + } + } + } + + Ok(Value::Array(found)) +} + /// 尝试自动安装 Git(Windows: winget; macOS: xcode-select; Linux: apt/yum) #[tauri::command] pub async fn auto_install_git(app: tauri::AppHandle) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index afdef55..0a19668 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,6 +104,7 @@ pub fn run() { config::get_npm_registry, config::set_npm_registry, config::check_git, + config::scan_git_paths, config::auto_install_git, config::configure_git_https, config::invalidate_path_cache, diff --git a/src/lib/icons.js b/src/lib/icons.js index 03cbdd5..89d054c 100644 --- a/src/lib/icons.js +++ b/src/lib/icons.js @@ -22,6 +22,7 @@ const PATHS = { 'bar-chart': '', 'home': '', 'paperclip': '', + 'copy': '', 'clipboard': '', 'file': '', 'file-text': '', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 59fb36b..bdd2c53 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -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'), diff --git a/src/locales/modules/settings.js b/src/locales/modules/settings.js index 09839ff..f9b7e40 100644 --- a/src/locales/modules/settings.js +++ b/src/locales/modules/settings.js @@ -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', '使用', '使用', '사용'), } diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 1069b65..bf3dd9c 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -2431,10 +2431,10 @@ function renderMessages() { ? `${escHtml(img.name)}` : `
${escHtml(img.name || t('assistant.image'))}
` ).join('')}` : '' - return `
${imagesHtml}${textPart ? escHtml(textPart) : ''}
` + return `
${imagesHtml}${textPart ? escHtml(textPart) : ''}
` } else if (m.role === 'assistant') { const toolHtml = renderToolBlocks(m.toolHistory) - return `
${toolHtml}
${renderMarkdown(m.content)}
` + return `
${toolHtml}
${renderMarkdown(m.content)}
` } 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) diff --git a/src/pages/chat.js b/src/pages/chat.js index eac589e..ea0e0be 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -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(`·${tokenStr}`) } } + parts.push(``) 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 = `${formatTime(msgTime || new Date())}` + meta.innerHTML = `${formatTime(msgTime || new Date())}` 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 = `${formatTime(msgTime || new Date())}` + meta.innerHTML = `${formatTime(msgTime || new Date())}` wrap.appendChild(bubble) wrap.appendChild(meta) diff --git a/src/pages/settings.js b/src/pages/settings.js index 682f618..62ab15b 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -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) { + +
` } catch (e) { bar.innerHTML = `
${e}
` @@ -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 = `
${t('settings.gitScanning')}
` + try { + const results = await api.scanGitPaths() + if (!results || results.length === 0) { + container.innerHTML = `
${t('settings.gitScanEmpty')}
` + return + } + container.innerHTML = `
${results.map(r => + `
+ ${escapeHtml(r.path)} + ${escapeHtml(r.version || '')} + ${escapeHtml(r.source)} + +
` + ).join('')}
` + } catch (e) { + container.innerHTML = `
${e}
` + } +} + async function handleResetGitPath(page) { const cfg = await api.readPanelConfig() delete cfg.gitPath diff --git a/src/style/assistant.css b/src/style/assistant.css index 7acb04d..8a9dc50 100644 --- a/src/style/assistant.css +++ b/src/style/assistant.css @@ -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; diff --git a/src/style/chat.css b/src/style/chat.css index f063648..1b2fa4b 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -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;