mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(workspace): default storage root setting + desktop local-tools whitelist management (#512)
- Settings → System gains a 'default workspace storage path' item: validated on save (absolute, creatable), applied immediately without restart, and re-applied from the database on startup. Blank clears the override; existing data is never migrated. - Desktop local file/command tools get a renderer settings page (allowed directory list with per-row delete, add via native picker, enable toggle, tunnel status); the native dialog additionally gains a 'remove directory' flow, fixing the whitelist that could only grow. - System settings save surfaces backend validation errors as a toast.
This commit is contained in:
parent
cf43294a9e
commit
f0dcc44fef
@ -855,12 +855,17 @@ async function showLocalToolsSettings(): Promise<void> {
|
||||
].join('\n')
|
||||
|
||||
const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
const hasDirs = cfg.allowedDirs.length > 0
|
||||
const buttons = hasDirs
|
||||
? ['关闭', '添加目录…', '移除目录…', cfg.enabled ? '停用' : '启用']
|
||||
: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用']
|
||||
const toggleId = buttons.length - 1
|
||||
const opts = {
|
||||
type: 'info' as const,
|
||||
title: '本地工具设置',
|
||||
message: '本地文件/命令工具',
|
||||
detail,
|
||||
buttons: ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'],
|
||||
buttons,
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
@ -871,13 +876,39 @@ async function showLocalToolsSettings(): Promise<void> {
|
||||
|
||||
if (res.response === 1) {
|
||||
await pickAllowedDirectory()
|
||||
} else if (res.response === 2) {
|
||||
} else if (hasDirs && res.response === 2) {
|
||||
await pickDirectoryToRemove(cfg.allowedDirs)
|
||||
} else if (res.response === toggleId) {
|
||||
const saved = saveLocalToolsConfig({ enabled: !cfg.enabled })
|
||||
if (saved.enabled && backendReady) localBridge.start()
|
||||
else if (!saved.enabled) localBridge.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Second-level picker for removing a whitelisted directory: native dialogs
|
||||
// cannot render per-item delete controls, so each directory becomes a button.
|
||||
async function pickDirectoryToRemove(dirs: string[]): Promise<void> {
|
||||
const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
const opts = {
|
||||
type: 'question' as const,
|
||||
title: '移除目录',
|
||||
message: '选择要从白名单移除的目录',
|
||||
detail: '移除后,本地文件/命令工具将无法再访问该目录。',
|
||||
buttons: ['取消', ...dirs],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
}
|
||||
const res = parent
|
||||
? await dialog.showMessageBox(parent, opts)
|
||||
: await dialog.showMessageBox(opts)
|
||||
if (res.response === 0) return
|
||||
|
||||
const dir = dirs[res.response - 1]
|
||||
const cfg = loadLocalToolsConfig()
|
||||
saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir) })
|
||||
}
|
||||
|
||||
async function menuCheckForUpdates(): Promise<void> {
|
||||
if (!app.isPackaged) {
|
||||
dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' })
|
||||
|
||||
@ -10,6 +10,13 @@ public class SystemSettingsDTO {
|
||||
private Boolean debugMode;
|
||||
private Boolean stateGraphEnabled;
|
||||
|
||||
/**
|
||||
* Default workspace storage root (global fallback sandbox root). Empty
|
||||
* string = not overridden, fall back to the yml/env configuration. Null on
|
||||
* save = field not submitted (partial payloads keep the stored value).
|
||||
*/
|
||||
private String workspaceStorageRoot;
|
||||
|
||||
// ===== 搜索服务配置 =====
|
||||
private Boolean searchEnabled;
|
||||
/** serper / tavily */
|
||||
|
||||
@ -1,20 +1,31 @@
|
||||
package vip.mate.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.plugin.PluginManager;
|
||||
import vip.mate.system.model.SearchProviderCatalogEntry;
|
||||
import vip.mate.system.model.SearchProviderCatalogResponse;
|
||||
import vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
import vip.mate.tool.search.SearchProvider;
|
||||
import vip.mate.tool.search.SearchProviderRegistry;
|
||||
import vip.mate.workspace.core.config.WorkspaceSandboxProperties;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SystemSettingService {
|
||||
|
||||
@ -78,6 +89,14 @@ public class SystemSettingService {
|
||||
private static final String DEFAULT_VISION_MODEL_KEY = "default.vision_model";
|
||||
private static final String DEFAULT_VIDEO_MODEL_KEY = "default.video_model";
|
||||
|
||||
/**
|
||||
* Default workspace storage root override. When set, it replaces the
|
||||
* yml/env-configured {@code mateclaw.workspace.sandbox.root} as the global
|
||||
* fallback sandbox root for conversations without a per-workspace base
|
||||
* path. Empty string means "not overridden" (fall back to yml/env).
|
||||
*/
|
||||
private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root";
|
||||
|
||||
private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey";
|
||||
private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl";
|
||||
private static final String FAL_API_KEY_KEY = "falApiKey";
|
||||
@ -100,6 +119,7 @@ public class SystemSettingService {
|
||||
private final SystemSettingMapper systemSettingMapper;
|
||||
private final SearchProviderRegistry searchProviderRegistry;
|
||||
private final SettingCrypto settingCrypto;
|
||||
private final WorkspaceSandboxProperties workspaceSandboxProperties;
|
||||
|
||||
/**
|
||||
* {@code PluginManager} is injected lazily because the bean graph is
|
||||
@ -118,10 +138,12 @@ public class SystemSettingService {
|
||||
public SystemSettingService(SystemSettingMapper systemSettingMapper,
|
||||
SearchProviderRegistry searchProviderRegistry,
|
||||
SettingCrypto settingCrypto,
|
||||
WorkspaceSandboxProperties workspaceSandboxProperties,
|
||||
@Lazy PluginManager pluginManager) {
|
||||
this.systemSettingMapper = systemSettingMapper;
|
||||
this.searchProviderRegistry = searchProviderRegistry;
|
||||
this.settingCrypto = settingCrypto;
|
||||
this.workspaceSandboxProperties = workspaceSandboxProperties;
|
||||
this.pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
@ -210,6 +232,9 @@ public class SystemSettingService {
|
||||
// Multimodal sidecar routing — empty string means "not configured"
|
||||
dto.setDefaultVisionModelId(parseIdOrNull(getValue(DEFAULT_VISION_MODEL_KEY, "")));
|
||||
dto.setDefaultVideoModelId(parseIdOrNull(getValue(DEFAULT_VIDEO_MODEL_KEY, "")));
|
||||
|
||||
// Default workspace storage root — empty string means "not overridden"
|
||||
dto.setWorkspaceStorageRoot(getValue(WORKSPACE_STORAGE_ROOT_KEY, ""));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@ -461,9 +486,90 @@ public class SystemSettingService {
|
||||
String.valueOf(dto.getDefaultVideoModelId()),
|
||||
"Default video-capable model id (mate_model_config.id) for sidecar routing");
|
||||
}
|
||||
|
||||
// Default workspace storage root. null = field not submitted (partial
|
||||
// save from an unrelated settings page); blank = explicit clear, fall
|
||||
// back to the yml/env-configured sandbox root. Applied immediately —
|
||||
// no restart required. Only affects newly created files; existing data
|
||||
// is never migrated.
|
||||
if (dto.getWorkspaceStorageRoot() != null) {
|
||||
String root = dto.getWorkspaceStorageRoot().trim();
|
||||
if (!root.isEmpty()) {
|
||||
validateWorkspaceStorageRoot(root);
|
||||
}
|
||||
saveValue(WORKSPACE_STORAGE_ROOT_KEY, root, "默认工作空间存储路径(全局兜底沙箱根,空=使用配置文件默认值)");
|
||||
applyWorkspaceStorageRoot(root);
|
||||
}
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a storage root that could never work: relative paths (the guard
|
||||
* needs a stable absolute boundary) and paths that cannot be created.
|
||||
*/
|
||||
private void validateWorkspaceStorageRoot(String root) {
|
||||
Path path;
|
||||
try {
|
||||
path = Paths.get(root);
|
||||
} catch (InvalidPathException e) {
|
||||
throw new MateClawException("err.settings.storage_root_invalid", 400,
|
||||
"Invalid storage path: " + e.getMessage());
|
||||
}
|
||||
if (!path.isAbsolute()) {
|
||||
throw new MateClawException("err.settings.storage_root_not_absolute", 400,
|
||||
"Storage path must be absolute: " + root);
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(path);
|
||||
} catch (Exception e) {
|
||||
throw new MateClawException("err.settings.storage_root_create_failed", 400,
|
||||
"Cannot create storage directory " + root + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the effective global fallback sandbox root with
|
||||
* {@link WorkspacePathGuard}. Priority: DB override > yml/env > built-in
|
||||
* default. A blank override restores the yml/env-configured behaviour,
|
||||
* including the {@code enabled=false} escape hatch.
|
||||
*/
|
||||
private void applyWorkspaceStorageRoot(String override) {
|
||||
if (override == null || override.isBlank()) {
|
||||
if (workspaceSandboxProperties.isEnabled()) {
|
||||
Path root = Paths.get(workspaceSandboxProperties.getRoot()).toAbsolutePath().normalize();
|
||||
WorkspacePathGuard.setDefaultRoot(root.toString());
|
||||
} else {
|
||||
WorkspacePathGuard.setDefaultRoot(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Path root = Paths.get(override).toAbsolutePath().normalize();
|
||||
WorkspacePathGuard.setDefaultRoot(root.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a persisted storage-root override once the database is ready.
|
||||
* Startup registration order: WorkspaceSandboxAutoConfiguration registers
|
||||
* the yml/env root at context construction, then this listener overrides
|
||||
* it with the DB value when one is set.
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void applyPersistedWorkspaceStorageRoot() {
|
||||
String root = getValue(WORKSPACE_STORAGE_ROOT_KEY, "");
|
||||
if (root == null || root.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(Paths.get(root));
|
||||
} catch (Exception e) {
|
||||
// Registering the root still tightens the boundary even if the
|
||||
// directory can't be pre-created; log and continue.
|
||||
log.warn("[SystemSetting] Failed to create workspace storage root {}: {}", root, e.getMessage());
|
||||
}
|
||||
applyWorkspaceStorageRoot(root);
|
||||
log.info("[SystemSetting] Workspace storage root override applied: {}", root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated update path for the multimodal sidecar configuration.
|
||||
* <p>
|
||||
|
||||
@ -330,3 +330,8 @@ chat.stopMarker.userAborted=[\u5df2\u88ab\u7528\u6237\u4e2d\u6b62]
|
||||
tool.send_file.error.too_large=\u6587\u4ef6\u8fc7\u5927\uff1a{0}MB \u8d85\u51fa\u9650\u5236 {1}MB
|
||||
tool.send_file.error.failed=\u53d1\u9001\u6587\u4ef6\u5931\u8d25\uff1a{0}
|
||||
tool.send_file.success={0} \u5df2\u53d1\u9001\uff1a[{0}]({1})\uff08\u94fe\u63a5 10 \u5206\u949f\u5185\u6709\u6548\uff09\u3002\n\u91cd\u8981\uff1a\u56de\u7b54\u7528\u6237\u65f6**\u5fc5\u987b**\u4f7f\u7528\u4e0a\u8ff0\u76f8\u5bf9\u8def\u5f84 `{1}`\uff0c**\u4e0d\u8981**\u6dfb\u52a0\u4efb\u4f55 https://\u3001http:// \u57df\u540d\u524d\u7f00\uff0c\u524d\u7aef\u4f1a\u81ea\u52a8\u62fc\u63a5\u5f53\u524d\u4e3b\u673a\u3002
|
||||
|
||||
# --- Settings: workspace storage root ---
|
||||
err.settings.storage_root_invalid=\u5b58\u50a8\u8def\u5f84\u65e0\u6548
|
||||
err.settings.storage_root_not_absolute=\u5b58\u50a8\u8def\u5f84\u5fc5\u987b\u4e3a\u7edd\u5bf9\u8def\u5f84
|
||||
err.settings.storage_root_create_failed=\u65e0\u6cd5\u521b\u5efa\u5b58\u50a8\u76ee\u5f55\uff0c\u8bf7\u68c0\u67e5\u8def\u5f84\u4e0e\u5199\u5165\u6743\u9650
|
||||
|
||||
@ -337,3 +337,8 @@ chat.stopMarker.userAborted=[Stopped by user]
|
||||
tool.send_file.error.too_large=File too large: {0}MB exceeds limit of {1}MB
|
||||
tool.send_file.error.failed=Failed to send file: {0}
|
||||
tool.send_file.success={0} sent: [{0}]({1}) (link valid for 10 minutes).\nIMPORTANT: when replying to the user you **must** use the relative path `{1}` exactly; do **not** prepend any https:// or http:// host. The frontend will prepend the current host automatically.
|
||||
|
||||
# --- Settings: workspace storage root ---
|
||||
err.settings.storage_root_invalid=Invalid storage path
|
||||
err.settings.storage_root_not_absolute=Storage path must be an absolute path
|
||||
err.settings.storage_root_create_failed=Cannot create the storage directory; check the path and write permission
|
||||
|
||||
@ -14,6 +14,7 @@ import vip.mate.plugin.PluginManager;
|
||||
import vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import vip.mate.tool.search.SearchProviderRegistry;
|
||||
import vip.mate.workspace.core.config.WorkspaceSandboxProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -47,7 +48,7 @@ class SystemSettingBoolApiTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()),
|
||||
new SettingCrypto("test-key"), mock(PluginManager.class));
|
||||
new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), mock(PluginManager.class));
|
||||
}
|
||||
|
||||
private SystemSettingEntity row(String value) {
|
||||
|
||||
@ -17,6 +17,7 @@ import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import vip.mate.tool.search.SearchProvider;
|
||||
import vip.mate.tool.search.SearchProviderRegistry;
|
||||
import vip.mate.workspace.core.config.WorkspaceSandboxProperties;
|
||||
import vip.mate.tool.search.SearchResult;
|
||||
|
||||
import java.util.List;
|
||||
@ -68,7 +69,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("marks builtin providers as builtin=true with no pluginName")
|
||||
void builtinEntry() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -86,7 +87,7 @@ class SystemSettingServiceCatalogTest {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of());
|
||||
registry.registerPluginProvider(stub("my-search", 500, true, true));
|
||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -105,7 +106,7 @@ class SystemSettingServiceCatalogTest {
|
||||
stub("duckduckgo", 100, false, true)));
|
||||
registry.registerPluginProvider(stub("my-search", 200, true, true));
|
||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -128,7 +129,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("surfaces the resolved provider id and source alongside the catalog")
|
||||
void resolvedSurfaced() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true)));
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -140,7 +141,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("resolvedId/resolvedSource are null when no provider is available at all")
|
||||
void resolvedNullWhenNothingAvailable() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), new WorkspaceSandboxProperties(), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
package vip.mate.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.plugin.PluginManager;
|
||||
import vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
import vip.mate.tool.search.SearchProviderRegistry;
|
||||
import vip.mate.workspace.core.config.WorkspaceSandboxProperties;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Covers the default workspace storage root setting: validation, immediate
|
||||
* registration with {@link WorkspacePathGuard}, clearing back to the yml/env
|
||||
* default, and the partial-payload (null) no-op contract.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SystemSettingWorkspaceStorageRootTest {
|
||||
|
||||
@Mock
|
||||
private SystemSettingMapper mapper;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private WorkspaceSandboxProperties sandboxProperties;
|
||||
private SystemSettingService service;
|
||||
private final Map<String, String> store = new HashMap<>();
|
||||
private Path originalDefaultRoot;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
SystemSettingEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
originalDefaultRoot = WorkspacePathGuard.getDefaultRoot();
|
||||
sandboxProperties = new WorkspaceSandboxProperties();
|
||||
sandboxProperties.setRoot(tempDir.resolve("yml-default").toString());
|
||||
service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()),
|
||||
new SettingCrypto("test-key"), sandboxProperties, mock(PluginManager.class));
|
||||
|
||||
// Back the mapper with an in-memory map so saveValue/getValue round-trip.
|
||||
store.clear();
|
||||
lenient().when(mapper.selectOne(any())).thenAnswer(inv -> null);
|
||||
lenient().when(mapper.selectList(any())).thenAnswer(inv -> List.of());
|
||||
lenient().when(mapper.insert(any(SystemSettingEntity.class))).thenAnswer(inv -> {
|
||||
SystemSettingEntity e = inv.getArgument(0);
|
||||
store.put(e.getSettingKey(), e.getSettingValue());
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreGuard() {
|
||||
WorkspacePathGuard.setDefaultRoot(originalDefaultRoot == null ? null : originalDefaultRoot.toString());
|
||||
}
|
||||
|
||||
private SystemSettingsDTO dtoWithRoot(String root) {
|
||||
SystemSettingsDTO dto = new SystemSettingsDTO();
|
||||
dto.setWorkspaceStorageRoot(root);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("relative path is rejected")
|
||||
void relativePathRejected() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.saveSettings(dtoWithRoot("data/workspace")));
|
||||
assertEquals("err.settings.storage_root_not_absolute", ex.getMsgKey());
|
||||
assertNull(store.get("workspace.storage_root"),
|
||||
"the storage root must not be persisted on validation failure");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absolute path is persisted and registered immediately")
|
||||
void absolutePathApplied() {
|
||||
Path newRoot = tempDir.resolve("custom-root");
|
||||
service.saveSettings(dtoWithRoot(newRoot.toString()));
|
||||
|
||||
assertEquals(newRoot.toString(), store.get("workspace.storage_root"));
|
||||
assertEquals(newRoot.toAbsolutePath().normalize(), WorkspacePathGuard.getDefaultRoot());
|
||||
assertTrue(newRoot.toFile().isDirectory(), "directory should be created on save");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank value clears the override and falls back to the yml root")
|
||||
void blankClearsOverride() {
|
||||
service.saveSettings(dtoWithRoot(tempDir.resolve("custom-root").toString()));
|
||||
service.saveSettings(dtoWithRoot(""));
|
||||
|
||||
assertEquals("", store.get("workspace.storage_root"));
|
||||
assertEquals(Paths.get(sandboxProperties.getRoot()).toAbsolutePath().normalize(),
|
||||
WorkspacePathGuard.getDefaultRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank value with sandbox disabled clears the guard entirely")
|
||||
void blankWithSandboxDisabled() {
|
||||
sandboxProperties.setEnabled(false);
|
||||
service.saveSettings(dtoWithRoot(""));
|
||||
assertNull(WorkspacePathGuard.getDefaultRoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null (field not submitted) leaves the stored value untouched")
|
||||
void nullFieldIsNoOp() {
|
||||
service.saveSettings(new SystemSettingsDTO());
|
||||
assertNull(store.get("workspace.storage_root"));
|
||||
}
|
||||
}
|
||||
@ -751,6 +751,7 @@ export default {
|
||||
advanced: 'Advanced',
|
||||
skillCurator: 'Skill Curator',
|
||||
proxy: 'Network Proxy',
|
||||
localTools: 'Local Tools',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -1045,6 +1046,7 @@ export default {
|
||||
language: 'Language',
|
||||
streamEnabled: 'Stream Response',
|
||||
debugMode: 'Debug Mode',
|
||||
workspaceStorageRoot: 'Default Workspace Storage Path',
|
||||
searchEnabled: 'Enable Search',
|
||||
searchProvider: 'Search Provider',
|
||||
searchFallbackEnabled: 'Fallback on Failure',
|
||||
@ -1096,6 +1098,8 @@ export default {
|
||||
language: 'Interface language preference stored in backend settings.',
|
||||
streamEnabled: 'Controls whether chat prefers streaming output in UI settings.',
|
||||
debugMode: 'Reserved for showing more execution details later.',
|
||||
workspaceStorageRoot: 'Files of new conversations and workspaces are stored under this path (the global fallback directory). Takes effect immediately and never migrates existing data; leave blank to use the server default. Must be an absolute path.',
|
||||
workspaceStorageRootPlaceholder: 'Leave blank for the server default, e.g. /data/mateclaw/workspace',
|
||||
searchEnabled: 'When disabled, the search tool will be unavailable to agents.',
|
||||
searchProvider: 'Primary search provider used when the search tool is invoked.',
|
||||
searchFallbackEnabled: 'Automatically try the other provider when the primary one fails.',
|
||||
@ -1175,6 +1179,28 @@ export default {
|
||||
searchGoToPlugins: 'Go to Plugins →',
|
||||
proxyTitle: 'Network Proxy',
|
||||
proxyDesc: 'Configure a global HTTP / SOCKS proxy for all outbound requests (LLM APIs, web search, channel bridging, etc.). For environments that cannot reach overseas APIs directly or require a unified egress.',
|
||||
localToolsTitle: 'Local Tools',
|
||||
localToolsDesc: 'Manage the desktop local file/command tools: master switch, tunnel status, and the allowed-directory whitelist.',
|
||||
localTools: {
|
||||
desktopOnly: 'Local tools are only available inside the desktop client. Open this page in the MateClaw desktop app to manage them.',
|
||||
enableLabel: 'Enable local tools',
|
||||
enableHint: 'When on, agents can reach local file and command tools through the tunnel, constrained to the directory whitelist below.',
|
||||
tunnelLabel: 'Tunnel status',
|
||||
tunnelHint: 'Connection state of the local-tools channel between the desktop and the server.',
|
||||
tunnelConnected: 'Connected',
|
||||
tunnelDisconnected: 'Disconnected',
|
||||
dirsLabel: 'Allowed directories',
|
||||
dirsHint: 'Local file operations are confined to the directories below. Removal takes effect immediately.',
|
||||
addDir: 'Add directory',
|
||||
emptyFailClosed: 'No directories configured — all local file access is currently denied by default.',
|
||||
emptyFailOpen: 'No directories configured — the entire local filesystem is currently accessible by default.',
|
||||
removeTitle: 'Remove directory',
|
||||
removeConfirm: 'Remove {dir} from the whitelist? Agents will no longer be able to access it.',
|
||||
removeSuccess: 'Directory removed',
|
||||
addSuccess: 'Directory added',
|
||||
loadFail: 'Failed to load local tools configuration',
|
||||
saveFail: 'Operation failed, please retry',
|
||||
},
|
||||
proxy: {
|
||||
enableLabel: 'Enable proxy',
|
||||
enableHint: 'Global switch. When on, the outbound connections below all route through the proxy.',
|
||||
|
||||
@ -613,6 +613,7 @@ export default {
|
||||
advanced: '高级',
|
||||
skillCurator: '技能管家',
|
||||
proxy: '网络代理',
|
||||
localTools: '本地工具',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -907,6 +908,7 @@ export default {
|
||||
language: '界面语言',
|
||||
streamEnabled: '流式响应',
|
||||
debugMode: '调试模式',
|
||||
workspaceStorageRoot: '默认工作空间存储路径',
|
||||
searchEnabled: '启用搜索',
|
||||
searchProvider: '搜索提供商',
|
||||
searchFallbackEnabled: '失败回退',
|
||||
@ -964,6 +966,8 @@ export default {
|
||||
language: '界面语言会持久化到后端设置中。',
|
||||
streamEnabled: '用于控制前端默认流式响应偏好。',
|
||||
debugMode: '预留给后续执行明细展示。',
|
||||
workspaceStorageRoot: '新建会话、工作空间的文件将存放在该路径下(作为全局兜底目录)。修改后立即生效,不影响已有数据;留空则使用服务端默认位置。必须为绝对路径。',
|
||||
workspaceStorageRootPlaceholder: '留空使用服务端默认位置,例如 /data/mateclaw/workspace',
|
||||
searchEnabled: '关闭后搜索工具将不可用,Agent 无法联网搜索。',
|
||||
searchProvider: '选择主搜索提供商,调用搜索工具时优先使用。',
|
||||
searchFallbackEnabled: '主提供商调用失败时,自动回退到另一个提供商。',
|
||||
@ -1049,6 +1053,28 @@ export default {
|
||||
searchGoToPlugins: '前往插件页 →',
|
||||
proxyTitle: '网络代理',
|
||||
proxyDesc: '为所有出站请求(LLM API、网页搜索、频道桥接等)配置全局 HTTP / SOCKS 代理。适用于无法直连海外 API、或要求统一出口的网络环境。',
|
||||
localToolsTitle: '本地工具',
|
||||
localToolsDesc: '管理桌面端本地文件/命令工具:启停开关、隧道状态与允许访问的目录白名单。',
|
||||
localTools: {
|
||||
desktopOnly: '本地工具仅在桌面客户端内可用。请在 MateClaw 桌面应用中打开此页面进行管理。',
|
||||
enableLabel: '启用本地工具',
|
||||
enableHint: '开启后,智能体可通过隧道访问本机的文件与命令工具(受下方目录白名单约束)。',
|
||||
tunnelLabel: '隧道状态',
|
||||
tunnelHint: '桌面端与服务器之间的本地工具通道连接状态。',
|
||||
tunnelConnected: '已连接',
|
||||
tunnelDisconnected: '未连接',
|
||||
dirsLabel: '允许访问的目录',
|
||||
dirsHint: '本机文件操作只能落在以下目录内。删除立即生效。',
|
||||
addDir: '添加目录',
|
||||
emptyFailClosed: '未配置任何目录 — 当前默认拒绝所有本地文件访问。',
|
||||
emptyFailOpen: '未配置任何目录 — 当前默认允许访问整个本地文件系统。',
|
||||
removeTitle: '移除目录',
|
||||
removeConfirm: '确定将 {dir} 从白名单中移除?移除后智能体将无法再访问该目录。',
|
||||
removeSuccess: '目录已移除',
|
||||
addSuccess: '目录已添加',
|
||||
loadFail: '读取本地工具配置失败',
|
||||
saveFail: '操作失败,请重试',
|
||||
},
|
||||
proxy: {
|
||||
enableLabel: '启用代理',
|
||||
enableHint: '全局开关。开启后,下方各类出站连接统一走代理。',
|
||||
|
||||
@ -245,6 +245,15 @@ const router = createRouter({
|
||||
component: () => import('@/views/Settings/Proxy/index.vue'),
|
||||
meta: { title: 'Settings - Proxy', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
// Desktop-only: local file/shell tool whitelist management. The
|
||||
// page itself degrades to a notice when the desktop bridge is
|
||||
// absent (plain browser access).
|
||||
path: 'local-tools',
|
||||
name: 'SettingsLocalTools',
|
||||
component: () => import('@/views/Settings/LocalTools/index.vue'),
|
||||
meta: { title: 'Settings - Local Tools', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints (External coding agents)
|
||||
{
|
||||
path: 'acp',
|
||||
|
||||
35
mateclaw-ui/src/types/desktop.d.ts
vendored
Normal file
35
mateclaw-ui/src/types/desktop.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
// Bridge API exposed by the desktop shell's preload script. Present only when
|
||||
// the SPA runs inside the desktop app; always access via optional chaining.
|
||||
export interface LocalToolsConfig {
|
||||
// Master switch for the local file/shell tool proxy.
|
||||
enabled: boolean
|
||||
// Directories the agent may touch through the local tools tunnel.
|
||||
allowedDirs: string[]
|
||||
// Policy when allowedDirs is empty: true = deny all, false = allow all.
|
||||
failClosed: boolean
|
||||
}
|
||||
|
||||
export interface LocalToolsState extends LocalToolsConfig {
|
||||
// Whether the tunnel to the backend is currently connected.
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface MateClawDesktopAPI {
|
||||
getPlatform: () => Promise<string>
|
||||
getVersion: () => Promise<string>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
|
||||
getLocalToolsConfig: () => Promise<LocalToolsState>
|
||||
setLocalToolsConfig: (patch: Partial<LocalToolsConfig>) => Promise<LocalToolsConfig>
|
||||
// Opens a native folder picker; `added` is null when the user cancels.
|
||||
addLocalToolsDir: () => Promise<LocalToolsConfig & { added: string | null }>
|
||||
removeLocalToolsDir: (dir: string) => Promise<LocalToolsConfig>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mateClawAPI?: MateClawDesktopAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@ -808,6 +808,8 @@ export interface SystemSettings {
|
||||
language: 'zh-CN' | 'en-US'
|
||||
streamEnabled: boolean
|
||||
debugMode: boolean
|
||||
// Default workspace storage root; '' = use the server-side default
|
||||
workspaceStorageRoot?: string
|
||||
// 搜索服务配置
|
||||
searchEnabled: boolean
|
||||
searchProvider: 'serper' | 'tavily'
|
||||
|
||||
@ -55,6 +55,9 @@ import { useI18n } from 'vue-i18n'
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
// The desktop preload bridge marks that we run inside the desktop shell.
|
||||
const isDesktop = typeof window !== 'undefined' && !!window.mateClawAPI
|
||||
|
||||
// Routes that benefit from extra editor width — the sub-nav auto-collapses
|
||||
// to a 56px rail unless the user has explicitly toggled it open.
|
||||
const COMPACT_ROUTES = ['/settings/workflows']
|
||||
@ -195,6 +198,16 @@ const sections = computed(() => [
|
||||
label: t('settings.sections.proxy', '网络代理'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||||
},
|
||||
// Desktop-only entry: the local tools bridge exists only inside the
|
||||
// desktop shell, so hide the nav item in plain browsers.
|
||||
...(isDesktop
|
||||
? [{
|
||||
id: 'local-tools',
|
||||
path: '/settings/local-tools',
|
||||
label: t('settings.sections.localTools', '本地工具'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||||
}]
|
||||
: []),
|
||||
// RFC-090 Phase 7: ACP endpoints
|
||||
{
|
||||
id: 'acp',
|
||||
|
||||
191
mateclaw-ui/src/views/Settings/LocalTools/index.vue
Normal file
191
mateclaw-ui/src/views/Settings/LocalTools/index.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ t('settings.localToolsTitle') }}</h2>
|
||||
<p class="section-desc">{{ t('settings.localToolsDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Shown when the SPA is opened in a plain browser: the bridge only
|
||||
exists inside the desktop shell, so there is nothing to manage. -->
|
||||
<div v-if="!isDesktop" class="settings-card">
|
||||
<div class="empty-note">{{ t('settings.localTools.desktopOnly') }}</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.localTools.enableLabel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.localTools.enableHint') }}</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="state.enabled" :disabled="busy" @change="onToggleEnabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.localTools.tunnelLabel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.localTools.tunnelHint') }}</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<span class="status-chip" :class="{ ok: state.connected }">
|
||||
<span class="dot"></span>
|
||||
{{ state.connected ? t('settings.localTools.tunnelConnected') : t('settings.localTools.tunnelDisconnected') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card dirs-card">
|
||||
<div class="dirs-header">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.localTools.dirsLabel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.localTools.dirsHint') }}</div>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="busy" @click="onAddDir">
|
||||
{{ t('settings.localTools.addDir') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="state.allowedDirs.length === 0" class="empty-note">
|
||||
{{ state.failClosed ? t('settings.localTools.emptyFailClosed') : t('settings.localTools.emptyFailOpen') }}
|
||||
</div>
|
||||
|
||||
<ul v-else class="dir-list">
|
||||
<li v-for="dir in state.allowedDirs" :key="dir" class="dir-row">
|
||||
<span class="dir-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</span>
|
||||
<span class="dir-path">{{ dir }}</span>
|
||||
<button class="btn-remove" :disabled="busy" @click="onRemoveDir(dir)">
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import type { LocalToolsState } from '@/types/desktop'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDesktop = typeof window !== 'undefined' && !!window.mateClawAPI
|
||||
|
||||
const busy = ref(false)
|
||||
const state = reactive<LocalToolsState>({
|
||||
enabled: false,
|
||||
allowedDirs: [],
|
||||
failClosed: true,
|
||||
connected: false,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isDesktop) void refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const cfg = await window.mateClawAPI!.getLocalToolsConfig()
|
||||
state.enabled = cfg.enabled
|
||||
state.allowedDirs = cfg.allowedDirs ?? []
|
||||
state.failClosed = cfg.failClosed
|
||||
state.connected = cfg.connected
|
||||
} catch {
|
||||
mcToast.error(t('settings.localTools.loadFail'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEnabled(e: Event) {
|
||||
const enabled = (e.target as HTMLInputElement).checked
|
||||
busy.value = true
|
||||
try {
|
||||
await window.mateClawAPI!.setLocalToolsConfig({ enabled })
|
||||
await refresh()
|
||||
} catch {
|
||||
mcToast.error(t('settings.localTools.saveFail'))
|
||||
await refresh()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddDir() {
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await window.mateClawAPI!.addLocalToolsDir()
|
||||
await refresh()
|
||||
if (result.added) mcToast.success(t('settings.localTools.addSuccess'))
|
||||
} catch {
|
||||
mcToast.error(t('settings.localTools.saveFail'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveDir(dir: string) {
|
||||
const ok = await mcConfirm({
|
||||
title: t('settings.localTools.removeTitle'),
|
||||
message: t('settings.localTools.removeConfirm', { dir }),
|
||||
confirmText: t('common.delete'),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
busy.value = true
|
||||
try {
|
||||
await window.mateClawAPI!.removeLocalToolsDir(dir)
|
||||
await refresh()
|
||||
mcToast.success(t('settings.localTools.removeSuccess'))
|
||||
} catch {
|
||||
mcToast.error(t('settings.localTools.saveFail'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-section { width: 100%; }
|
||||
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
|
||||
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
|
||||
|
||||
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124,63,30,0.04); width: 100%; }
|
||||
.settings-card + .settings-card { margin-top: 16px; }
|
||||
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.setting-item:last-child { border-bottom: none; }
|
||||
.setting-info { flex: 1; }
|
||||
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
|
||||
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
|
||||
.setting-control { display: flex; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.status-chip { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; padding: 5px 12px; border-radius: 999px; background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
|
||||
.status-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.status-chip.ok { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
|
||||
.dirs-card .dirs-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding-bottom: 14px; }
|
||||
.empty-note { padding: 14px 4px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
|
||||
|
||||
.dir-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.dir-row { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border: 1px solid var(--mc-border-light); border-radius: 12px; background: var(--mc-bg-sunken); }
|
||||
.dir-icon { display: inline-flex; color: var(--mc-accent); flex-shrink: 0; }
|
||||
.dir-path { flex: 1; font-family: var(--mc-font-mono); font-size: 13px; color: var(--mc-text-primary); word-break: break-all; }
|
||||
|
||||
.btn-primary { border: none; border-radius: 10px; padding: 9px 16px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; white-space: nowrap; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-remove { border: 1px solid var(--mc-border); border-radius: 8px; padding: 5px 12px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--mc-bg-elevated); color: var(--mc-danger); flex-shrink: 0; }
|
||||
.btn-remove:hover { border-color: var(--mc-danger); background: var(--mc-danger); color: white; }
|
||||
.btn-remove:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@ -44,6 +44,21 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item-vertical">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.fields.workspaceStorageRoot') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.hints.workspaceStorageRoot') }}</div>
|
||||
</div>
|
||||
<div class="setting-control-full">
|
||||
<input
|
||||
v-model.trim="settings.workspaceStorageRoot"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="t('settings.hints.workspaceStorageRootPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索服务配置 -->
|
||||
@ -279,6 +294,7 @@ import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { settingsApi } from '@/api'
|
||||
import { applyLocale } from '@/i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
||||
import { buildProviderOptions, builtinFallbackCatalog, resolveDefaultExpandedId, resolveSourceLabelKey } from '@/composables/useSearchProviderCatalog'
|
||||
import type { SystemSettings, SearchProviderCatalog } from '@/types'
|
||||
@ -329,6 +345,7 @@ const settings = reactive<SystemSettings>({
|
||||
language: 'zh-CN',
|
||||
streamEnabled: true,
|
||||
debugMode: false,
|
||||
workspaceStorageRoot: '',
|
||||
searchEnabled: true,
|
||||
searchProvider: 'serper',
|
||||
searchFallbackEnabled: false,
|
||||
@ -366,7 +383,13 @@ async function onSaveSettings() {
|
||||
if (weixinoaAppSecretInput.value) {
|
||||
payload.weixinoaAppSecret = weixinoaAppSecretInput.value
|
||||
}
|
||||
await settingsApi.update(payload)
|
||||
try {
|
||||
await settingsApi.update(payload)
|
||||
} catch (e: any) {
|
||||
// Surface backend validation failures (e.g. invalid storage path).
|
||||
mcToast.error(e?.response?.data?.msg || e?.message || t('settings.messages.saveFailed'))
|
||||
return
|
||||
}
|
||||
await applyLocale(settings.language)
|
||||
// 重新加载以获取最新脱敏值
|
||||
await loadSettings()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user