From cdeb5a068e7d09ea7d55b5242c2e79f41df6921a Mon Sep 17 00:00:00 2001 From: bai <1145000687@qq.com> Date: Thu, 13 Aug 2026 16:44:30 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor(desktop):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=9D=99=E9=BB=98=E5=91=BD=E4=BB=A4=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=81=BF=E5=85=8D=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=BC=B9=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换所有 Command::new 调用为 silent_command 函数 - 移除 Windows 平台的 CREATE_NO_WINDOW 标志设置 - 添加 process_utils 模块处理静默命令执行 - 为 git 命令和其他系统命令统一应用静默执行模式 - 添加 Windows 平台测试验证命令不会创建控制台窗口 --- .../src-tauri/src/desktop_ops.rs | 72 +++++++++++++++++-- soloncode-desktop/src-tauri/src/lib.rs | 48 +++++-------- 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/soloncode-desktop/src-tauri/src/desktop_ops.rs b/soloncode-desktop/src-tauri/src/desktop_ops.rs index 5311478b..9e4fc97e 100644 --- a/soloncode-desktop/src-tauri/src/desktop_ops.rs +++ b/soloncode-desktop/src-tauri/src/desktop_ops.rs @@ -1,8 +1,9 @@ +use crate::process_utils::silent_command; use serde::Serialize; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::time::{Instant, SystemTime, UNIX_EPOCH}; const CHECKPOINT_REF_ROOT: &str = "refs/soloncode/checkpoints"; @@ -43,7 +44,7 @@ fn canonical_workspace(workspace: &str) -> Result { } fn git_output(repo: &Path, args: &[&str]) -> Result, String> { - let output = Command::new("git") + let output = silent_command("git") .args(args) .current_dir(repo) .output() @@ -71,7 +72,7 @@ fn git_repo(workspace: &str) -> Result { } fn git_with_temp_index(repo: &Path, index: &Path, args: &[&str]) -> Result, String> { - let output = Command::new("git") + let output = silent_command("git") .args(args) .env("GIT_INDEX_FILE", index) .env("GIT_AUTHOR_NAME", "SolonCode") @@ -255,7 +256,7 @@ pub fn workspace_checkpoint_restore(workspace: &str, checkpoint_id: &str) -> Res return Ok(()); } for check_only in [true, false] { - let mut command = Command::new("git"); + let mut command = silent_command("git"); command.arg("apply").arg("--binary"); if check_only { command.arg("--check"); @@ -360,7 +361,7 @@ pub async fn run_workspace_check( .collect::>() .join(" "); let started = Instant::now(); - let output = Command::new(&program) + let output = silent_command(&program) .args(&args) .current_dir(&workspace) .output() @@ -391,6 +392,67 @@ pub async fn run_workspace_check( #[cfg(test)] mod tests { use super::*; + use std::process::Command; + + #[cfg(target_os = "windows")] + #[test] + fn git_commands_do_not_create_console_windows() { + const PROBE_ENV: &str = "SOLONCODE_DESKTOP_CONSOLE_PROBE"; + + if std::env::var_os(PROBE_ENV).is_some() { + extern "system" { + fn FreeConsole() -> i32; + } + + // Simulate the release desktop process, which has no attached console. + unsafe { + FreeConsole(); + } + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("soloncode-console-probe-{nonce}")); + fs::create_dir_all(&root).unwrap(); + git_output(&root, &["init"]).unwrap(); + + let script = "Add-Type -Name NativeConsole -Namespace Win32 -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern IntPtr GetConsoleWindow();'; [Win32.NativeConsole]::GetConsoleWindow().ToInt64()"; + let script_bytes: Vec = script + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect(); + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, script_bytes); + let alias = format!( + "alias.console=!powershell.exe -NoProfile -NonInteractive -EncodedCommand {encoded}" + ); + let output = + String::from_utf8(git_output(&root, &["-c", &alias, "console"]).unwrap()).unwrap(); + let console_handle = output + .lines() + .find_map(|line| line.trim().parse::().ok()) + .expect("PowerShell should report its console window handle"); + + let canonical_root = fs::canonicalize(&root).unwrap(); + let canonical_temp = fs::canonicalize(std::env::temp_dir()).unwrap(); + assert!(canonical_root.starts_with(canonical_temp)); + fs::remove_dir_all(canonical_root).unwrap(); + assert_eq!(console_handle, 0, "Git descendants must remain windowless"); + return; + } + + let status = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "desktop_ops::tests::git_commands_do_not_create_console_windows", + "--nocapture", + ]) + .env(PROBE_ENV, "1") + .status() + .unwrap(); + assert!(status.success(), "windowless Git probe failed"); + } #[test] fn checkpoint_restores_workspace_but_not_secret_files() { diff --git a/soloncode-desktop/src-tauri/src/lib.rs b/soloncode-desktop/src-tauri/src/lib.rs index bb0ca4fe..ace0eafa 100644 --- a/soloncode-desktop/src-tauri/src/lib.rs +++ b/soloncode-desktop/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ mod credentials; mod desktop_ops; +mod process_utils; + +use process_utils::silent_command; use serde::{Deserialize, Serialize}; use std::fs; use std::io::{Read as IoRead, Seek as IoSeek, SeekFrom, Write as IoWrite}; use base64::Engine; use std::path::{Path, PathBuf}; -use std::process::{Child, Command}; +use std::process::Child; use std::sync::Mutex; use std::net::TcpStream; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -264,13 +267,8 @@ pub struct GitLogEntry { /// 执行 git 命令的辅助函数 fn run_git(args: &[&str], cwd: &str) -> Result { - let mut command = Command::new("git"); + let mut command = silent_command("git"); command.args(args).current_dir(cwd); - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x08000000); // CREATE_NO_WINDOW - } let output = command.output() .map_err(|e| format!("执行 git 命令失败: {}", e))?; @@ -1381,7 +1379,7 @@ fn detect_launch_method() -> BackendLaunchMethod { } } // 1. 优先检查 soloncode 命令是否在 PATH 中 - let check = Command::new(if cfg!(windows) { "where" } else { "which" }) + let check = silent_command(if cfg!(windows) { "where" } else { "which" }) .arg("soloncode") .output(); if let Ok(output) = check { @@ -1515,15 +1513,13 @@ fn terminate_reused_backend(pid: u32, port: u16) { } #[cfg(target_os = "windows")] { - use std::os::windows::process::CommandExt; - let mut command = Command::new("taskkill"); + let mut command = silent_command("taskkill"); command.args(["/PID", &pid.to_string(), "/T", "/F"]); - command.creation_flags(0x08000000); let _ = command.output(); } #[cfg(not(target_os = "windows"))] { - let _ = Command::new("kill").args(["-TERM", &pid.to_string()]).output(); + let _ = silent_command("kill").args(["-TERM", &pid.to_string()]).output(); } } @@ -1531,10 +1527,8 @@ fn terminate_managed_backend(managed: &mut ManagedBackendProcess) { let pid = managed.child.id(); #[cfg(target_os = "windows")] { - use std::os::windows::process::CommandExt; - let mut command = Command::new("taskkill"); + let mut command = silent_command("taskkill"); command.args(["/PID", &pid.to_string(), "/T", "/F"]); - command.creation_flags(0x08000000); let _ = command.output(); } #[cfg(not(target_os = "windows"))] @@ -1788,8 +1782,6 @@ fn install_updates(app: tauri::AppHandle, backend_port: Option) -> Result Result Result Result Date: Thu, 13 Aug 2026 19:22:27 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=81=8A=E5=A4=A9=E8=87=AA=E5=8A=A8=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在图标组件中添加 audit 图标定义 - 在活动栏中添加审计日志入口 - 在设置面板中集成审计日志查看器 - 添加完整的审计日志面板组件,支持操作记录和权限策略管理 - 实现聊天模式中的完全访问选项 - 优化聊天消息列表的自动跟随逻辑,解决流式响应时的滚动问题 - 修复会话错误处理时的资源清理逻辑 - 在后端服务中支持完全访问模式,跳过审批流程 - 添加 Windows 平台无窗口进程创建工具 - 添加聊天自动跟随行为的单元测试 --- .../solon/codecli/portal/desktop/WsGate.java | 6 +- .../src-tauri/src/process_utils.rs | 18 +++ soloncode-desktop/src/App.tsx | 3 + .../src/components/ChatInput.tsx | 3 +- .../src/components/ChatMessages.tsx | 20 ++- soloncode-desktop/src/components/ChatView.tsx | 15 +- .../src/components/common/Icon.tsx | 3 +- .../src/components/layout/ActivityBar.tsx | 5 +- .../src/components/sidebar/AuditLogPanel.css | 69 +++++++++ .../src/components/sidebar/AuditLogPanel.tsx | 135 ++++++++++++++++++ .../src/components/sidebar/SettingsPanel.tsx | 14 +- soloncode-desktop/src/utils/chatAutoFollow.ts | 17 +++ .../tests/chatAutoFollow.test.ts | 17 +++ 13 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 soloncode-desktop/src-tauri/src/process_utils.rs create mode 100644 soloncode-desktop/src/components/sidebar/AuditLogPanel.css create mode 100644 soloncode-desktop/src/components/sidebar/AuditLogPanel.tsx create mode 100644 soloncode-desktop/src/utils/chatAutoFollow.ts create mode 100644 soloncode-desktop/tests/chatAutoFollow.test.ts diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java index 01934492..789597da 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/desktop/WsGate.java @@ -1353,7 +1353,7 @@ public class WsGate extends SimpleWebSocketListener { } static String normalizeDesktopRunMode(String mode) { - if ("auto".equals(mode) || "plan".equals(mode) || "goal".equals(mode)) { + if ("auto".equals(mode) || "plan".equals(mode) || "goal".equals(mode) || "full".equals(mode)) { return mode; } // 未知或缺失模式按最严格的审批执行处理,避免客户端字段异常导致静默放行。 @@ -1362,6 +1362,10 @@ public class WsGate extends SimpleWebSocketListener { static boolean requiresDesktopApproval(String mode, String toolName) { String normalizedMode = normalizeDesktopRunMode(mode); + // 完全访问模式:编辑和命令执行均无需审批 + if ("full".equals(normalizedMode)) { + return false; + } if ("bash".equals(toolName)) { return "default".equals(normalizedMode) || "auto".equals(normalizedMode); } diff --git a/soloncode-desktop/src-tauri/src/process_utils.rs b/soloncode-desktop/src-tauri/src/process_utils.rs new file mode 100644 index 00000000..7d9b8421 --- /dev/null +++ b/soloncode-desktop/src-tauri/src/process_utils.rs @@ -0,0 +1,18 @@ +use std::ffi::OsStr; +use std::process::Command; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +/// Builds a non-interactive child process without allocating a Windows console. +pub fn silent_command>(program: S) -> Command { + let mut command = Command::new(program); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); + } + + command +} diff --git a/soloncode-desktop/src/App.tsx b/soloncode-desktop/src/App.tsx index f7f15d9e..25c60fe3 100644 --- a/soloncode-desktop/src/App.tsx +++ b/soloncode-desktop/src/App.tsx @@ -20,6 +20,7 @@ import { SkillsPanel } from './components/sidebar/SkillsPanel'; import { AgentsPanel } from './components/sidebar/AgentsPanel'; import { ConfirmDialog } from './components/common/ConfirmDialog'; import { MemoryPanel } from './components/sidebar/MemoryPanel'; +import { AuditLogPanel } from './components/sidebar/AuditLogPanel'; import { AgentDetail } from './components/sidebar/AgentDetail'; import type { Settings } from './components/sidebar/SettingsPanel'; import type { ChatReviewFile } from './components/ChatHeader'; @@ -1596,6 +1597,8 @@ function App() { }} onCreateWithAI={() => handleStartPromptCreation('agent')} />; + case 'audit': + return ; default: return null; } diff --git a/soloncode-desktop/src/components/ChatInput.tsx b/soloncode-desktop/src/components/ChatInput.tsx index ca989917..a59cdc23 100644 --- a/soloncode-desktop/src/components/ChatInput.tsx +++ b/soloncode-desktop/src/components/ChatInput.tsx @@ -284,7 +284,7 @@ function getModelDisplayName(p: ModelProvider): string { } export type ReasoningEffort = 'low' | 'medium' | 'high' | 'max'; -export type ChatMode = 'default' | 'auto' | 'plan' | 'goal'; +export type ChatMode = 'default' | 'auto' | 'plan' | 'goal' | 'full'; function extractGoalCommandObjective(input: string): string | null { const match = input.trim().match(/^\/goal(?:\s+([\s\S]*))?$/i); @@ -1312,6 +1312,7 @@ export function ChatInput({ onSend, isLoading, onStop, availableFiles = [], agen + {/* 模型 */} diff --git a/soloncode-desktop/src/components/ChatMessages.tsx b/soloncode-desktop/src/components/ChatMessages.tsx index c70b6b31..37282cf0 100644 --- a/soloncode-desktop/src/components/ChatMessages.tsx +++ b/soloncode-desktop/src/components/ChatMessages.tsx @@ -10,6 +10,7 @@ import { ActionGroupBlock } from './ActionGroupBlock'; import type { Message, Theme, ContentItem } from '../types'; import { isTodoToolName } from '../utils/todoTools'; import { isSafeImageDataUrl } from '../utils/messageContent'; +import { resolveChatAutoFollow } from '../utils/chatAutoFollow'; import { permissionService } from '../services/permissionService'; import './ChatMessages.css'; @@ -666,6 +667,17 @@ export const ChatMessages = forwardRef( // 运行期间始终保留底部状态,避免工具调用结果出现后看不到仍在执行。 const showThinkingRow = isLoading; + const handleListHeightChanged = useCallback(() => { + const followState = resolveChatAutoFollow({ + running: isLoading, + atBottom: autoFollowRef.current, + }); + autoFollowRef.current = followState.enabled; + if (isLoading) { + // 流式响应会原地增高最后一项,totalCount 不变时 followOutput 不会自行触发。 + virtuosoRef.current?.autoscrollToBottom(); + } + }, [isLoading]); const itemContent = useCallback((index: number) => { if (showThinkingRow && index === visibleMessages.length) { @@ -696,10 +708,14 @@ export const ChatMessages = forwardRef( ref={virtuosoRef} totalCount={visibleMessages.length + (showThinkingRow ? 1 : 0)} itemContent={itemContent} - followOutput={(isAtBottom) => autoFollowRef.current && isAtBottom ? 'auto' : false} + followOutput={(isAtBottom) => resolveChatAutoFollow({ + running: isLoading, + atBottom: autoFollowRef.current && isAtBottom, + }).behavior} atBottomStateChange={(atBottom) => { - autoFollowRef.current = atBottom; + autoFollowRef.current = resolveChatAutoFollow({ running: isLoading, atBottom }).enabled; }} + totalListHeightChanged={handleListHeightChanged} initialTopMostItemIndex={Math.max(0, visibleMessages.length + (showThinkingRow ? 1 : 0) - 1)} computeItemKey={(index) => showThinkingRow && index === visibleMessages.length ? 'thinking' : (visibleMessages[index]?.id ?? index)} style={{ height: '100%' }} diff --git a/soloncode-desktop/src/components/ChatView.tsx b/soloncode-desktop/src/components/ChatView.tsx index 6fbea004..d339772f 100644 --- a/soloncode-desktop/src/components/ChatView.tsx +++ b/soloncode-desktop/src/components/ChatView.tsx @@ -1304,6 +1304,13 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN resolveStreamIdle(sessionId); } + function pauseStreamPump() { + if (streamPumpTimerRef.current) { + clearTimeout(streamPumpTimerRef.current); + streamPumpTimerRef.current = null; + } + } + function clearLiveSession(sessionId: string) { backgroundContentBySessionRef.current.delete(sessionId); liveBaseMessagesBySessionRef.current.delete(sessionId); @@ -1823,22 +1830,24 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN }); } if (!isCurrentSession) { + pauseStreamPump(); const pending = await flushPendingUserMessage(msgSessionId); await flushAssistantPersistence(msgSessionId); await appendResponseError(pending?.sessionId || msgSessionId, data.text); if (pending?.wasNew && onUpdateSessionTitleRef.current) { onUpdateSessionTitleRef.current(pending.sessionId, pending.title); } - clearLiveSession(msgSessionId); if (streamingSessionIdRef.current === msgSessionId) { + clearLoadingTimer(); streamingSessionIdRef.current = null; isStreamingRef.current = false; setIsLoading(false); } + onSessionRunStateChangeRef.current?.(msgSessionId, 'error', data.text || '会话执行失败'); return; } clearLoadingTimer(); - clearStreamQueue(msgSessionId); + pauseStreamPump(); // 即使出错也要持久化用户消�? const pending = await flushPendingUserMessage(msgSessionId); @@ -1851,12 +1860,12 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN onUpdateSessionTitleRef.current(pending.sessionId, pending.title); } - clearLiveSession(msgSessionId); setIsLoading(false); isStreamingRef.current = false; if (streamingSessionIdRef.current === msgSessionId) { streamingSessionIdRef.current = null; } + onSessionRunStateChangeRef.current?.(msgSessionId, 'error', data.text || '会话执行失败'); return; } diff --git a/soloncode-desktop/src/components/common/Icon.tsx b/soloncode-desktop/src/components/common/Icon.tsx index 2ce2679b..65cafd6c 100644 --- a/soloncode-desktop/src/components/common/Icon.tsx +++ b/soloncode-desktop/src/components/common/Icon.tsx @@ -7,7 +7,7 @@ export type IconName = | 'file-java' | 'file-rs' | 'file-py' | 'file-img' | 'file-lock' | 'file-yml' // 功能图标 | 'explorer' | 'search' | 'git' | 'extensions' | 'sessions' | 'settings' - | 'chat' | 'terminal' | 'code' | 'skills' | 'agents' | 'automation' | 'memory' | 'goal' | 'channels' | 'info' | 'model' + | 'chat' | 'terminal' | 'code' | 'skills' | 'agents' | 'automation' | 'memory' | 'goal' | 'channels' | 'info' | 'model' | 'audit' // 操作图标 | 'add' | 'remove' | 'edit' | 'delete' | 'refresh' | 'save' | 'push' | 'pull' | 'commit' | 'stage' | 'unstage' @@ -63,6 +63,7 @@ const iconPaths: Record = { 'code': 'M16 18l6-6-6-6M8 6l-6 6 6 6', 'info': 'M12 16v-4M12 8h.01M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20', 'model': 'M12 2l9 5-9 5-9-5 9-5zM3 12l9 5 9-5M3 17l9 5 9-5', + 'audit': 'M9 2h6a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zM10 6h4M10 10h4M10 14h2M3 6l3 3-3 3M3 18h3', // 操作图标 'add': 'M12 5v14M5 12h14', diff --git a/soloncode-desktop/src/components/layout/ActivityBar.tsx b/soloncode-desktop/src/components/layout/ActivityBar.tsx index daeb6540..d0a947a2 100644 --- a/soloncode-desktop/src/components/layout/ActivityBar.tsx +++ b/soloncode-desktop/src/components/layout/ActivityBar.tsx @@ -2,7 +2,7 @@ import { Icon } from '../common/Icon'; import type { Theme } from '../../types'; import './ActivityBar.css'; -export type ActivityType = 'explorer' | 'git' | 'extensions' | 'sessions' | 'automation' | 'memory' | 'settings' | 'skills' | 'agents'; +export type ActivityType = 'explorer' | 'git' | 'extensions' | 'sessions' | 'automation' | 'memory' | 'settings' | 'skills' | 'agents' | 'audit'; interface ActivityBarProps { activeActivity: ActivityType; @@ -13,7 +13,7 @@ interface ActivityBarProps { interface ActivityItem { id: ActivityType; - icon: 'explorer' | 'search' | 'git' | 'extensions' | 'sessions' | 'automation' | 'memory' | 'settings' | 'skills' | 'agents'; + icon: 'explorer' | 'search' | 'git' | 'extensions' | 'sessions' | 'automation' | 'memory' | 'settings' | 'skills' | 'agents' | 'audit'; title: string; } @@ -23,6 +23,7 @@ const activities: ActivityItem[] = [ { id: 'memory', icon: 'memory', title: '长期记忆' }, { id: 'skills', icon: 'skills', title: 'Skills' }, { id: 'agents', icon: 'agents', title: 'Agents' }, + { id: 'audit', icon: 'audit', title: '审计日志' }, ]; export function ActivityBar({ activeActivity, theme, onActivityChange, onToggleTheme }: ActivityBarProps) { diff --git a/soloncode-desktop/src/components/sidebar/AuditLogPanel.css b/soloncode-desktop/src/components/sidebar/AuditLogPanel.css new file mode 100644 index 00000000..30b05baa --- /dev/null +++ b/soloncode-desktop/src/components/sidebar/AuditLogPanel.css @@ -0,0 +1,69 @@ +.audit-log-panel { height: 100%; display: flex; flex-direction: column; color: var(--cb-text-primary); } + +/* ---- Tabs ---- */ +.audit-tabs { display: flex; gap: 2px; padding: 0 9px; } +.audit-tab { + flex: 1; padding: 6px 10px; font-size: 12px; cursor: pointer; + border: 0; border-bottom: 2px solid transparent; + background: transparent; color: var(--cb-text-secondary); +} +.audit-tab.active { + color: var(--cb-text-primary); + border-bottom-color: var(--cb-accent); + font-weight: 600; +} +.audit-tab:hover { color: var(--cb-text-primary); } + +/* ---- Search ---- */ +.audit-search { + margin: 9px; display: flex; align-items: center; gap: 7px; + padding: 6px 8px; border: 1px solid var(--cb-vscode-panel-border); + border-radius: 6px; background: var(--cb-vscode-input-background); +} +.audit-search input { min-width: 0; width: 100%; padding: 0; border: 0; background: transparent; } + +/* ---- List ---- */ +.audit-list { flex: 1; min-height: 100px; overflow: auto; border-top: 1px solid var(--cb-vscode-panel-border); } +.audit-empty { padding: 28px 14px; text-align: center; color: var(--cb-text-secondary); } + +/* ---- Audit Item ---- */ +.audit-item { + display: flex; flex-direction: column; gap: 4px; + padding: 9px 12px; border-bottom: 1px solid var(--cb-vscode-panel-border); +} +.audit-item-header { display: flex; align-items: center; gap: 8px; } +.audit-badge { + font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px; + white-space: nowrap; +} +.audit-badge-approve_once { color: var(--cb-success); background: color-mix(in srgb, var(--cb-success) 12%, transparent); } +.audit-badge-approve_always { color: var(--cb-accent); background: color-mix(in srgb, var(--cb-accent) 12%, transparent); } +.audit-badge-reject { color: var(--cb-error); background: color-mix(in srgb, var(--cb-error) 12%, transparent); } +.audit-badge-auto_approve { color: var(--cb-text-secondary); background: color-mix(in srgb, var(--cb-text-secondary) 12%, transparent); } +.audit-tool { font-weight: 600; font-size: 12px; } +.audit-time { margin-left: auto; color: var(--cb-text-secondary); font-size: 10px; white-space: nowrap; } +.audit-command { + font-family: var(--cb-vscode-editor-font-family, monospace); + font-size: 11px; color: var(--cb-text-secondary); + background: var(--cb-vscode-textBlockQuote-background); + padding: 4px 6px; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; max-width: 100%; +} +.audit-workspace { font-size: 10px; color: var(--cb-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---- Policy Item ---- */ +.audit-policy-item { + display: flex; align-items: center; gap: 8px; + padding: 9px 12px; border-bottom: 1px solid var(--cb-vscode-panel-border); +} +.audit-policy-item:hover { background: var(--cb-vscode-list-hoverBackground); } +.audit-policy-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.audit-policy-tool { font-weight: 600; font-size: 12px; } +.audit-policy-path { font-size: 10px; color: var(--cb-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.audit-policy-time { font-size: 10px; color: var(--cb-text-secondary); } +.audit-policy-remove { + flex-shrink: 0; border: 0; background: transparent; cursor: pointer; + color: var(--cb-text-secondary); padding: 4px; border-radius: 4px; + display: flex; align-items: center; justify-content: center; +} +.audit-policy-remove:hover { color: var(--cb-error); background: color-mix(in srgb, var(--cb-error) 10%, transparent); } diff --git a/soloncode-desktop/src/components/sidebar/AuditLogPanel.tsx b/soloncode-desktop/src/components/sidebar/AuditLogPanel.tsx new file mode 100644 index 00000000..3fd67c9b --- /dev/null +++ b/soloncode-desktop/src/components/sidebar/AuditLogPanel.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Icon } from '../common/Icon'; +import { permissionService } from '../../services/permissionService'; +import type { DbPermissionAudit, DbPermissionPolicy, PermissionAuditAction } from '../../db'; +import './AuditLogPanel.css'; + +const ACTION_LABELS: Record = { + approve_once: '允许本次', + approve_always: '总是允许', + reject: '拒绝', + auto_approve: '自动放行', +}; + +export function AuditLogPanel() { + const [tab, setTab] = useState<'audit' | 'policy'>('audit'); + const [audits, setAudits] = useState([]); + const [policies, setPolicies] = useState([]); + const [busy, setBusy] = useState(false); + const [filter, setFilter] = useState(''); + + const refresh = useCallback(async () => { + setBusy(true); + try { + const [a, p] = await Promise.all([ + permissionService.listAudits(200), + permissionService.listPolicies(), + ]); + setAudits(a); + setPolicies(p); + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { void refresh(); }, [refresh]); + + const visibleAudits = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return audits; + return audits.filter(a => + a.toolName.toLowerCase().includes(q) || + a.action.toLowerCase().includes(q) || + a.workspacePath.toLowerCase().includes(q) || + (a.commandPreview || '').toLowerCase().includes(q) + ); + }, [audits, filter]); + + const visiblePolicies = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return policies; + return policies.filter(p => + p.toolName.toLowerCase().includes(q) || + p.workspacePath.toLowerCase().includes(q) + ); + }, [policies, filter]); + + async function removePolicy(id: string) { + await permissionService.removePolicy(id); + await refresh(); + } + + return ( +
+
+ 审计日志 +
+ +
+
+
+ + +
+
+ + setFilter(e.target.value)} placeholder="搜索..." /> +
+
+ {busy && (tab === 'audit' ? audits.length === 0 : policies.length === 0) ? ( +
加载中...
+ ) : tab === 'audit' ? ( + visibleAudits.length === 0 ? ( +
暂无审计记录
+ ) : visibleAudits.map(audit => ( +
+
+ {ACTION_LABELS[audit.action]} + {audit.toolName} + {formatTime(audit.createdAt)} +
+ {audit.commandPreview && ( +
{audit.commandPreview}
+ )} +
{audit.workspacePath}
+
+ )) + ) : visiblePolicies.length === 0 ? ( +
暂无权限策略
+ ) : visiblePolicies.map(policy => ( +
+
+ {policy.toolName} + {policy.workspacePath} + 创建于 {formatTime(policy.createdAt)} +
+ +
+ ))} +
+
+ ); +} + +function formatTime(iso: string): string { + try { + const d = new Date(iso); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`; + if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}天前`; + return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } catch { + return iso; + } +} diff --git a/soloncode-desktop/src/components/sidebar/SettingsPanel.tsx b/soloncode-desktop/src/components/sidebar/SettingsPanel.tsx index 89342f36..0fbf2a4e 100644 --- a/soloncode-desktop/src/components/sidebar/SettingsPanel.tsx +++ b/soloncode-desktop/src/components/sidebar/SettingsPanel.tsx @@ -21,6 +21,7 @@ import { updateService, type UpdateInfo } from '../../services/updateService'; import { requestDesktopModels } from '../../services/modelDiscoveryService'; import { redactLogContent } from '../../utils/logViewer'; import { ChannelQrBind } from './ChannelQrBind'; +import { AuditLogPanel } from './AuditLogPanel'; import './SettingsPanel.css'; import './ChannelPanel.css'; @@ -1533,7 +1534,7 @@ function PromptsSettings({ skillPrompt, agentPrompt, gitPrompt, onPromptChange } /* ==================== 日志查看 ==================== */ function LogsSettings({ workspacePath }: { workspacePath?: string | null }) { - const [activeLog, setActiveLog] = useState<'desktop' | 'cli'>('desktop'); + const [activeLog, setActiveLog] = useState<'desktop' | 'cli' | 'audit'>('desktop'); const [logs, setLogs] = useState>({ desktop: '', cli: '' }); const [loading, setLoading] = useState(false); const requestIdRef = useRef(0); @@ -1580,11 +1581,16 @@ function LogsSettings({ workspacePath }: { workspacePath?: string | null }) {
+ 仅加载最近日志,敏感信息会自动脱敏
-
-        {content}
-      
+ {activeLog === 'audit' ? ( + + ) : ( +
+          {content}
+        
+ )} ); } diff --git a/soloncode-desktop/src/utils/chatAutoFollow.ts b/soloncode-desktop/src/utils/chatAutoFollow.ts new file mode 100644 index 00000000..eecff1b1 --- /dev/null +++ b/soloncode-desktop/src/utils/chatAutoFollow.ts @@ -0,0 +1,17 @@ +export interface ChatAutoFollowInput { + running: boolean; + atBottom: boolean; +} + +export interface ChatAutoFollowState { + enabled: boolean; + behavior: 'auto' | false; +} + +export function resolveChatAutoFollow({ running, atBottom }: ChatAutoFollowInput): ChatAutoFollowState { + const enabled = running || atBottom; + return { + enabled, + behavior: enabled ? 'auto' : false, + }; +} diff --git a/soloncode-desktop/tests/chatAutoFollow.test.ts b/soloncode-desktop/tests/chatAutoFollow.test.ts new file mode 100644 index 00000000..c35db5af --- /dev/null +++ b/soloncode-desktop/tests/chatAutoFollow.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveChatAutoFollow } from '../src/utils/chatAutoFollow.ts'; + +test('keeps following the bottom while a running response grows in place', () => { + assert.deepEqual( + resolveChatAutoFollow({ running: true, atBottom: false }), + { enabled: true, behavior: 'auto' }, + ); +}); + +test('allows leaving the bottom after the response stops running', () => { + assert.deepEqual( + resolveChatAutoFollow({ running: false, atBottom: false }), + { enabled: false, behavior: false }, + ); +}); -- Gitee From 03c96c3e56752ae80d0a94d1283b2bba8837b995 Mon Sep 17 00:00:00 2001 From: bai <1145000687@qq.com> Date: Fri, 14 Aug 2026 19:06:28 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(mount):=20=E4=BF=AE=E5=A4=8D=E6=8C=82?= =?UTF-8?q?=E8=BD=BD=E7=82=B9=E5=88=97=E8=A1=A8=E4=B8=AD=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加去重逻辑,确保同一目录只保留一个挂载项 - 优先显示系统挂载而非自定义别名 - 根据路径规范化处理避免大小写导致的重复 - 正确区分 workspace 和 user 作用域的挂载点 --- .gitignore | 2 ++ .../web/settings/MountSettingsController.java | 30 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dac0724b..8937d74e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ soloncode-desktop/src-tauri/release/ soloncode-desktop/src-tauri/build/ soloncode-desktop/src-tauri/resources/ nul +soloncode-desktop-web/node_modules +soloncode-desktop-web/dist diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/MountSettingsController.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/MountSettingsController.java index b630dacb..6f2c0270 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/MountSettingsController.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/MountSettingsController.java @@ -56,7 +56,18 @@ public class MountSettingsController extends BaseSettingsController { public Result mountsList(Context ctx) { List> list = new ArrayList<>(); + // 同一目录可能同时保留旧版自定义别名和新版系统别名。 + // 安装目标只保留一个,并优先使用能明确表达 user/workspace 作用域的系统挂载。 + Map uniqueMounts = new LinkedHashMap<>(); for (MountDir entry : engine.getMounts()) { + String storageKey = mountStorageKey(entry); + MountDir current = uniqueMounts.get(storageKey); + if (current == null || (!current.isPrimary() && entry.isPrimary())) { + uniqueMounts.put(storageKey, entry); + } + } + + for (MountDir entry : uniqueMounts.values()) { Map item = new LinkedHashMap<>(); item.put("alias", entry.getAlias()); item.put("type", entry.getType()); @@ -70,7 +81,9 @@ public class MountSettingsController extends BaseSettingsController { MountDo mountDo = settings.getMountPools().get(entry.getAlias()); if (mountDo == null) { - item.put("scope", AgentFlags.SCOPE_USER); + item.put("scope", entry.getAlias().startsWith("@workspace-") + ? AgentFlags.SCOPE_LOCAL + : AgentFlags.SCOPE_USER); } else { item.put("scope", mountDo.getScope()); } @@ -83,6 +96,19 @@ public class MountSettingsController extends BaseSettingsController { return Result.succeed(list); } + private String mountStorageKey(MountDir mount) { + Path realPath = mount.getRealPath(); + if (realPath == null) { + return mount.getType() + "|alias:" + mount.getAlias(); + } + + String normalizedPath = realPath.toAbsolutePath().normalize().toString(); + if (File.separatorChar == '\\') { + normalizedPath = normalizedPath.toLowerCase(Locale.ROOT); + } + return mount.getType() + "|path:" + normalizedPath; + } + /** * 添加挂载池 */ @@ -384,4 +410,4 @@ public class MountSettingsController extends BaseSettingsController { break; } } -} \ No newline at end of file +} -- Gitee From 528f535a0d9d59604b7259ced1c398621f691472 Mon Sep 17 00:00:00 2001 From: bai <1145000687@qq.com> Date: Fri, 28 Aug 2026 17:24:39 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(statusbar):=20=E5=9C=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E6=B7=BB=E5=8A=A0=E6=A1=8C=E9=9D=A2=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=89=88=E6=9C=AC=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 StatusBar 组件中添加应用版本号显示 - 使用 Tauri API 获取桌面应用版本信息 - 添加版本号格式化工具函数 - 优化状态栏样式以适配版本显示 - 添加单元测试验证版本获取和显示功能 --- .../desktop/DesktopAttachmentSupportTest.java | 66 +++++++++++++ .../portal/desktop/DesktopStreamHubTest.java | 92 +++++++++++++++++++ .../portal/desktop/WsGateGoalModeTest.java | 25 +++++ .../portal/desktop/WsGateRunModeTest.java | 48 ++++++++++ .../web/WebControllerSessionIdTest.java | 31 +++++++ .../settings/MountSettingsControllerTest.java | 64 +++++++++++++ soloncode-desktop/src/components/ChatView.tsx | 7 +- .../src/components/layout/StatusBar.css | 7 +- .../src/components/layout/StatusBar.tsx | 25 +++++ .../src/services/updateService.ts | 6 ++ soloncode-desktop/src/utils/messageContent.ts | 5 + .../tests/messageContent.test.ts | 13 ++- 12 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopAttachmentSupportTest.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHubTest.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateGoalModeTest.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateRunModeTest.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/WebControllerSessionIdTest.java create mode 100644 soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/settings/MountSettingsControllerTest.java diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopAttachmentSupportTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopAttachmentSupportTest.java new file mode 100644 index 00000000..bfb8d5ec --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopAttachmentSupportTest.java @@ -0,0 +1,66 @@ +package org.noear.solon.codecli.portal.desktop; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DesktopAttachmentSupportTest { + @TempDir + Path workspace; + + @Test + void decodesAndStoresFileInsideWorkspaceUploads() throws Exception { + WsMessage.WsAttachment attachment = attachment("file", "notes.txt", "text/plain", "hello"); + + byte[] bytes = DesktopAttachmentSupport.decode(attachment); + String relativePath = DesktopAttachmentSupport.save(workspace, attachment.getName(), bytes); + + assertEquals(".uploads/notes.txt", relativePath); + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(workspace.resolve(relativePath))); + } + + @Test + void rejectsTraversalAndInvalidBase64() { + WsMessage.WsAttachment traversal = attachment("file", "../outside.txt", "text/plain", "hello"); + byte[] bytes = DesktopAttachmentSupport.decode(traversal); + assertThrows(IllegalArgumentException.class, + () -> DesktopAttachmentSupport.save(workspace, traversal.getName(), bytes)); + + WsMessage.WsAttachment invalid = attachment("file", "safe.txt", "text/plain", "hello"); + invalid.setData("not-base64!!"); + assertThrows(IllegalArgumentException.class, () -> DesktopAttachmentSupport.decode(invalid)); + } + + @Test + void multimodalImageRequiresSupportedExtensionAndMimeType() { + WsMessage.WsAttachment png = attachment("image", "chart.png", "image/png", "image"); + assertTrue(DesktopAttachmentSupport.isMultimodalImage(png)); + + WsMessage.WsAttachment renamed = attachment("image", "chart.txt", "image/png", "image"); + assertFalse(DesktopAttachmentSupport.isMultimodalImage(renamed)); + + WsMessage.WsAttachment wrongMime = attachment("image", "chart.png", "text/html", "image"); + assertFalse(DesktopAttachmentSupport.isMultimodalImage(wrongMime)); + } + + private static WsMessage.WsAttachment attachment(String type, String name, String mimeType, String text) { + WsMessage.WsAttachment attachment = new WsMessage.WsAttachment(); + attachment.setType(type); + attachment.setName(name); + attachment.setMimeType(mimeType); + attachment.setEncoding("base64"); + attachment.setData(Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8))); + return attachment; + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHubTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHubTest.java new file mode 100644 index 00000000..fceb2d32 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/DesktopStreamHubTest.java @@ -0,0 +1,92 @@ +package org.noear.solon.codecli.portal.desktop; + +import org.junit.jupiter.api.Test; +import org.noear.snack4.ONode; +import org.noear.solon.net.websocket.WebSocket; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DesktopStreamHubTest { + @Test + void reconnectReplaysOnlyMessagesAfterAcknowledgedSequence() { + DesktopStreamHub hub = new DesktopStreamHub(); + SocketRecorder original = new SocketRecorder(); + hub.begin("session-1", original.socket); + + hub.emit("session-1", new ONode().set("type", "text").set("text", "a").toJson()); + hub.emit("session-1", new ONode().set("type", "done").toJson()); + + assertEquals(2, original.messages.size()); + assertEquals(1L, sequence(original.messages.get(0))); + assertEquals(2L, sequence(original.messages.get(1))); + + SocketRecorder resumed = new SocketRecorder(); + assertTrue(hub.attach("session-1", resumed.socket, 1L)); + assertEquals(1, resumed.messages.size()); + assertEquals(2L, sequence(resumed.messages.get(0))); + } + + @Test + void reconnectFailsWhenRequestedSequenceHasFallenOutOfReplayBuffer() { + DesktopStreamHub hub = new DesktopStreamHub(); + SocketRecorder original = new SocketRecorder(); + hub.begin("session-gap", original.socket); + for (int i = 0; i < 4097; i++) { + hub.emit("session-gap", new ONode().set("type", "text").set("text", Integer.toString(i)).toJson()); + } + + SocketRecorder resumed = new SocketRecorder(); + assertFalse(hub.attach("session-gap", resumed.socket, 0L)); + assertTrue(resumed.messages.isEmpty()); + } + + @Test + void reconnectFailsForUnknownSession() { + DesktopStreamHub hub = new DesktopStreamHub(); + assertFalse(hub.attach("missing", new SocketRecorder().socket, 0L)); + } + + private static long sequence(String json) { + return ONode.ofJson(json).get("sequence").getLong(); + } + + private static final class SocketRecorder implements InvocationHandler { + private final List messages = new ArrayList<>(); + private final WebSocket socket = (WebSocket) Proxy.newProxyInstance( + WebSocket.class.getClassLoader(), + new Class[]{WebSocket.class}, + this); + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String name = method.getName(); + if ("send".equals(name) && args != null && args.length == 1 && args[0] instanceof String) { + messages.add((String) args[0]); + return CompletableFuture.completedFuture(null); + } + if ("hashCode".equals(name)) { + return System.identityHashCode(proxy); + } + if ("equals".equals(name)) { + return proxy == args[0]; + } + if ("toString".equals(name)) { + return "SocketRecorder"; + } + Class returnType = method.getReturnType(); + if (returnType == boolean.class) return true; + if (returnType == long.class) return 0L; + if (returnType == int.class) return 0; + return null; + } + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateGoalModeTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateGoalModeTest.java new file mode 100644 index 00000000..3b49f0e8 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateGoalModeTest.java @@ -0,0 +1,25 @@ +package org.noear.solon.codecli.portal.desktop; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class WsGateGoalModeTest { + @Test + void goalModeUsesMessageAsObjective() { + assertEquals("持续修复测试", WsGate.extractGoalObjective("持续修复测试", "goal")); + } + + @Test + void goalCommandIsInterceptedBeforeGenericCommandDispatch() { + assertEquals("你好", WsGate.extractGoalObjective("/goal 你好", "default")); + assertEquals("", WsGate.extractGoalObjective(" /GOAL ", "default")); + } + + @Test + void similarlyNamedCommandsRemainGenericCommands() { + assertNull(WsGate.extractGoalObjective("/goalkeeper 你好", "default")); + assertNull(WsGate.extractGoalObjective("/help", "default")); + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateRunModeTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateRunModeTest.java new file mode 100644 index 00000000..e5d1e61d --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateRunModeTest.java @@ -0,0 +1,48 @@ +package org.noear.solon.codecli.portal.desktop; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WsGateRunModeTest { + @Test + void approvalModeProtectsFileChangesAndCommands() { + assertTrue(WsGate.requiresDesktopApproval("default", "write")); + assertTrue(WsGate.requiresDesktopApproval("default", "edit")); + assertTrue(WsGate.requiresDesktopApproval("default", "bash")); + assertFalse(WsGate.requiresDesktopApproval("default", "read")); + } + + @Test + void autoModeOnlyProtectsCommands() { + assertFalse(WsGate.requiresDesktopApproval("auto", "write")); + assertFalse(WsGate.requiresDesktopApproval("auto", "edit")); + assertTrue(WsGate.requiresDesktopApproval("auto", "bash")); + } + + @Test + void planAndGoalDoNotInstallDesktopApprovalPolicy() { + assertFalse(WsGate.requiresDesktopApproval("plan", "write")); + assertFalse(WsGate.requiresDesktopApproval("plan", "bash")); + assertFalse(WsGate.requiresDesktopApproval("goal", "write")); + assertFalse(WsGate.requiresDesktopApproval("goal", "bash")); + } + + @Test + void fullModeBypassesAllApprovals() { + assertFalse(WsGate.requiresDesktopApproval("full", "write")); + assertFalse(WsGate.requiresDesktopApproval("full", "edit")); + assertFalse(WsGate.requiresDesktopApproval("full", "bash")); + assertFalse(WsGate.requiresDesktopApproval("full", "read")); + } + + @Test + void missingOrUnknownModeFailsClosed() { + assertEquals("default", WsGate.normalizeDesktopRunMode(null)); + assertEquals("default", WsGate.normalizeDesktopRunMode("unexpected")); + assertTrue(WsGate.requiresDesktopApproval(null, "write")); + assertTrue(WsGate.requiresDesktopApproval("unexpected", "bash")); + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/WebControllerSessionIdTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/WebControllerSessionIdTest.java new file mode 100644 index 00000000..a557ebf9 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/WebControllerSessionIdTest.java @@ -0,0 +1,31 @@ +package org.noear.solon.codecli.portal.web; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WebControllerSessionIdTest { + private boolean valid(String value) throws Exception { + Method method = WebController.class.getDeclaredMethod("isValidSessionId", String.class); + method.setAccessible(true); + return (Boolean) method.invoke(null, value); + } + + @Test + void acceptsWebAndDesktopSessionIds() throws Exception { + assertTrue(valid("web-demo_1")); + assertTrue(valid("42")); + assertFalse(valid("0")); + } + + @Test + void rejectsTraversalAndUnexpectedDesktopIds() throws Exception { + assertFalse(valid("../42")); + assertFalse(valid("1/../../secret")); + assertFalse(valid("temp-42")); + assertFalse(valid("42.json")); + } +} diff --git a/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/settings/MountSettingsControllerTest.java b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/settings/MountSettingsControllerTest.java new file mode 100644 index 00000000..a3193ef8 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/settings/MountSettingsControllerTest.java @@ -0,0 +1,64 @@ +package org.noear.solon.codecli.portal.web.settings; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.ai.harness.HarnessEngine; +import org.noear.solon.ai.talents.mount.MountDir; +import org.noear.solon.ai.talents.mount.MountType; +import org.noear.solon.codecli.config.AgentSettings; +import org.noear.solon.core.handle.Result; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MountSettingsControllerTest { + @TempDir + Path tempDir; + + private String oldUserHome; + private String oldUserDir; + private MountSettingsController controller; + + @BeforeEach + void setUp() { + oldUserHome = System.getProperty("user.home"); + oldUserDir = System.getProperty("user.dir"); + System.setProperty("user.home", tempDir.resolve("home").toString()); + System.setProperty("user.dir", tempDir.resolve("workspace").toString()); + + HarnessEngine engine = HarnessEngine.of(tempDir.resolve("workspace").toString(), ".soloncode/") + .mountAdd(MountDir.builder().alias("@global-skills").type(MountType.SKILLS).path("~/.soloncode/skills/").build()) + .mountAdd(MountDir.builder().alias("@user-skills").type(MountType.SKILLS).path("~/.soloncode/skills/").primary(true).build()) + .mountAdd(MountDir.builder().alias("@workspace-skills").type(MountType.SKILLS).path("./.soloncode/skills/").primary(true).build()) + .build(); + controller = new MountSettingsController(engine, new AgentSettings(), null, null); + } + + @AfterEach + void tearDown() { + System.setProperty("user.home", oldUserHome); + System.setProperty("user.dir", oldUserDir); + } + + @Test + @SuppressWarnings("unchecked") + void listsOneInstallTargetPerDirectoryAndMarksWorkspaceScope() { + Result result = controller.mountsList(null); + assertEquals(200, result.getCode()); + + List> mounts = (List>) result.getData(); + assertEquals(2, mounts.size()); + assertTrue(mounts.stream().anyMatch(item -> "@user-skills".equals(item.get("alias")) + && "user".equals(item.get("scope")))); + assertTrue(mounts.stream().anyMatch(item -> "@workspace-skills".equals(item.get("alias")) + && "workspace".equals(item.get("scope")))); + assertFalse(mounts.stream().anyMatch(item -> "@global-skills".equals(item.get("alias")))); + } +} diff --git a/soloncode-desktop/src/components/ChatView.tsx b/soloncode-desktop/src/components/ChatView.tsx index 2645ec29..50a82bf6 100644 --- a/soloncode-desktop/src/components/ChatView.tsx +++ b/soloncode-desktop/src/components/ChatView.tsx @@ -17,7 +17,7 @@ import { type GeneratedAutomationPlan, } from '../utils/automationPlan'; import { isTodoToolName } from '../utils/todoTools'; -import { buildUserMessageContents, isSafeImageDataUrl, mergeStreamingMessage } from '../utils/messageContent'; +import { buildUserMessageContents, isSafeImageDataUrl, mergeStreamingMessage, sumReportedTokens } from '../utils/messageContent'; import { EMPTY_RESPONSE_ERROR, RESPONSE_PROTOCOL_ERROR, @@ -2590,10 +2590,7 @@ export function ChatView({ currentConversation, plugins, workspacePath, projectN const currentSession = useMemo(() => { return sessions.find(session => session.id === currentConversationIdString); }, [sessions, currentConversationIdString]); - const metadataTokens = useMemo(() => { - return messages.reduce((total, message) => total + (message.metadata?.totalTokens || 0), 0); - }, [messages]); - const headerTotalTokens = metadataTokens > 0 ? metadataTokens : baseContextTokens; + const headerTotalTokens = useMemo(() => sumReportedTokens(messages), [messages]); const headerMessageCount = Math.max(messages.length, currentSession?.messageCount || 0); const headerStartedAt = currentSession?.timestamp || currentConversation.timestamp; const totalConversationCount = useMemo(() => { diff --git a/soloncode-desktop/src/components/layout/StatusBar.css b/soloncode-desktop/src/components/layout/StatusBar.css index adb4b8be..8a56e8e4 100644 --- a/soloncode-desktop/src/components/layout/StatusBar.css +++ b/soloncode-desktop/src/components/layout/StatusBar.css @@ -147,6 +147,11 @@ /* 编码和语言 */ .status-encoding, -.status-language { +.status-language, +.status-version { opacity: 0.7; } + +.status-version { + font-variant-numeric: tabular-nums; +} diff --git a/soloncode-desktop/src/components/layout/StatusBar.tsx b/soloncode-desktop/src/components/layout/StatusBar.tsx index 3d28c46f..65ddc896 100644 --- a/soloncode-desktop/src/components/layout/StatusBar.tsx +++ b/soloncode-desktop/src/components/layout/StatusBar.tsx @@ -1,5 +1,8 @@ +import { useEffect, useState } from 'react'; +import { getVersion } from '@tauri-apps/api/app'; import { Icon } from '../common/Icon'; import { startWindowDrag } from '../../hooks/useWindowDrag'; +import { formatDesktopVersion } from '../../services/updateService'; import './StatusBar.css'; export type BackendStatus = 'connecting' | 'connected' | 'disconnected'; @@ -35,6 +38,22 @@ export function StatusBar({ hasUnsavedChanges = false, onReconnect, }: StatusBarProps) { + const [desktopVersion, setDesktopVersion] = useState(''); + + useEffect(() => { + let cancelled = false; + getVersion() + .then(version => { + if (!cancelled) setDesktopVersion(formatDesktopVersion(version)); + }) + .catch(error => { + console.warn('[StatusBar] read desktop version failed:', error); + }); + return () => { + cancelled = true; + }; + }, []); + const backendTitle = backendStatus === 'connected' ? '后端已连接' : backendStatus === 'connecting' @@ -116,6 +135,12 @@ export function StatusBar({ {language} )} + + {desktopVersion && ( + + {desktopVersion} + + )} ); diff --git a/soloncode-desktop/src/services/updateService.ts b/soloncode-desktop/src/services/updateService.ts index 0a1475c3..e3a1cf48 100644 --- a/soloncode-desktop/src/services/updateService.ts +++ b/soloncode-desktop/src/services/updateService.ts @@ -16,6 +16,12 @@ function normalizeBackendPort(backendPort?: number | null): number | null { return typeof backendPort === 'number' && backendPort > 0 ? backendPort : null; } +export function formatDesktopVersion(version: string): string { + const normalized = version.trim(); + if (!normalized) return ''; + return /^v/i.test(normalized) ? normalized : `v${normalized}`; +} + export const updateService = { async checkForUpdates(backendPort?: number | null): Promise { return await invoke('check_updates', { diff --git a/soloncode-desktop/src/utils/messageContent.ts b/soloncode-desktop/src/utils/messageContent.ts index 1ad742e4..d2cdb463 100644 --- a/soloncode-desktop/src/utils/messageContent.ts +++ b/soloncode-desktop/src/utils/messageContent.ts @@ -68,3 +68,8 @@ export function mergeStreamingMessage(previous: Message | undefined, snapshot: M metadata: snapshot.metadata ?? previous.metadata, }; } + +/** Sum only token usage reported by completed backend responses. */ +export function sumReportedTokens(messages: readonly Message[]): number { + return messages.reduce((total, message) => total + (message.metadata?.totalTokens ?? 0), 0); +} diff --git a/soloncode-desktop/tests/messageContent.test.ts b/soloncode-desktop/tests/messageContent.test.ts index 28824cad..bad2e031 100644 --- a/soloncode-desktop/tests/messageContent.test.ts +++ b/soloncode-desktop/tests/messageContent.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildUserMessageContents, isSafeImageDataUrl, mergeStreamingMessage } from '../src/utils/messageContent.ts'; +import { buildUserMessageContents, isSafeImageDataUrl, mergeStreamingMessage, sumReportedTokens } from '../src/utils/messageContent.ts'; import type { Message } from '../src/types/index.ts'; const PNG_DATA_URL = 'data:image/png;base64,iVBORw0KGgo='; @@ -59,3 +59,14 @@ test('late streaming snapshot cannot erase final response metadata', () => { metadata: finalMessage.metadata, }); }); + +test('reported zero token usage is not replaced with a text-length estimate', () => { + const messages: Message[] = [{ + id: 8, + role: 'ASSISTANT', + timestamp: '19:13:23', + contents: [{ type: 'TEXT', text: '这段回复有内容,但模型明确报告本轮 token 为零' }], + metadata: { totalTokens: 0 }, + }]; + assert.equal(sumReportedTokens(messages), 0); +}); -- Gitee From d55479bdf4f7f6caf994f27312f4f754da993166 Mon Sep 17 00:00:00 2001 From: bai <1145000687@qq.com> Date: Fri, 28 Aug 2026 18:02:57 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(desktop):=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=86=85=E7=BD=AECLI=20JAR=E5=B9=B6=E6=B7=BB=E5=8A=A0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=89=93=E5=8C=85=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改detect_launch_method函数优先使用安装包内置JAR文件 - 添加bundled_cli_jar_path函数获取资源目录中的JAR路径 - 更新start_backend函数获取Tauri应用资源目录 - 添加package和package:plan命令到package.json - 创建package-desktop.mjs脚本实现跨平台打包流程 - 添加Windows、macOS、Linux专用的Tauri配置文件 - 创建集成测试验证打包脚本功能 - 更新README文档说明新的打包方式和流程 - 移除对系统已安装CLI的优先查找逻辑 --- soloncode-desktop/README.md | 82 +++++++------- soloncode-desktop/package.json | 4 +- soloncode-desktop/scripts/package-desktop.mjs | 102 ++++++++++++++++++ soloncode-desktop/src-tauri/src/lib.rs | 60 +++++++++-- .../src-tauri/tauri.linux.conf.json | 9 ++ .../src-tauri/tauri.package.conf.json | 8 ++ .../src-tauri/tauri.windows.conf.json | 8 ++ .../tests/packageDesktop.test.ts | 84 +++++++++++++++ .../tests/versionDisplay.test.ts | 8 ++ 9 files changed, 315 insertions(+), 50 deletions(-) create mode 100644 soloncode-desktop/scripts/package-desktop.mjs create mode 100644 soloncode-desktop/src-tauri/tauri.linux.conf.json create mode 100644 soloncode-desktop/src-tauri/tauri.package.conf.json create mode 100644 soloncode-desktop/src-tauri/tauri.windows.conf.json create mode 100644 soloncode-desktop/tests/packageDesktop.test.ts create mode 100644 soloncode-desktop/tests/versionDisplay.test.ts diff --git a/soloncode-desktop/README.md b/soloncode-desktop/README.md index 7280a2c9..37b84c2b 100644 --- a/soloncode-desktop/README.md +++ b/soloncode-desktop/README.md @@ -101,7 +101,27 @@ npm run dev ## 构建发布 -### 完整构建流程 +### 推荐:统一打包脚本 + +在对应操作系统上执行: + +```bash +cd soloncode-desktop +npm install +npm run package -- windows # Windows,生成 MSI +npm run package -- mac # macOS,生成 APP 和 DMG +npm run package -- linux # Linux,生成 DEB 和 AppImage +``` + +不传平台时自动选择当前系统。脚本拒绝跨系统打包,并依次完成以下工作: + +1. 从当前源码构建 `soloncode-cli/target/soloncode-cli.jar`。 +2. 将 JAR 复制到 Tauri 打包资源目录。 +3. 构建前端、Rust 应用和当前平台安装包。 + +使用 `npm run package:plan -- windows` 可只查看步骤而不执行构建;将 `windows` 换为当前平台即可。 + +### 打包原理 ``` 步骤 1: 构建 CLI JAR 步骤 2: 构建桌面安装包 @@ -122,7 +142,7 @@ mvn clean package -DskipTests npm run tauri:build └── *.deb / *.AppImage (Linux) ``` -#### 1. 构建 CLI JAR +#### 1. 手动构建 CLI JAR ```bash cd soloncode-cli @@ -131,9 +151,9 @@ mvn clean package -DskipTests 构建产物:`target/soloncode-cli.jar` -#### 2. 安装 CLI +#### 2. 可选:单独安装 CLI -桌面版不打包 CLI 文件,需要预先安装 CLI 到用户目录: +统一打包脚本会把当前 JAR 放入桌面安装包。只有需要在终端中单独使用 CLI 时,才需要安装到用户目录: ```bash cd soloncode-cli/release @@ -151,7 +171,7 @@ bash install.sh ```bash cd soloncode-desktop -npm run tauri:build +npm run package -- windows ``` **macOS:** @@ -159,17 +179,11 @@ npm run tauri:build ```bash # 前置依赖 brew install node rust -npm install -g pnpm -# 构建 CLI JAR + 安装 -cd soloncode-cli -mvn clean package -DskipTests -cd release && bash install.sh && cd .. - -# 构建桌面安装包 -cd ../soloncode-desktop -pnpm install -pnpm tauri build +# 构建当前 CLI JAR 并生成 APP/DMG +cd soloncode-desktop +npm install +npm run package -- mac ``` **macOS Universal Binary(Apple Silicon + Intel):** @@ -213,28 +227,22 @@ x86_64 arm64 # 前置依赖 sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev -# 构建 CLI JAR + 安装 -cd soloncode-cli -mvn clean package -DskipTests -cd release && bash install.sh && cd .. - -# 构建桌面安装包 -cd ../soloncode-desktop -pnpm install -pnpm tauri build +# 构建当前 CLI JAR 并生成 DEB/AppImage +cd soloncode-desktop +npm install +npm run package -- linux ``` 默认架构的产物位于 `src-tauri/target/release/bundle/`;指定 `--target` 时,产物位于 `src-tauri/target//release/bundle/`: | 平台 | 格式 | 文件 | |------|------|------| -| Windows | MSI | `soloncode-desktop_0.1.0_x64_en-US.msi` | -| Windows | NSIS | `soloncode-desktop_0.1.0_x64-setup.exe` | -| macOS | DMG | `soloncode-desktop_0.1.0_x64.dmg` | +| Windows | MSI | `soloncode-desktop__x64_zh-CN.msi` | +| macOS | DMG | `soloncode-desktop__*.dmg` | | macOS | App | `soloncode-desktop.app` | | macOS Universal | DMG / App | `src-tauri/target/universal-apple-darwin/release/bundle/` | -| Linux | Deb | `soloncode-desktop_0.1.0_amd64.deb` | -| Linux | AppImage | `soloncode-desktop_0.1.0_amd64.AppImage` | +| Linux | Deb | `soloncode-desktop__amd64.deb` | +| Linux | AppImage | `soloncode-desktop__amd64.AppImage` | > **注意**:各操作系统的正式安装包应在对应系统上构建;macOS Universal Binary 必须在 macOS 上生成。如需自动化多平台构建,建议使用对应系统的 CI Runner。 @@ -245,20 +253,10 @@ pnpm tauri build ``` detect_launch_method() │ - ├── 1. 检查 PATH 中的 soloncode 命令 - │ → where soloncode (Windows) / which soloncode (Linux/macOS) - │ → 找到:powershell -ExecutionPolicy Bypass -File soloncode.ps1 serve - │ 或 soloncode serve - │ - ├── 2. 检查 ~/.soloncode/bin/ 中的启动脚本 - │ → Windows: soloncode.bat / soloncode.ps1 - │ → Linux/macOS: soloncode - │ → 找到:powershell -ExecutionPolicy Bypass -File