diff --git a/.gitignore b/.gitignore index dac0724b9189522d021fa6af0787eb28d99bf96c..61045b9907a81a0c97e447b9ac1a317191413afb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ target/ *.flattened-pom.xml *.md !soloncode-cli/docs/*.md +!soloncode-desktop/docs/*.md ### NetBeans ### nbproject/private/ @@ -47,3 +48,6 @@ soloncode-desktop/src-tauri/release/ soloncode-desktop/src-tauri/build/ soloncode-desktop/src-tauri/resources/ nul +nul +soloncode-desktop-web/node_modules +soloncode-desktop-web/dist diff --git a/.kiro/specs/desktop-settings-enhancement/.config.kiro b/.kiro/specs/desktop-settings-enhancement/.config.kiro deleted file mode 100644 index b320e90ce67eaaf6747fa6ed97c98e0b2d5ebe4f..0000000000000000000000000000000000000000 --- a/.kiro/specs/desktop-settings-enhancement/.config.kiro +++ /dev/null @@ -1,5 +0,0 @@ -{ - "specType": "feature", - "workflowType": "requirements-first", - "featureName": "desktop-settings-enhancement" -} diff --git a/.kiro/specs/model-switching/.config.kiro b/.kiro/specs/model-switching/.config.kiro deleted file mode 100644 index 5ffe4dc4adbd06c0839cb40a94db7e08a6ba2d72..0000000000000000000000000000000000000000 --- a/.kiro/specs/model-switching/.config.kiro +++ /dev/null @@ -1 +0,0 @@ -{"specId": "19b271d5-2946-4c1f-8339-4d803c6f8448", "workflowType": "requirements-first", "specType": "feature"} diff --git a/UPDATE_LOG.md b/UPDATE_LOG.md index 4098651fb7b6ecbd7baee4168b3289920c92c64c..1e09cca4821c5fb1653cf8fe29beb23846fdb1d4 100644 --- a/UPDATE_LOG.md +++ b/UPDATE_LOG.md @@ -1,5 +1,13 @@ +### v2026.9.26 + +* 优化 soloncode desktop 「自动编辑」模式完全免审批(write/edit/bash 一律直接放行,不再弹确认) +* 新增 soloncode desktop 「完全访问」执行模式(任何工具调用都不进入审批流程,全自动免打扰;执行模式现为四个:审批执行 / 自动编辑 / 完全访问 / 仅规划) +* 优化 soloncode desktop 编辑总结页文件列表行间距减半,多文件展示更紧凑 +* 修复 soloncode-cli MountSettingsControllerTest 因构造器变更导致模块测试无法编译的问题 +* 细节优化 + ### v2026.9.15 * 优化 soloncode web 浏览本地目录界面 for window 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 92cabc7c2a8c717fd3dba655bd4f50e71ddfb037..e75ef17f5f113fe9357ea52e7b4a719c9b6c4b7f 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 @@ -653,7 +653,7 @@ public class WsGate extends SimpleWebSocketListener { currentInput = "[规划模式 - 仅分析不执行任何操作] " + currentInput; } } else if ("auto".equals(mode)) { - // 自动编辑模式:文件编辑自动放行,shell 命令仍需审批 + // 自动编辑模式:文件编辑与命令执行均自动放行,无需审批 session.attrs().remove("_plan_mode"); } else { session.attrs().remove("_plan_mode"); @@ -1362,7 +1362,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; } // 未知或缺失模式按最严格的审批执行处理,避免客户端字段异常导致静默放行。 @@ -1371,8 +1371,12 @@ public class WsGate extends SimpleWebSocketListener { static boolean requiresDesktopApproval(String mode, String toolName) { String normalizedMode = normalizeDesktopRunMode(mode); + if ("auto".equals(normalizedMode) || "full".equals(normalizedMode)) { + // 自动编辑/完全访问模式:文件修改与命令执行均无需审批 + return false; + } if ("bash".equals(toolName)) { - return "default".equals(normalizedMode) || "auto".equals(normalizedMode); + return "default".equals(normalizedMode); } return "default".equals(normalizedMode) && ("write".equals(toolName) || "edit".equals(toolName)); 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 5c54be5fada04c82c15c54823d0e8264779b21a4..8b80ef629aecb482cec64682a126f5ee9e6ce5e4 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 @@ -57,7 +57,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()); @@ -71,7 +82,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()); } @@ -84,6 +97,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; + } + /** * 添加挂载池 */ @@ -371,4 +397,4 @@ public class MountSettingsController extends BaseSettingsController { break; } } -} \ No newline at end of file +} diff --git a/soloncode-cli/src/main/resources/app.yml b/soloncode-cli/src/main/resources/app.yml index 15a974c708d7cf142d80a94d17360952bc2e287e..4bcf9b23c8ad97cd77e3213a73a4e2bab8e91221 100644 --- a/soloncode-cli/src/main/resources/app.yml +++ b/soloncode-cli/src/main/resources/app.yml @@ -32,6 +32,47 @@ solon.logging.appender: maxHistory: "7" #单位:天 totalSizeCap: "200 MB" + +soloncode: + tools: "*,read,edit,bash" # 仅公域工具 + 文件读写 + workDir: "work" # 工作区目录 + maxSteps: 30 # ReAct 最大循环步数 + maxStepsAutoExtensible: true #最大步数自动续航(由 LLM 反思控制) + uiType: new + + sessionWindowSize: 8 # 会话历史窗口大小(即,新指令时使用几条历史消息) + + summaryWindowSize: 12 # 触发摘要压缩的消息条数阈值(即,工作上下文压缩时留几条消息不压缩) + summaryWindowToken: 15000 # 触发摘要压缩的内容长度阈值(即,工作上下文压缩时留多长内容不压缩) + + sandboxMode: true # 消盒模式,启用时禁止访问绝对路径 + thinkPrinted: true # 内心思考,是否打印 + + hitlEnabled: false # 是否启用人工审核危险操作,和 maxSteps 动态继步 + subagentEnabled: true # 是否启用子代理模式 + browserEnabled: true # 是否启用浏览器功能(比如去淘宝买东西) + + cliEnabled: true # 是否启用控制台 + cliPrintSimplified: true + + webEnabled: true # 是否启用 WebApi + webEndpoint: "/cli" + + acpEnabled: false # 是否启用 Acp 协议(一些支持 Acp 的 IDE 可以连接) + acpTransport: "websocket" # "stdio" or "websocket" + acpEndpoint: "/acp" + + wsEnabled: true + + modelRetries: 3 +# models: # llm 配置(参考 solon ai chatmodel 配置) +# - apiUrl: "https://open.bigmodel.cn/api/paas/v4/chat/completions" +# # apiUrl: "http://43.130.41.153:1279/v1/chat/completions" +# apiKey: "2d3699d64be147d5ae39ce410de2c956.1wgMvtcx8xjY1fSI" +# model: "glm-4.7" +# timeout: "120s" +# userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; SolonCode/2.0; +https://solon.noear.org/)" + solon: logging: logger: 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 0000000000000000000000000000000000000000..bfb8d5ecece4f0caeca3a6e3b48ee9cf639fb169 --- /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 0000000000000000000000000000000000000000..fceb2d3218daebb089a2017ae25cfee73183ede0 --- /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 0000000000000000000000000000000000000000..3b49f0e834cf5d871a4dc2a3b9f8b80c8bbf8092 --- /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 0000000000000000000000000000000000000000..18ffd566a84acc62c362b09155c3bf57e0c3da33 --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/desktop/WsGateRunModeTest.java @@ -0,0 +1,49 @@ +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 autoModeBypassesAllApprovals() { + assertFalse(WsGate.requiresDesktopApproval("auto", "write")); + assertFalse(WsGate.requiresDesktopApproval("auto", "edit")); + assertFalse(WsGate.requiresDesktopApproval("auto", "bash")); + assertFalse(WsGate.requiresDesktopApproval("auto", "read")); + } + + @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 0000000000000000000000000000000000000000..a557ebf97b07af0b1c2aa03bb866d077265dfc0d --- /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 0000000000000000000000000000000000000000..dceaafc4f84303ac83c4a4b9c61939a0aa9ad2bb --- /dev/null +++ b/soloncode-cli/src/test/java/org/noear/solon/codecli/portal/web/settings/MountSettingsControllerTest.java @@ -0,0 +1,86 @@ +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.codecli.workspace.WorkspaceContext; +import org.noear.solon.codecli.workspace.WorkspaceManager; +import org.noear.solon.codecli.workspace.WorkspaceMeta; +import org.noear.solon.core.handle.Result; + +import java.nio.file.Files; +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() throws Exception { + 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()); + + // 预建挂载目录:realPath 解析依赖目录存在,否则去重键退化为别名 + Files.createDirectories(tempDir.resolve("home/.soloncode/skills")); + Files.createDirectories(tempDir.resolve("workspace/.soloncode/skills")); + + 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(); + + // 直接构建工作区上下文并覆写 getOrCreate,绕开需要 Solon 运行时的完整启动链 + WorkspaceContext workspaceContext = new WorkspaceContext( + new WorkspaceMeta("default", "workspace", + tempDir.resolve("workspace").toString(), System.currentTimeMillis(), true), + engine, null, null, null, null, null, null, new AgentSettings()); + + WorkspaceManager workspaceManager = new WorkspaceManager(new AgentSettings()) { + @Override + public WorkspaceContext getOrCreate(String workspaceIdOrPath) { + return workspaceContext; + } + }; + + controller = new MountSettingsController(workspaceManager); + } + + @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/README.md b/soloncode-desktop/README.md index 7280a2c9618124ec11d35452d063fd26f84985de..7c864c711c2eb009ee4a74f945048858051e9e0c 100644 --- a/soloncode-desktop/README.md +++ b/soloncode-desktop/README.md @@ -2,6 +2,8 @@ AI 驱动的桌面编程助手,基于 Tauri 2.0 + React 18 构建。前端通过 HTTP/WebSocket 连接后端 CLI 服务,实现 AI 对话、代码编辑、Git 管理等功能。 +版本更新:[SolonCode Desktop 26.7.30:会话、执行与连接体验更新](docs/soloncode-desktop-26.7.30.md) + ## 架构 ``` @@ -101,7 +103,36 @@ npm run dev ## 构建发布 -### 完整构建流程 +### 推荐:统一打包脚本 + +在对应操作系统上执行: + +```bash +cd soloncode-desktop +npm install +npm run package -- windows # Windows,生成 NSIS EXE +npm run package -- mac # macOS,生成 APP、DMG 和 PKG +npm run package -- linux # Linux,生成 DEB 和 AppImage +``` + +不传平台时自动选择当前系统。脚本拒绝跨系统打包,并依次完成以下工作: + +1. 从当前源码构建 `soloncode-cli/target/soloncode-cli.jar`。 +2. 将 JAR 复制到 Tauri 打包资源目录。 +3. 根据 `src-tauri/tauri.conf.json` 的桌面版本生成当前平台安装钩子。 +4. 构建前端、Rust 应用和当前平台安装包。 + +安装器会在安装阶段将内置 JAR 复制到: + +```text +~/.soloncode/bin/soloncode-cli_<桌面版本号>.jar +``` + +例如桌面版本为 `26.8.11` 时,目标文件是 `~/.soloncode/bin/soloncode-cli_26.8.11.jar`。复制失败会使安装失败;重新安装同一版本会覆盖同名文件,但不会删除旧版本 JAR。 + +使用 `npm run package:plan -- windows` 可只查看步骤而不执行构建;将 `windows` 换为当前平台即可。 + +### 打包原理 ``` 步骤 1: 构建 CLI JAR 步骤 2: 构建桌面安装包 @@ -117,12 +148,12 @@ mvn clean package -DskipTests npm run tauri:build │ ▼ 生成安装包: - ├── *.msi / *.exe (Windows) - ├── *.dmg / *.app (macOS) + ├── *.exe (Windows NSIS) + ├── *.pkg / *.dmg / *.app (macOS) └── *.deb / *.AppImage (Linux) ``` -#### 1. 构建 CLI JAR +#### 1. 手动构建 CLI JAR ```bash cd soloncode-cli @@ -131,9 +162,9 @@ mvn clean package -DskipTests 构建产物:`target/soloncode-cli.jar` -#### 2. 安装 CLI +#### 2. 可选:单独安装 CLI -桌面版不打包 CLI 文件,需要预先安装 CLI 到用户目录: +统一打包脚本会把当前 JAR 放入桌面安装包。只有需要在终端中单独使用 CLI 时,才需要安装到用户目录: ```bash cd soloncode-cli/release @@ -151,7 +182,7 @@ bash install.sh ```bash cd soloncode-desktop -npm run tauri:build +npm run package -- windows ``` **macOS:** @@ -159,17 +190,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/PKG +cd soloncode-desktop +npm install +npm run package -- mac ``` **macOS Universal Binary(Apple Silicon + Intel):** @@ -213,30 +238,27 @@ 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 | NSIS | `soloncode-desktop__x64-setup.exe` | +| macOS | PKG | `soloncode-desktop__.pkg` | +| 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。 +> +> 只有 Windows NSIS、macOS PKG 和 Linux DEB 具有安装阶段,会复制版本化 JAR。DMG、APP 和 AppImage 是便携产物,不执行安装钩子。Linux DEB 安装时必须能从 `SUDO_USER` 或 `PKEXEC_UID` 确定桌面用户,否则安装会终止。 ### 后端启动流程 @@ -245,20 +267,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